314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { IoChevronForward, IoDiscOutline, IoPulseOutline } from "react-icons/io5";
|
|
import { useCart } from "@/components/cart/CartContext";
|
|
import type { MatchedGroup, MatchedSubgroup, MatchedVariant } from "@/lib/variants";
|
|
|
|
const FORMAT_PREF_KEY = "trptk_format_pref";
|
|
const DISC_FALLBACKS: Record<string, string> = { cd: "sacd", sacd: "cd" };
|
|
|
|
type FormatPref = {
|
|
groupSlug: string;
|
|
subgroupSlug: string | null;
|
|
variantSlug: string;
|
|
};
|
|
|
|
type Props = {
|
|
groups: MatchedGroup[];
|
|
releaseDate?: string;
|
|
};
|
|
|
|
const groupIcons: Record<string, React.ReactNode> = {
|
|
physical: <IoDiscOutline />,
|
|
digital: <IoPulseOutline />,
|
|
};
|
|
|
|
/** Strip the subgroup prefix (e.g. "Stereo " / "Surround ") from a variant title when redundant. */
|
|
function shortVariantTitle(title: string, subgroup: MatchedSubgroup | null): string {
|
|
if (!subgroup) return title;
|
|
const prefix = subgroup.label + " ";
|
|
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
|
}
|
|
|
|
function formatPrice(amount: number, currencyCode: string) {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: currencyCode,
|
|
minimumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
const fadeVariants = {
|
|
initial: { opacity: 0, y: 6 },
|
|
animate: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.25 },
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
y: -6,
|
|
transition: { type: "tween" as const, ease: "easeInOut" as const, duration: 0.15 },
|
|
},
|
|
};
|
|
|
|
const optionBase =
|
|
"relative z-10 w-full rounded-xl border bg-lightbg p-4 shadow-lg text-center transition-all hover:border-lightline-hover hover:text-trptkblue dark:bg-darkbg dark:hover:border-darkline-hover dark:hover:text-white duration-200 border-lightline dark:border-darkline text-lighttext dark:text-darktext ease-in-out";
|
|
|
|
const optionSelected =
|
|
"relative z-10 w-full border-trptkblue bg-[oklch(0.921_0.012_252.4)] text-trptkblue dark:border-white dark:bg-[oklch(0.335_0.021_262.52)] dark:text-white rounded-xl border p-4 shadow-lg text-center transition-all duration-200 ease-in-out text-lighttext dark:text-darktext";
|
|
|
|
export function FormatSelector({ groups, releaseDate }: Props) {
|
|
const { addItem, isAdding } = useCart();
|
|
const isPreorder = releaseDate ? new Date(releaseDate) > new Date() : false;
|
|
|
|
// Navigation state
|
|
const [selectedGroup, setSelectedGroup] = useState<MatchedGroup | null>(null);
|
|
const [selectedSubgroup, setSelectedSubgroup] = useState<MatchedSubgroup | null>(null);
|
|
const [selectedVariant, setSelectedVariant] = useState<MatchedVariant | null>(null);
|
|
|
|
const reset = useCallback(() => {
|
|
setSelectedGroup(null);
|
|
setSelectedSubgroup(null);
|
|
setSelectedVariant(null);
|
|
}, []);
|
|
|
|
// Restore last-used format preference on mount
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem(FORMAT_PREF_KEY);
|
|
if (!raw) return;
|
|
const pref: FormatPref = JSON.parse(raw);
|
|
|
|
const group = groups.find((g) => g.slug === pref.groupSlug);
|
|
if (!group) return;
|
|
|
|
if (pref.subgroupSlug && group.subgroups) {
|
|
const subgroup = group.subgroups.find((sg) => sg.slug === pref.subgroupSlug);
|
|
if (!subgroup) return;
|
|
const variant = subgroup.variants.find((v) => v.slug === pref.variantSlug);
|
|
if (!variant) return;
|
|
setSelectedGroup(group);
|
|
setSelectedSubgroup(subgroup);
|
|
setSelectedVariant(variant);
|
|
} else if (group.variants) {
|
|
const variant =
|
|
group.variants.find((v) => v.slug === pref.variantSlug) ??
|
|
group.variants.find((v) => v.slug === DISC_FALLBACKS[pref.variantSlug]);
|
|
if (!variant) return;
|
|
setSelectedGroup(group);
|
|
setSelectedVariant(variant);
|
|
}
|
|
} catch {}
|
|
}, [groups]);
|
|
|
|
const goToGroup = useCallback((group: MatchedGroup) => {
|
|
setSelectedGroup(group);
|
|
setSelectedSubgroup(null);
|
|
setSelectedVariant(null);
|
|
|
|
// If the group has direct variants (Physical) and only one, auto-select
|
|
if (group.variants && group.variants.length === 1) {
|
|
setSelectedVariant(group.variants[0]);
|
|
}
|
|
}, []);
|
|
|
|
const goToSubgroup = useCallback((subgroup: MatchedSubgroup) => {
|
|
setSelectedSubgroup(subgroup);
|
|
setSelectedVariant(null);
|
|
|
|
if (subgroup.variants.length === 1) {
|
|
setSelectedVariant(subgroup.variants[0]);
|
|
}
|
|
}, []);
|
|
|
|
const handleAddToCart = useCallback(async () => {
|
|
if (!selectedVariant) return;
|
|
await addItem(selectedVariant.medusaVariantId);
|
|
|
|
// Save selection path so the next release page pre-selects the same format
|
|
const pref: FormatPref = {
|
|
groupSlug: selectedGroup?.slug ?? groups[0]?.slug ?? "",
|
|
subgroupSlug: selectedSubgroup?.slug ?? null,
|
|
variantSlug: selectedVariant.slug,
|
|
};
|
|
try {
|
|
localStorage.setItem(FORMAT_PREF_KEY, JSON.stringify(pref));
|
|
} catch {}
|
|
}, [selectedVariant, selectedGroup, selectedSubgroup, groups, addItem]);
|
|
|
|
if (groups.length === 0) return null;
|
|
|
|
// Auto-select if only one group
|
|
const effectiveGroup = groups.length === 1 ? groups[0] : selectedGroup;
|
|
const showGroupStep = groups.length > 1 && !selectedGroup;
|
|
|
|
// Determine current step for breadcrumb
|
|
const breadcrumbs: { label: string; onClick?: () => void }[] = [];
|
|
if (groups.length > 1) {
|
|
breadcrumbs.push({
|
|
label: "Format",
|
|
onClick: selectedGroup ? reset : undefined,
|
|
});
|
|
}
|
|
if (effectiveGroup) {
|
|
breadcrumbs.push({
|
|
label: effectiveGroup.label,
|
|
onClick: selectedSubgroup
|
|
? () => {
|
|
setSelectedSubgroup(null);
|
|
setSelectedVariant(null);
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
if (selectedSubgroup) {
|
|
breadcrumbs.push({ label: selectedSubgroup.label });
|
|
}
|
|
|
|
// Determine what to show
|
|
const showPhysicalVariants = effectiveGroup && effectiveGroup.variants && !showGroupStep;
|
|
const showSubgroups =
|
|
effectiveGroup && effectiveGroup.subgroups && !selectedSubgroup && !showGroupStep;
|
|
const showDigitalVariants = selectedSubgroup != null;
|
|
|
|
// Which variants to display?
|
|
const displayVariants = showPhysicalVariants
|
|
? effectiveGroup!.variants!
|
|
: showDigitalVariants
|
|
? selectedSubgroup!.variants
|
|
: null;
|
|
|
|
// Subgroups to display
|
|
const displaySubgroups = showSubgroups ? effectiveGroup!.subgroups! : null;
|
|
|
|
return (
|
|
<div className="mt-8">
|
|
{/* Breadcrumbs */}
|
|
{breadcrumbs.length > 0 && (
|
|
<nav className="mb-4 flex items-center gap-1 text-xs text-lightsec dark:text-darksec">
|
|
{breadcrumbs.map((bc, i) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
{i > 0 && <IoChevronForward className="opacity-50" />}
|
|
{bc.onClick ? (
|
|
<button
|
|
type="button"
|
|
onClick={bc.onClick}
|
|
className="transition-colors duration-200 hover:text-trptkblue dark:hover:text-white"
|
|
>
|
|
{bc.label}
|
|
</button>
|
|
) : (
|
|
<span className="text-lighttext dark:text-darktext">{bc.label}</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</nav>
|
|
)}
|
|
|
|
<AnimatePresence mode="wait">
|
|
{/* Step 1: Group selection (Physical / Digital) */}
|
|
{showGroupStep && (
|
|
<motion.div key="groups" className="grid grid-cols-2 gap-4 text-sm" {...fadeVariants}>
|
|
{groups.map((group) => (
|
|
<button
|
|
key={group.slug}
|
|
type="button"
|
|
onClick={() => goToGroup(group)}
|
|
className={optionBase + " flex flex-col items-center justify-center gap-1.5 py-3"}
|
|
>
|
|
<span className="text-base sm:text-lg md:text-xl">{groupIcons[group.slug]}</span>
|
|
<span className="">{group.label}</span>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Subgroups (Stereo, Surround, Immersive, Video) */}
|
|
{displaySubgroups && (
|
|
<motion.div key="subgroups" className="grid grid-cols-2 gap-4" {...fadeVariants}>
|
|
{displaySubgroups.map((sg) => (
|
|
<button
|
|
key={sg.slug}
|
|
type="button"
|
|
onClick={() => goToSubgroup(sg)}
|
|
className={optionBase + " flex flex-col items-center justify-center gap-0.5 py-3"}
|
|
>
|
|
<span className="text-sm">{sg.label}</span>
|
|
<span className="text-xs text-lightsec dark:text-darksec">
|
|
{sg.variants.length} {sg.variants.length === 1 ? "format" : "formats"}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Variant selection (final step — pick a specific format) */}
|
|
{displayVariants && (
|
|
<motion.div key="variants" className="grid grid-cols-2 gap-4" {...fadeVariants}>
|
|
{displayVariants.map((v) => (
|
|
<button
|
|
key={v.slug}
|
|
type="button"
|
|
onClick={() => setSelectedVariant(v)}
|
|
className={
|
|
(selectedVariant?.slug === v.slug ? optionSelected : optionBase) +
|
|
" flex flex-col items-center justify-center gap-0.5 py-3"
|
|
}
|
|
>
|
|
<span className="text-sm break-words">
|
|
{shortVariantTitle(v.title, selectedSubgroup)}
|
|
</span>
|
|
<span className="text-xs text-lightsec dark:text-darksec">
|
|
{formatPrice(v.price, v.currencyCode)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Add to Cart */}
|
|
<AnimatePresence>
|
|
{selectedVariant && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{
|
|
opacity: 1,
|
|
height: "auto",
|
|
transition: { duration: 0.2, ease: "easeInOut" },
|
|
}}
|
|
exit={{ opacity: 0, height: 0, transition: { duration: 0.15, ease: "easeInOut" } }}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleAddToCart}
|
|
disabled={isAdding}
|
|
className="mt-4 flex w-full items-center justify-center gap-2 rounded-xl bg-trptkblue p-4 font-silkasb text-sm text-white shadow-lg transition-all duration-200 ease-in-out hover:opacity-90 disabled:pointer-events-none disabled:opacity-50 dark:bg-white dark:text-lighttext"
|
|
>
|
|
{isAdding
|
|
? isPreorder
|
|
? "Pre-ordering…"
|
|
: "Adding…"
|
|
: isPreorder
|
|
? "Pre-order"
|
|
: "Add to Cart"}
|
|
</button>
|
|
{isPreorder && releaseDate && (
|
|
<p className="mt-2 text-xs text-lightsec dark:text-darksec">
|
|
Available{" "}
|
|
{new Intl.DateTimeFormat("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
}).format(new Date(releaseDate))}
|
|
</p>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|