Files
Epicnabbo-Catalogus-Updated…/src/features/catalog/components/packages/catalog-package-preview.tsx
T

484 lines
14 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { useEffect, useId, useMemo, useState } from "react";
import { getCatalogPreviewAssets } from "@/actions/catalog-preview-assets";
import { CurrencyIcon } from "@/components/shared/currency-icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type {
PackageOffer,
PackageSnapshot,
} from "@/features/catalog/domain/packages";
import { getLocalIconUrl } from "@/lib/furni/classname";
import {
buildPackagePreview,
createPreviewAssetLoader,
parsePreviewItemIds,
} from "./catalog-package-preview-model";
type Asset = { id: number; classname: string; name: string };
function PreviewIcon({ asset, missing }: { asset?: Asset; missing: string }) {
const [failed, setFailed] = useState(false);
if (!asset?.classname || failed)
return (
<span className="px-2 text-center text-[10px] text-muted-foreground">
{missing}
</span>
);
return (
<img
src={getLocalIconUrl(asset.classname)}
alt=""
width={64}
height={64}
loading="lazy"
className="h-16 w-16 object-contain"
style={{ imageRendering: "pixelated" }}
onError={() => setFailed(true)}
/>
);
}
function PreviewPrice({ offer }: { offer: PackageOffer }) {
const t = useTranslations("pages.admin.catalogPackagePreview");
if (offer.costCredits < 0 || offer.costPoints < 0)
return (
<span className="text-xs">
{t("specialPricing", {
credits: offer.costCredits,
points: offer.costPoints,
type: offer.pointsType,
})}
</span>
);
return (
<span className="inline-flex flex-wrap items-center gap-2 text-xs">
{offer.costCredits > 0 && (
<span className="inline-flex items-center gap-1">
<CurrencyIcon kind="credits" size={14} alt={t("credits")} />
{offer.costCredits}
</span>
)}
{offer.costPoints > 0 && (
<span className="inline-flex items-center gap-1">
{offer.pointsType === 0 || offer.pointsType === 5 ? (
<CurrencyIcon
kind={offer.pointsType === 5 ? "diamonds" : "duckets"}
size={14}
alt={t(offer.pointsType === 5 ? "diamonds" : "duckets")}
/>
) : (
<span>{t("pointsType", { type: offer.pointsType })}</span>
)}
{offer.costPoints}
</span>
)}
{offer.costCredits === 0 && offer.costPoints === 0 && (
<span>{t("free")}</span>
)}
</span>
);
}
function offerChanged(offer: PackageOffer, baseline?: PackageSnapshot) {
if (!baseline) return false;
const previous = baseline.offers.find((item) => item.id === offer.id);
return (
!previous ||
(Object.keys(offer) as Array<keyof PackageOffer>).some(
(key) => offer[key] !== previous[key],
)
);
}
export function CatalogPackagePreview({
snapshot,
baseline,
}: {
snapshot: PackageSnapshot;
baseline?: PackageSnapshot;
}) {
const t = useTranslations("pages.admin.catalogPackagePreview");
const controlId = useId();
const [query, setQuery] = useState("");
const [profile, setProfile] = useState({
playerMode: true,
rank: 1,
club: false,
vip: false,
});
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
const [selectedOfferId, setSelectedOfferId] = useState<number | null>(null);
const [pagination, setPagination] = useState({ scope: "", index: 0 });
const preview = useMemo(
() => buildPackagePreview(snapshot, profile, query),
[snapshot, profile, query],
);
const selectedPage =
preview.pages.find((page) => page.id === selectedPageId) ??
preview.pages.find((page) => page.id === preview.matchingPageIds[0]);
const normalized = query.trim().toLowerCase();
const offers = preview.offers.filter(
(offer) =>
offer.pageId === selectedPage?.id &&
(!normalized ||
selectedPage.caption.toLowerCase().includes(normalized) ||
`${offer.catalogName} ${offer.id} ${offer.itemIds}`
.toLowerCase()
.includes(normalized)),
);
const scope = `${selectedPage?.id}:${query}`;
const lastIndex = Math.max(0, Math.ceil(offers.length / 48) - 1);
const index =
pagination.scope === scope ? Math.min(pagination.index, lastIndex) : 0;
const visibleOffers = offers.slice(index * 48, (index + 1) * 48);
const selectedOffer = offers.find((offer) => offer.id === selectedOfferId);
const allItemIds = [
...new Set(
[...visibleOffers, ...(selectedOffer ? [selectedOffer] : [])].flatMap(
(offer) => parsePreviewItemIds(offer.itemIds),
),
),
];
const assetKey = allItemIds
.slice(0, 200)
.sort((a, b) => a - b)
.join(",");
const loader = useMemo(
() => createPreviewAssetLoader(getCatalogPreviewAssets),
[],
);
const [assetState, setAssetState] = useState<{
key: string;
status: "ok" | "failed";
assets: Asset[];
}>({ key: "", status: "ok", assets: [] });
useEffect(() => {
if (!assetKey) return;
void loader.run(
assetKey.split(",").map(Number),
(result) =>
setAssetState({
key: assetKey,
status: result.ok ? "ok" : "failed",
assets: result.assets,
}),
() => setAssetState({ key: assetKey, status: "failed", assets: [] }),
);
return () => loader.cancel();
}, [assetKey, loader]);
const assets = new Map(
(assetState.key === assetKey ? assetState.assets : []).map((asset) => [
asset.id,
asset,
]),
);
const genericLayout =
selectedPage &&
[
"default_3x3",
"default_3x3_color_grouping",
"default_3x3_pet",
"default_3x3_wallpaper",
"default_3x3_floor",
"default_3x3_landscape",
].includes(selectedPage.pageLayout);
return (
<section className="min-w-0 space-y-3" aria-label={t("title")}>
<p className="text-sm text-muted-foreground">{t("hint")}</p>
<div className="flex flex-wrap items-end gap-3 rounded-lg border p-3">
<label
htmlFor={`${controlId}-search`}
className="grid min-w-0 flex-1 gap-1 text-xs"
>
<span>{t("search")}</span>
<Input
id={`${controlId}-search`}
value={query}
maxLength={191}
onChange={(event) => {
setQuery(event.target.value);
setSelectedPageId(null);
setSelectedOfferId(null);
}}
/>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={profile.playerMode}
onChange={(event) =>
setProfile({ ...profile, playerMode: event.target.checked })
}
/>
{t("playerMode")}
</label>
<label htmlFor={`${controlId}-rank`} className="grid gap-1 text-xs">
<span>{t("rank")}</span>
<Input
id={`${controlId}-rank`}
type="number"
min={0}
max={100}
value={profile.rank}
className="w-20"
disabled={!profile.playerMode}
onChange={(event) =>
setProfile({
...profile,
rank: Math.min(
100,
Math.max(0, Number(event.target.value) || 0),
),
})
}
/>
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
disabled={!profile.playerMode}
checked={profile.club}
onChange={(event) =>
setProfile({ ...profile, club: event.target.checked })
}
/>
{t("club")}
</label>
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
disabled={!profile.playerMode}
checked={profile.vip}
onChange={(event) =>
setProfile({ ...profile, vip: event.target.checked })
}
/>
{t("vip")}
</label>
</div>
{preview.externalParents && (
<p className="text-xs text-muted-foreground">{t("externalParents")}</p>
)}
{preview.invalidCount > 0 && (
<p role="status" className="text-xs text-[var(--admin-warning)]">
{t("invalidHierarchy", { count: preview.invalidCount })}
</p>
)}
{preview.truncated && (
<p role="status" className="text-xs text-[var(--admin-warning)]">
{t("snapshotLimit")}
</p>
)}
{preview.restrictedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("restricted", { count: preview.restrictedCount })}
</p>
)}
<div className="grid min-w-0 gap-3 md:grid-cols-[minmax(150px,210px)_minmax(0,1fr)]">
<nav
aria-label={t("categories")}
className="max-h-64 overflow-auto rounded-lg border p-2 md:max-h-[520px]"
>
{preview.pages.length ? (
<ul className="space-y-1">
{preview.pages.map((page) => (
<li key={page.id}>
<button
type="button"
aria-current={
selectedPage?.id === page.id ? "page" : undefined
}
onClick={() => {
setSelectedPageId(page.id);
setSelectedOfferId(null);
}}
className={`w-full rounded px-2 py-2 text-left text-xs break-words ${selectedPage?.id === page.id ? "bg-primary/15 font-semibold" : "hover:bg-muted"}`}
style={{ paddingLeft: 8 + Math.min(page.depth, 8) * 12 }}
>
{page.caption}
</button>
</li>
))}
</ul>
) : (
<p className="p-2 text-xs text-muted-foreground">
{snapshot.pages.length === 0
? t("noPages")
: preview.restrictedCount > 0 && !query
? t("inaccessible")
: t("noMatches")}
</p>
)}
</nav>
<div className="min-w-0 space-y-3">
{selectedPage && (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold break-all">
{selectedPage.caption}
</h3>
<span className="text-xs text-muted-foreground">
{t("offerCount", { count: offers.length })}
</span>
</div>
<p className="text-xs text-muted-foreground">
{t("layout", {
layout: selectedPage.pageLayout || t("missingLayout"),
})}
</p>
{!genericLayout && (
<p
role="status"
className="rounded border p-2 text-xs text-[var(--admin-warning)]"
>
{t("unsupportedLayout")}
</p>
)}
{assetKey && assetState.key !== assetKey && (
<p role="status" className="text-xs text-muted-foreground">
{t("loadingIcons")}
</p>
)}
{assetState.key === assetKey &&
assetState.status === "failed" && (
<p
role="status"
className="text-xs text-[var(--admin-warning)]"
>
{t("assetsUnavailable")}
</p>
)}
{allItemIds.length > 200 && (
<p className="text-xs text-muted-foreground">
{t("assetLimit")}
</p>
)}
{(preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{t("restrictedOffers", {
count: preview.restrictedOfferCounts[selectedPage.id],
})}
</p>
)}
{offers.length === 0 ? (
<p className="rounded border border-dashed p-6 text-center text-sm text-muted-foreground">
{query
? t("noMatches")
: (preview.restrictedOfferCounts[selectedPage.id] ?? 0) > 0
? t("restrictedOffers", {
count: preview.restrictedOfferCounts[selectedPage.id],
})
: t("emptyPage")}
</p>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-4">
{visibleOffers.map((offer) => {
const ids = parsePreviewItemIds(offer.itemIds);
const asset = assets.get(ids[0]);
return (
<button
type="button"
key={offer.id}
aria-pressed={selectedOffer?.id === offer.id}
onClick={() => setSelectedOfferId(offer.id)}
className={`flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left ${selectedOffer?.id === offer.id ? "border-primary bg-primary/10" : "bg-card hover:bg-muted"}`}
>
<span className="flex h-20 w-full items-center justify-center">
<PreviewIcon
key={asset?.classname ?? `missing-${offer.id}`}
asset={asset}
missing={t("missingIcon")}
/>
</span>
<span
className="w-full truncate text-xs font-medium"
title={offer.catalogName}
>
{offer.catalogName || `#${offer.id}`}
</span>
<PreviewPrice offer={offer} />
{offer.clubOnly === "1" && (
<span className="text-[10px]">{t("clubOffer")}</span>
)}
{offer.amount > 1 && (
<span className="text-[10px]">
{t("amount", { count: offer.amount })}
</span>
)}
{ids.length > 1 && (
<span className="text-[10px]">
{t("bundle", { count: ids.length })}
</span>
)}
{offerChanged(offer, baseline) && (
<span className="text-[10px] font-medium text-primary">
{t("changed")}
</span>
)}
</button>
);
})}
</div>
)}
{lastIndex > 0 && (
<div className="flex items-center justify-between gap-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={index === 0}
onClick={() => setPagination({ scope, index: index - 1 })}
>
{t("previous")}
</Button>
<span className="text-xs">
{t("page", { page: index + 1, total: lastIndex + 1 })}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={index === lastIndex}
onClick={() => setPagination({ scope, index: index + 1 })}
>
{t("next")}
</Button>
</div>
)}
</>
)}
{selectedOffer ? (
<div
className="space-y-2 rounded-lg border bg-muted/30 p-3"
aria-live="polite"
>
<h4 className="font-medium break-all">
{selectedOffer.catalogName || `#${selectedOffer.id}`}
</h4>
<p className="text-xs">
{t("offerId", { id: selectedOffer.id })} ·{" "}
{t("amount", { count: selectedOffer.amount })}
</p>
<PreviewPrice offer={selectedOffer} />
<ul className="text-xs text-muted-foreground">
{parsePreviewItemIds(selectedOffer.itemIds).map((id) => (
<li key={id}>
{assets.get(id)?.name || t("unknownFurniture")} · #{id}
</li>
))}
</ul>
{offerChanged(selectedOffer, baseline) && (
<p className="text-xs">{t("changed")}</p>
)}
</div>
) : (
selectedPage && (
<p className="text-xs text-muted-foreground">
{t("selectOffer")}
</p>
)
)}
</div>
</div>
</section>
);
}