Initial ecommerce standalone package
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delayMs = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export type GenStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||
|
||||
export interface UseGenerationStatusReturn {
|
||||
status: GenStatus;
|
||||
error: string | null;
|
||||
abortRef: { current: boolean };
|
||||
start: () => void;
|
||||
succeed: () => void;
|
||||
fail: (msg: string) => void;
|
||||
reset: () => void;
|
||||
cancel: () => void;
|
||||
isGenerating: boolean;
|
||||
isFailed: boolean;
|
||||
isIdle: boolean;
|
||||
}
|
||||
|
||||
export function useGenerationStatus(): UseGenerationStatusReturn {
|
||||
const [status, setStatus] = useState<GenStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setStatus("generating");
|
||||
setError(null);
|
||||
abortRef.current = false;
|
||||
}, []);
|
||||
|
||||
const succeed = useCallback(() => setStatus("done"), []);
|
||||
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
|
||||
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
|
||||
const cancel = useCallback(() => { abortRef.current = true; }, []);
|
||||
|
||||
return {
|
||||
status, error, abortRef, start, succeed, fail, reset, cancel,
|
||||
isGenerating: status === "generating",
|
||||
isFailed: status === "failed",
|
||||
isIdle: status === "idle",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useRef, useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { GenerationQueueItem } from "../stores/useGenerationStore";
|
||||
import { useGenerationStore } from "../stores/useGenerationStore";
|
||||
import {
|
||||
startBackgroundPolling,
|
||||
subscribeToTaskUpdates,
|
||||
} from "../services/backgroundTaskRunner";
|
||||
|
||||
interface UseGenerationTasksOptions {
|
||||
sourceView: string;
|
||||
autoResume?: boolean;
|
||||
}
|
||||
|
||||
export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
||||
const { sourceView, autoResume = true } = options;
|
||||
const {
|
||||
queue,
|
||||
addTask,
|
||||
updateTask: updateStoredTask,
|
||||
getRunningTasks,
|
||||
} = useGenerationStore(useShallow((s) => ({
|
||||
queue: s.queue,
|
||||
addTask: s.addTask,
|
||||
updateTask: s.updateTask,
|
||||
getRunningTasks: s.getRunningTasks,
|
||||
})));
|
||||
const pollingStartedRef = useRef(false);
|
||||
|
||||
// ── Auto-resume: re-subscribe to running tasks on mount ────
|
||||
useEffect(() => {
|
||||
if (!autoResume || pollingStartedRef.current) return;
|
||||
pollingStartedRef.current = true;
|
||||
|
||||
const active = getRunningTasks().filter((t) => t.sourceView === sourceView);
|
||||
if (active.length > 0) {
|
||||
startBackgroundPolling();
|
||||
}
|
||||
|
||||
return () => {
|
||||
pollingStartedRef.current = false;
|
||||
};
|
||||
}, [autoResume, sourceView, getRunningTasks]);
|
||||
|
||||
// ── Subscribe to live updates ───────────────────────────
|
||||
useEffect(() => {
|
||||
return subscribeToTaskUpdates((updated) => {
|
||||
updateStoredTask(updated.id, updated);
|
||||
});
|
||||
}, [updateStoredTask]);
|
||||
|
||||
// ── View-scoped computed lists ──────────────────────────
|
||||
const myTasks = useMemo(
|
||||
() => queue.filter((t) => t.sourceView === sourceView),
|
||||
[queue, sourceView],
|
||||
);
|
||||
|
||||
const activeTasks = useMemo(
|
||||
() => myTasks.filter((t) => t.status === "running" || t.status === "pending"),
|
||||
[myTasks],
|
||||
);
|
||||
|
||||
const completedTasks = useMemo(
|
||||
() => myTasks.filter((t) => t.status === "completed"),
|
||||
[myTasks],
|
||||
);
|
||||
|
||||
const failedTasks = useMemo(
|
||||
() => myTasks.filter((t) => t.status === "failed"),
|
||||
[myTasks],
|
||||
);
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────
|
||||
const submitTask = useCallback(
|
||||
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
|
||||
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
addTask({ ...task, id, createdAt: Date.now() });
|
||||
return id;
|
||||
},
|
||||
[addTask],
|
||||
);
|
||||
|
||||
const updateTask = useCallback(
|
||||
(id: string, patch: Partial<GenerationQueueItem>) => {
|
||||
updateStoredTask(id, patch);
|
||||
},
|
||||
[updateStoredTask],
|
||||
);
|
||||
|
||||
const markCompleted = useCallback(
|
||||
(id: string, resultUrl: string) => {
|
||||
updateStoredTask(id, { status: "completed", progress: 100, resultUrl });
|
||||
},
|
||||
[updateStoredTask],
|
||||
);
|
||||
|
||||
const markFailed = useCallback(
|
||||
(id: string, error: string) => {
|
||||
updateStoredTask(id, { status: "failed", error });
|
||||
},
|
||||
[updateStoredTask],
|
||||
);
|
||||
|
||||
const retryTask = useCallback(
|
||||
(id: string) => {
|
||||
const task = queue.find((t) => t.id === id);
|
||||
if (task) {
|
||||
updateStoredTask(id, { status: "pending", progress: 0, error: null });
|
||||
}
|
||||
},
|
||||
[queue, updateStoredTask],
|
||||
);
|
||||
|
||||
return {
|
||||
tasks: myTasks,
|
||||
activeTasks,
|
||||
completedTasks,
|
||||
failedTasks,
|
||||
submitTask,
|
||||
updateTask,
|
||||
markCompleted,
|
||||
markFailed,
|
||||
retryTask,
|
||||
hasActiveTasks: activeTasks.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useScrollEntrance<T extends HTMLElement>(threshold = 0.15) {
|
||||
const ref = useRef<T>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return { ref, isVisible };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
|
||||
|
||||
interface SmoothedProgressOptions {
|
||||
creepSpeed?: number;
|
||||
chaseRate?: number;
|
||||
creepCeiling?: number;
|
||||
creepAhead?: number;
|
||||
completionDuration?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CREEP_SPEED = 0.5;
|
||||
const DEFAULT_CHASE_RATE = 0.09;
|
||||
const DEFAULT_CREEP_CEILING = 97;
|
||||
const DEFAULT_CREEP_AHEAD = 25;
|
||||
const DEFAULT_COMPLETION_DURATION = 450;
|
||||
|
||||
function isTerminal(status: ProgressStatus): boolean {
|
||||
return status === "completed" || status === "success" || status === "failed" || status === "error";
|
||||
}
|
||||
|
||||
function isSuccess(status: ProgressStatus): boolean {
|
||||
return status === "completed" || status === "success";
|
||||
}
|
||||
|
||||
/**
|
||||
* Smoothly interpolates between coarse server-reported progress values.
|
||||
* On completion, animates quickly to 100% instead of jumping.
|
||||
*/
|
||||
export function useSmoothedProgress(
|
||||
realProgress: number,
|
||||
status: ProgressStatus,
|
||||
options?: SmoothedProgressOptions,
|
||||
): number {
|
||||
const creepSpeed = options?.creepSpeed ?? DEFAULT_CREEP_SPEED;
|
||||
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
||||
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
||||
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
||||
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
||||
|
||||
const [displayed, setDisplayed] = useState(0);
|
||||
const rafRef = useRef(0);
|
||||
const targetRef = useRef(realProgress);
|
||||
const statusRef = useRef(status);
|
||||
const completionStartRef = useRef<number | null>(null);
|
||||
const completionBaseRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current = realProgress;
|
||||
statusRef.current = status;
|
||||
|
||||
if (isSuccess(status) && completionStartRef.current === null) {
|
||||
completionStartRef.current = performance.now();
|
||||
completionBaseRef.current = displayed;
|
||||
}
|
||||
}, [realProgress, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "failed" || status === "error") {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
let lastFrame = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const dt = Math.min((now - lastFrame) / 1000, 0.1);
|
||||
lastFrame = now;
|
||||
|
||||
setDisplayed((current) => {
|
||||
const currentStatus = statusRef.current;
|
||||
|
||||
if (isSuccess(currentStatus)) {
|
||||
const start = completionStartRef.current ?? now;
|
||||
const elapsed = now - start;
|
||||
const t = Math.min(elapsed / completionDuration, 1);
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
const base = completionBaseRef.current;
|
||||
return base + (100 - base) * eased;
|
||||
}
|
||||
|
||||
if (isTerminal(currentStatus)) return current;
|
||||
|
||||
const target = targetRef.current;
|
||||
|
||||
if (current >= target) {
|
||||
const ceiling = Math.min(target + creepAhead, creepCeiling);
|
||||
if (current >= ceiling) return current;
|
||||
return current + creepSpeed * dt;
|
||||
}
|
||||
|
||||
const gap = target - current;
|
||||
const step = (gap * chaseRate + 0.3) * dt * 60;
|
||||
return Math.min(current + step, target);
|
||||
});
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
|
||||
|
||||
if (status === "failed" || status === "error") return Math.round(displayed);
|
||||
if (isSuccess(status) && displayed >= 99.5) return 100;
|
||||
return Math.round(Math.max(0, Math.min(99, displayed)));
|
||||
}
|
||||
Reference in New Issue
Block a user