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

379 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IoCloseOutline } from "react-icons/io5";
import {
HiOutlineChevronDoubleLeft,
HiOutlineChevronLeft,
HiOutlineChevronRight,
HiOutlineChevronDoubleRight,
} from "react-icons/hi2";
import { SortDropdown, type SortOption } from "@/components/SortDropdown";
import { FilterDropdown, type FilterGroup } from "@/components/FilterDropdown";
import { IconButton } from "@/components/IconButton";
import { CARD_GRID_CLASSES_3 } from "@/lib/constants";
import {
DownloadCard,
UpcomingCard,
type DownloadCardData,
type UpcomingCardData,
} from "./DownloadCard";
const PAGE_SIZE = 12;
// ── Sort ────────────────────────────────────────────────────────────
type SortMode =
| "titleAsc"
| "titleDesc"
| "catalogNoAsc"
| "catalogNoDesc"
| "releaseDateAsc"
| "releaseDateDesc";
const SORT_OPTIONS: SortOption<SortMode>[] = [
{ value: "titleAsc", label: "Title", iconDirection: "asc" },
{ value: "titleDesc", label: "Title", iconDirection: "desc" },
{ value: "catalogNoAsc", label: "Cat. No.", iconDirection: "asc" },
{ value: "catalogNoDesc", label: "Cat. No.", iconDirection: "desc" },
{ value: "releaseDateAsc", label: "Release date", iconDirection: "asc" },
{ value: "releaseDateDesc", label: "Release date", iconDirection: "desc" },
];
// ── Filters ─────────────────────────────────────────────────────────
const GENRE_OPTIONS = [
{ value: "earlyMusic", label: "Early Music" },
{ value: "baroque", label: "Baroque" },
{ value: "classical", label: "Classical" },
{ value: "romantic", label: "Romantic" },
{ value: "contemporary", label: "Contemporary" },
{ value: "worldMusic", label: "World Music" },
{ value: "jazz", label: "Jazz" },
{ value: "crossover", label: "Crossover" },
{ value: "electronic", label: "Electronic" },
{ value: "minimal", label: "Minimal" },
{ value: "popRock", label: "Pop / Rock" },
];
const INSTRUMENTATION_OPTIONS = [
{ value: "solo", label: "Solo" },
{ value: "chamber", label: "Chamber" },
{ value: "ensemble", label: "Ensemble" },
{ value: "orchestra", label: "Orchestral" },
{ value: "vocalChoral", label: "Vocal / Choral" },
];
const FILTER_GROUPS: FilterGroup[] = [
{ label: "Genre", param: "genre", options: GENRE_OPTIONS },
{ label: "Instrumentation", param: "instrumentation", options: INSTRUMENTATION_OPTIONS },
];
// ── Helpers ─────────────────────────────────────────────────────────
type Sortable = {
product_title: string;
catalogNo?: string | null;
releaseDate?: string | null;
};
function compareStr(a?: string | null, b?: string | null): number {
return (a ?? "").localeCompare(b ?? "", undefined, { sensitivity: "base" });
}
function compareDate(a?: string | null, b?: string | null, asc = true): number {
const da = a ?? (asc ? "9999-12-31" : "0000-01-01");
const db = b ?? (asc ? "9999-12-31" : "0000-01-01");
return da < db ? -1 : da > db ? 1 : 0;
}
function sortItems<T extends Sortable>(list: T[], mode: SortMode): T[] {
return [...list].sort((a, b) => {
switch (mode) {
case "titleAsc":
return compareStr(a.product_title, b.product_title);
case "titleDesc":
return compareStr(b.product_title, a.product_title);
case "catalogNoAsc":
return compareStr(a.catalogNo, b.catalogNo) || compareStr(a.product_title, b.product_title);
case "catalogNoDesc":
return compareStr(b.catalogNo, a.catalogNo) || compareStr(a.product_title, b.product_title);
case "releaseDateAsc":
return (
compareDate(a.releaseDate, b.releaseDate, true) ||
compareStr(a.product_title, b.product_title)
);
case "releaseDateDesc":
return (
compareDate(b.releaseDate, a.releaseDate, false) ||
compareStr(a.product_title, b.product_title)
);
}
});
}
type Searchable = {
product_title: string;
albumArtist?: string | null;
catalogNo?: string | null;
};
function matchesSearch(item: Searchable, lower: string): boolean {
return (
item.product_title.toLowerCase().includes(lower) ||
(item.albumArtist?.toLowerCase().includes(lower) ?? false) ||
(item.catalogNo?.toLowerCase().includes(lower) ?? false)
);
}
type Filterable = {
genre?: string[];
instrumentation?: string[];
};
function matchesFilters(item: Filterable, filters: Record<string, string[]>): boolean {
const genres = filters.genre ?? [];
const instrumentations = filters.instrumentation ?? [];
if (genres.length > 0 && !genres.some((g) => item.genre?.includes(g))) return false;
if (
instrumentations.length > 0 &&
!instrumentations.some((i) => item.instrumentation?.includes(i))
)
return false;
return true;
}
// ── Component ───────────────────────────────────────────────────────
export function DownloadsTab() {
const [downloads, setDownloads] = useState<DownloadCardData[]>([]);
const [upcoming, setUpcoming] = useState<UpcomingCardData[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState("");
const [sort, setSort] = useState<SortMode>("releaseDateDesc");
const [filters, setFilters] = useState<Record<string, string[]>>({});
const [dlPage, setDlPage] = useState(1);
const [upPage, setUpPage] = useState(1);
useEffect(() => {
async function fetchDownloads() {
try {
const res = await fetch("/api/account/downloads");
if (res.ok) {
const data = await res.json();
if (data.downloads?.length) setDownloads(data.downloads);
if (data.upcoming?.length) setUpcoming(data.upcoming);
}
} catch {
// Silently fail
} finally {
setLoading(false);
}
}
fetchDownloads();
}, []);
const resetPages = useCallback(() => {
setDlPage(1);
setUpPage(1);
}, []);
const handleFilterChange = useCallback((param: string, selected: string[]) => {
setFilters((prev) => ({ ...prev, [param]: selected }));
setDlPage(1);
setUpPage(1);
}, []);
const handleSortChange = useCallback(
(value: SortMode) => {
setSort(value);
resetPages();
},
[resetPages],
);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQ(e.target.value);
resetPages();
},
[resetPages],
);
const clearSearch = useCallback(() => {
setQ("");
resetPages();
}, [resetPages]);
const filteredDownloads = useMemo(() => {
const lower = q.trim().toLowerCase();
let result = downloads;
if (lower) result = result.filter((d) => matchesSearch(d, lower));
result = result.filter((d) => matchesFilters(d, filters));
return sortItems(result, sort);
}, [downloads, q, sort, filters]);
const filteredUpcoming = useMemo(() => {
const lower = q.trim().toLowerCase();
let result = upcoming;
if (lower) result = result.filter((u) => matchesSearch(u, lower));
result = result.filter((u) => matchesFilters(u, filters));
return sortItems(result, sort);
}, [upcoming, q, sort, filters]);
const dlTotalPages = Math.max(1, Math.ceil(filteredDownloads.length / PAGE_SIZE));
const dlSafePage = Math.min(dlPage, dlTotalPages);
const dlStart = (dlSafePage - 1) * PAGE_SIZE;
const paginatedDownloads = filteredDownloads.slice(dlStart, dlStart + PAGE_SIZE);
const upTotalPages = Math.max(1, Math.ceil(filteredUpcoming.length / PAGE_SIZE));
const upSafePage = Math.min(upPage, upTotalPages);
const upStart = (upSafePage - 1) * PAGE_SIZE;
const paginatedUpcoming = filteredUpcoming.slice(upStart, upStart + PAGE_SIZE);
if (loading) {
return <p className="py-12 text-center text-lightsec dark:text-darksec">Loading downloads</p>;
}
if (downloads.length === 0 && upcoming.length === 0) {
return (
<p className="py-12 text-center text-lightsec dark:text-darksec">
No downloads available. Your digital purchases will appear here.
</p>
);
}
const totalFiltered = filteredDownloads.length + filteredUpcoming.length;
return (
<div>
{/* Count */}
<div className="mb-4">
<p className="text-sm text-lightsec dark:text-darksec">
{totalFiltered ? `${totalFiltered} release(s)` : "No releases found."}
</p>
</div>
{/* Search + Filter + Sort bar */}
<div className="mb-12 flex items-center gap-3">
<input
value={q}
onChange={handleSearchChange}
placeholder="Search releases…"
className="no-ring w-full rounded-xl border border-lightline px-6 py-3 text-lighttext shadow-lg transition-all duration-200 ease-in-out hover:border-lightline-hover hover:text-trptkblue focus:border-lightline-focus dark:border-darkline dark:text-darktext dark:hover:border-darkline-hover dark:hover:text-white dark:focus:border-darkline-focus"
aria-label="Search releases"
/>
{q ? (
<IconButton onClick={clearSearch} className="text-lg" aria-label="Clear search">
<IoCloseOutline />
</IconButton>
) : null}
<FilterDropdown groups={FILTER_GROUPS} values={filters} onChange={handleFilterChange} />
<SortDropdown
options={SORT_OPTIONS}
value={sort}
onChange={handleSortChange}
ariaLabel="Sort releases"
/>
</div>
{/* Available Downloads */}
{paginatedDownloads.length > 0 && (
<section>
<div className={CARD_GRID_CLASSES_3}>
{paginatedDownloads.map((dl) => (
<DownloadCard key={dl.product_title} item={dl} />
))}
</div>
{dlTotalPages > 1 && (
<Pagination page={dlSafePage} totalPages={dlTotalPages} onPageChange={setDlPage} />
)}
</section>
)}
{/* Upcoming Releases */}
{paginatedUpcoming.length > 0 && (
<section className={paginatedDownloads.length > 0 ? "mt-16" : ""}>
<div className="mb-4">
<h2 className="font-silkasb text-sm tracking-wider uppercase">Upcoming Releases</h2>
<p className="mt-1 text-xs text-lightsec dark:text-darksec">
You&apos;ll receive download links by email when these become available.
</p>
</div>
<div className={CARD_GRID_CLASSES_3}>
{paginatedUpcoming.map((up) => (
<UpcomingCard key={up.product_title} item={up} />
))}
</div>
{upTotalPages > 1 && (
<Pagination page={upSafePage} totalPages={upTotalPages} onPageChange={setUpPage} />
)}
</section>
)}
{/* No results after filtering */}
{totalFiltered === 0 && (q || Object.values(filters).some((v) => v.length > 0)) && (
<p className="py-12 text-center text-lightsec dark:text-darksec">No releases found.</p>
)}
</div>
);
}
// ── Pagination ──────────────────────────────────────────────────────
function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
const hasPrev = page > 1;
const hasNext = page < totalPages;
return (
<nav aria-label="Pagination" className="mt-12 flex items-center justify-between">
<div className="text-sm text-lightsec dark:text-darksec">
Page {page} of {totalPages}
</div>
<div className="flex gap-3 text-lg">
<IconButton disabled={!hasPrev} onClick={() => onPageChange(1)} aria-label="First page">
<HiOutlineChevronDoubleLeft />
</IconButton>
<IconButton
disabled={!hasPrev}
onClick={() => onPageChange(page - 1)}
aria-label="Previous page"
>
<HiOutlineChevronLeft />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(page + 1)}
aria-label="Next page"
>
<HiOutlineChevronRight />
</IconButton>
<IconButton
disabled={!hasNext}
onClick={() => onPageChange(totalPages)}
aria-label="Last page"
>
<HiOutlineChevronDoubleRight />
</IconButton>
</div>
</nav>
);
}