trptk/components/release/FormatSelector.tsx
2026-02-24 17:14:07 +01:00

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>
);
}