Files
omniai-web/src/features/ecommerce/EcommerceTemplatesPage.tsx
T

328 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftOutlined,
CheckCircleOutlined,
DeleteOutlined,
PlayCircleOutlined,
SearchOutlined,
ShoppingOutlined,
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/more-tools.css";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import "../../styles/pages/local-theme-parity.css";
import type { WebProjectSummary } from "../../types";
import { useDebounce } from "../../hooks/useDebounce";
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
interface EcommerceTemplatesPageProps {
projects: WebProjectSummary[];
onOpenMore?: () => void;
onOpenEcommerce?: () => void;
onSelectTemplate?: (template: TemplateCase) => void;
onStartCreate?: () => void;
onOpenProject: (project: WebProjectSummary) => void;
onDeleteProject?: (project: WebProjectSummary) => void;
}
const APPLE_CAROUSEL_SLOTS = [-4, -3, -2, -1, 0, 1, 2, 3, 4];
const APPLE_CAROUSEL_TRANSITION_MS = 760;
interface AppleCarouselMotion {
direction: number;
progress: 0 | 1;
}
function getPositiveModulo(value: number, length: number) {
return ((value % length) + length) % length;
}
function getAppleCarouselCardStyle(offset: number): CSSProperties {
const depth = Math.abs(offset);
const direction = Math.sign(offset);
const isActive = depth === 0;
const xByDepth = [0, 286, 456, 610, 735, 840];
const yByDepth = [8, -2, -8, -13, -18, -24];
const rotateByDepth = [0, 0, 0, 0, 0];
const scaleByDepth = [1, 0.98, 0.94, 0.91, 0.88, 0.84];
const x = direction * (xByDepth[depth] ?? xByDepth[xByDepth.length - 1]!);
const y = yByDepth[depth] ?? yByDepth[yByDepth.length - 1]!;
const z = isActive ? 90 : 28 - depth;
const rotateY = 0;
const rotateZ = direction * (rotateByDepth[depth] ?? rotateByDepth[rotateByDepth.length - 1]!);
const scale = scaleByDepth[depth] ?? scaleByDepth[scaleByDepth.length - 1]!;
return {
"--apple-card-offset": offset,
"--apple-card-depth": depth,
"--apple-card-z": 80 - depth,
"--apple-card-x": `${x}px`,
"--apple-card-y": `${y}px`,
"--apple-card-z-offset": `${z}px`,
"--apple-card-rotate-y": `${rotateY}deg`,
"--apple-card-rotate-z": `${rotateZ}deg`,
"--apple-card-scale": String(scale),
"--apple-card-opacity": String(depth > 4 ? 0 : 1),
} as CSSProperties;
}
function EcommerceTemplatesPage({
projects,
onOpenMore,
onOpenEcommerce,
onSelectTemplate,
onStartCreate,
onOpenProject,
onDeleteProject,
}: EcommerceTemplatesPageProps) {
const [activeTemplateCategory, setActiveTemplateCategory] = useState("全部");
const [templateSearch, setTemplateSearch] = useState("");
const debouncedSearch = useDebounce(templateSearch, 300);
const [carouselIndex, setCarouselIndex] = useState(0);
const [carouselMotion, setCarouselMotion] = useState<AppleCarouselMotion | null>(null);
const [carouselIsResetting, setCarouselIsResetting] = useState(false);
const carouselFrameRef = useRef<number | null>(null);
const carouselResetFrameRef = useRef<number | null>(null);
const carouselTimerRef = useRef<number | null>(null);
const filteredTemplates = useMemo(() => {
const keyword = debouncedSearch.trim();
return templateCases.filter((item) => {
const categoryMatches = activeTemplateCategory === "全部" || item.category === activeTemplateCategory;
const keywordMatches =
!keyword || item.title.includes(keyword) || item.summary.includes(keyword) || item.category.includes(keyword);
return categoryMatches && keywordMatches;
});
}, [activeTemplateCategory, debouncedSearch]);
const carouselItems = useMemo(() => templateCarouselCases.slice(0, 5), []);
const carouselSlotOffsets = useMemo(() => {
const direction = carouselMotion?.direction ?? 0;
const minSlot = APPLE_CAROUSEL_SLOTS[0]! + Math.min(direction, 0);
const maxSlot = APPLE_CAROUSEL_SLOTS[APPLE_CAROUSEL_SLOTS.length - 1]! + Math.max(direction, 0);
return Array.from({ length: maxSlot - minSlot + 1 }, (_, index) => minSlot + index);
}, [carouselMotion?.direction]);
const startCarouselShift = useCallback(
(rawDirection: number) => {
const direction = Math.sign(rawDirection);
if (!direction || carouselItems.length <= 1 || carouselMotion) return;
if (carouselFrameRef.current !== null) {
window.cancelAnimationFrame(carouselFrameRef.current);
}
if (carouselTimerRef.current !== null) {
window.clearTimeout(carouselTimerRef.current);
}
if (carouselResetFrameRef.current !== null) {
window.cancelAnimationFrame(carouselResetFrameRef.current);
}
setCarouselIsResetting(false);
setCarouselMotion({ direction, progress: 0 });
carouselFrameRef.current = window.requestAnimationFrame(() => {
carouselFrameRef.current = window.requestAnimationFrame(() => {
setCarouselMotion((current) => (current?.direction === direction ? { direction, progress: 1 } : current));
});
});
carouselTimerRef.current = window.setTimeout(() => {
setCarouselIsResetting(true);
setCarouselIndex((current) => getPositiveModulo(current + direction, carouselItems.length));
setCarouselMotion(null);
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
carouselResetFrameRef.current = window.requestAnimationFrame(() => {
setCarouselIsResetting(false);
});
});
}, APPLE_CAROUSEL_TRANSITION_MS);
},
[carouselItems.length, carouselMotion],
);
useEffect(() => {
if (carouselItems.length <= 1) return undefined;
const intervalId = window.setInterval(() => {
startCarouselShift(-1);
}, 2200);
return () => window.clearInterval(intervalId);
}, [carouselItems.length, startCarouselShift]);
useEffect(
() => () => {
if (carouselFrameRef.current !== null) {
window.cancelAnimationFrame(carouselFrameRef.current);
}
if (carouselTimerRef.current !== null) {
window.clearTimeout(carouselTimerRef.current);
}
if (carouselResetFrameRef.current !== null) {
window.cancelAnimationFrame(carouselResetFrameRef.current);
}
},
[],
);
return (
<section className="image-workbench-page ecommerce-template-page" aria-label="示例模板">
<header className="image-workbench-topbar">
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
</button>
<div className="image-workbench-tool-strip" aria-label="电商工具入口">
<button type="button" onClick={onOpenEcommerce}>
<ShoppingOutlined />
</button>
<button type="button" className="is-active">
<TagsOutlined />
</button>
</div>
</header>
<div className="image-workbench-subbar">
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
<ArrowLeftOutlined />
</button>
<strong></strong>
<div className="image-workbench-camera-summary" aria-label="模板数量">
<strong>{filteredTemplates.length} </strong>
<span> {projects.length}</span>
</div>
</div>
<main className="ecommerce-template-page__scroll omni-commerce-page">
<section className={`ecommerce-template-apple-carousel${carouselIsResetting ? " is-resetting" : ""}`} aria-label="苹果卡片式模板轮播">
<div className="ecommerce-template-apple-carousel__stage" aria-roledescription="carousel">
<div className="ecommerce-template-apple-carousel__deck">
{carouselSlotOffsets.map((slotOffset) => {
const itemIndex = getPositiveModulo(carouselIndex + slotOffset, carouselItems.length);
const item = carouselItems[itemIndex];
const visualOffset = slotOffset - (carouselMotion?.direction ?? 0) * (carouselMotion?.progress ?? 0);
const isActive = visualOffset === 0;
if (!item) return null;
return (
<button
key={slotOffset}
type="button"
className={`ecommerce-template-apple-card${isActive ? " is-active" : ""}`}
style={getAppleCarouselCardStyle(visualOffset)}
aria-label={`套用模板:${item.title}`}
aria-pressed={isActive}
onClick={() => {
if (isActive) {
onSelectTemplate?.(item);
return;
}
startCarouselShift(slotOffset);
}}
>
<img src={item.imageUrl} alt="" />
</button>
);
})}
</div>
</div>
</section>
<section className="omni-commerce-band ecommerce-template-page__band" aria-labelledby="commerce-template-title">
<div className="omni-commerce-section-head">
<div>
<span>
<TagsOutlined />
</span>
<h2 id="commerce-template-title"></h2>
</div>
<label className="omni-commerce-search">
<SearchOutlined />
<input value={templateSearch} placeholder="搜索模板" onChange={(event) => setTemplateSearch(event.target.value)} />
</label>
</div>
<div className="omni-commerce-filter" role="tablist" aria-label="模板分类">
{templateCategories.map((category) => (
<button
key={category}
type="button"
role="tab"
aria-selected={activeTemplateCategory === category}
className={activeTemplateCategory === category ? "is-active" : ""}
onClick={() => setActiveTemplateCategory(category)}
>
{category}
</button>
))}
</div>
<div className="omni-commerce-template-grid motion-stagger">
{filteredTemplates.map((item) => (
<article key={item.title} className="omni-commerce-template">
<button type="button" className="omni-commerce-template__select" onClick={() => onSelectTemplate?.(item)}>
<img src={item.imageUrl} alt={item.title} />
<div>
<span>{item.category}</span>
<strong>{item.title}</strong>
<p>{item.summary}</p>
<em></em>
</div>
</button>
</article>
))}
</div>
</section>
<section className="omni-commerce-band ecommerce-template-page__band omni-commerce-band--projects" aria-labelledby="commerce-project-title">
<div className="omni-commerce-section-head">
<div>
<span>
<CheckCircleOutlined />
</span>
<h2 id="commerce-project-title"></h2>
</div>
<button type="button" className="omni-commerce-text-button" onClick={onStartCreate}>
</button>
</div>
{projects.length ? (
<div className="omni-commerce-project-grid motion-stagger">
{projects.slice(0, 4).map((project, index) => (
<article key={project.id} className="omni-commerce-project">
<button type="button" onClick={() => onOpenProject(project)}>
<img src={project.thumbnailUrl || templateCases[index % templateCases.length]!.imageUrl} alt="" />
<span>{project.description || "最近更新的电商项目"}</span>
<strong>{project.name || `预览项目 ${index + 1}`}</strong>
</button>
{onDeleteProject ? (
<button type="button" className="omni-commerce-project__delete" aria-label={`删除项目 ${project.name}`} onClick={() => onDeleteProject(project)}>
<DeleteOutlined />
</button>
) : null}
</article>
))}
</div>
) : (
<div className="omni-commerce-empty">
<PlayCircleOutlined />
<strong></strong>
<span></span>
</div>
)}
</section>
</main>
<footer className="image-workbench-status">
<span></span>
<p></p>
<em>{activeTemplateCategory}</em>
</footer>
</section>
);
}
export default EcommerceTemplatesPage;