484 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|