feat(housekeeping): deliver economy vertical
CI / check (pull_request) Successful in 40s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 13:47:06 +02:00
1 parent 1ec05cda39
commit 9c4b973faf
62 files changed
+6306 -1285

No files matched your search

@@ -0,0 +1,115 @@
import type { ReactNode } from "react";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function forms({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.CATALOG_EDIT)) return null;
if (routeId === "economy.catalog.maintenance") {
return (
<div className="grid gap-3 lg:grid-cols-2">
{[
["fix-offers", "Fix catalog offers"],
["fix-everything", "Run complete catalog repair"],
["fix-sprite-ids", "Fix sprite identifiers"],
["reconcile-ids", "Reconcile identifiers"],
["align-ids", "Align identifiers"],
["remove-duplicates", "Remove duplicate base items"],
].map(([operation, label]) => (
<EconomyCommandForm
key={operation}
commandId={`economy.catalog.maintenance.${operation}`}
buttonLabel={label}
input={{}}
requiresReason
/>
))}
</div>
);
}
const builderClub = routeId === "economy.catalog.builder-club-detail";
return (
<EconomyCommandForm
commandId={
builderClub
? "economy.catalog.bc-page.change"
: "economy.catalog.page.change"
}
buttonLabel="Save catalog page"
input={{
action: routeId === "economy.catalog.overview" ? "create" : "update",
}}
fields={[
{ name: "id", label: "ID", type: "identifier" },
{
name: "caption",
label: "Caption",
type: "text",
required: true,
maxLength: 128,
},
{
name: "parentId",
label: "Parent ID",
type: "number",
required: true,
},
{
name: "pageLayout",
label: "Page layout",
type: "text",
required: true,
maxLength: 26,
},
]}
/>
);
}
export function EconomyCatalogPage(
props: EconomyPageProps & { readonly editor?: ReactNode },
) {
return (
<EconomyPageFrame
title="Catalog"
description="Catalog pages, Builder Club and maintenance workflows."
result={props.result}
forms={props.editor ?? forms(props)}
/>
);
}
export async function renderEconomyCatalogPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.catalog.overview",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
const editor =
result.ok &&
(routeId === "economy.catalog.detail" ||
routeId === "economy.catalog.builder-club-detail")
? await (
await import("./specialized-catalog-editor")
).renderSpecializedCatalogEditor(
routeId,
input.match.params,
input.context,
)
: null;
return (
<EconomyCatalogPage
context={input.context}
result={result}
routeId={routeId}
editor={editor}
/>
);
}
@@ -0,0 +1,128 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function commerceForm({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.SHOP_EDIT)) return null;
if (routeId === "economy.commerce.vouchers") {
return (
<EconomyCommandForm
commandId="economy.commerce.voucher.change"
buttonLabel="Create voucher"
input={{ action: "create" }}
fields={[
{
name: "code",
label: "Code",
type: "text",
required: true,
maxLength: 255,
},
{
name: "amount",
label: "Amount",
type: "number",
required: true,
min: 1,
},
{
name: "maxUses",
label: "Maximum uses",
type: "number",
required: true,
min: 1,
},
{ name: "expiresAt", label: "Expires at", type: "text" },
]}
/>
);
}
if (routeId === "economy.commerce.marketplace") {
return (
<EconomyCommandForm
commandId="economy.commerce.marketplace.cancel"
buttonLabel="Cancel listing"
input={{}}
fields={[
{
name: "id",
label: "Listing ID",
type: "identifier",
required: true,
},
]}
requiresReason
/>
);
}
if (routeId?.startsWith("economy.commerce.shop")) {
return (
<EconomyCommandForm
commandId="economy.commerce.shop-article.change"
buttonLabel="Save shop package"
input={{
action:
routeId === "economy.commerce.shop-detail" ? "update" : "create",
}}
fields={[
{ name: "id", label: "ID", type: "identifier" },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "info",
label: "Information",
type: "text",
required: true,
maxLength: 255,
},
{
name: "costs",
label: "Costs",
type: "number",
required: true,
min: 0,
},
]}
/>
);
}
return null;
}
export function EconomyCommercePage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Commerce"
description="Shop, marketplace, vouchers and subscriptions."
result={props.result}
forms={commerceForm(props)}
/>
);
}
export async function renderEconomyCommercePage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.commerce.shop",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyCommercePage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -0,0 +1,199 @@
"use client";
import { useActionState } from "react";
import type { HousekeepingResult } from "../../../foundation/contracts";
export interface EconomyCommandField {
readonly name: string;
readonly label: string;
readonly type:
| "identifier"
| "text"
| "textarea"
| "number"
| "checkbox"
| "file";
readonly required?: boolean;
readonly min?: number;
readonly max?: number;
readonly maxLength?: number;
readonly defaultValue?: string | number | boolean;
readonly allowUnchanged?: boolean;
}
export interface EconomyCommandSubmission {
readonly commandId: string;
readonly input: Readonly<Record<string, unknown>>;
readonly fields?: readonly EconomyCommandField[];
readonly requiresReason?: boolean;
}
interface EconomyCommandFormProps extends EconomyCommandSubmission {
readonly buttonLabel: string;
}
const OMIT_FIELD = Symbol("omit optional Economy command field");
function parseField(field: EconomyCommandField, formData: FormData): unknown {
const rawValue = formData.get(field.name);
if (field.type === "checkbox") {
if (field.allowUnchanged) {
if (rawValue === null || rawValue === "") return OMIT_FIELD;
if (rawValue === "true") return true;
if (rawValue === "false") return false;
return OMIT_FIELD;
}
return rawValue === "on";
}
if (rawValue === null && !field.required) return OMIT_FIELD;
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
const raw = String(rawValue ?? "")
.normalize("NFC")
.trim();
if (!raw && !field.required) return OMIT_FIELD;
if (field.type === "number") {
const value = Number(raw);
if (!Number.isSafeInteger(value)) return 0;
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
}
return raw.slice(
0,
field.maxLength ?? (field.type === "textarea" ? 20_000 : 500),
);
}
const initialState: HousekeepingResult<unknown> | null = null;
export async function submitEconomyCommandForm(
configuration: EconomyCommandSubmission,
_previous: HousekeepingResult<unknown> | null,
formData: FormData,
): Promise<HousekeepingResult<unknown>> {
const submitted = (configuration.fields ?? []).flatMap((field) => {
const value = parseField(field, formData);
return value === OMIT_FIELD ? [] : [[field.name, value] as const];
});
const reason = String(formData.get("reason") ?? "")
.normalize("NFC")
.trim()
.slice(0, 1000);
const { executeHousekeepingCommand } = await import(
"@/actions/housekeeping-command"
);
return executeHousekeepingCommand({
commandId: configuration.commandId,
input: { ...configuration.input, ...Object.fromEntries(submitted) },
...(configuration.requiresReason ? { reason } : {}),
});
}
export function EconomyCommandForm({
commandId,
buttonLabel,
input,
fields = [],
requiresReason = false,
}: EconomyCommandFormProps) {
const [result, submit, pending] = useActionState(
submitEconomyCommandForm.bind(null, {
commandId,
input,
fields,
requiresReason,
}),
initialState,
);
return (
<form
action={submit}
data-housekeeping-command={commandId}
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
>
{fields.map((field) => {
const fieldId = `economy-${commandId}-${field.name}`;
return (
<label key={field.name} htmlFor={fieldId} className="block text-sm">
{field.type === "checkbox" && field.allowUnchanged ? (
<>
{field.label}
<select
id={fieldId}
name={field.name}
defaultValue=""
className="mt-1 block w-full"
>
<option value="">No change</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</>
) : field.type === "checkbox" ? (
<>
<input
id={fieldId}
name={field.name}
type="checkbox"
defaultChecked={field.defaultValue === true}
/>{" "}
{field.label}
</>
) : field.type === "textarea" ? (
<>
{field.label}
<textarea
id={fieldId}
name={field.name}
required={field.required}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
) : (
<>
{field.label}
<input
id={fieldId}
name={field.name}
type={
field.type === "number"
? "number"
: field.type === "file"
? "file"
: "text"
}
required={field.required}
min={field.min}
max={field.max}
maxLength={field.maxLength}
className="mt-1 block w-full"
/>
</>
)}
</label>
);
})}
{requiresReason ? (
<label
htmlFor={`economy-${commandId}-reason`}
className="block text-sm"
>
Reason
<textarea
id={`economy-${commandId}-reason`}
name="reason"
required
maxLength={1000}
/>
</label>
) : null}
<button type="submit" disabled={pending}>
{pending ? "Working..." : buttonLabel}
</button>
{result ? (
<p role="status">
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
</p>
) : null}
</form>
);
}
@@ -0,0 +1,82 @@
import type { ReactNode } from "react";
import type {
HousekeepingCapabilityContext,
HousekeepingResult,
} from "../../../foundation/contracts";
import type { EconomyQueryData } from "../queries/catalog";
import type { EconomyRouteId } from "../routes";
export interface EconomyPageProps {
readonly context: HousekeepingCapabilityContext;
readonly result?: HousekeepingResult<EconomyQueryData>;
readonly routeId: EconomyRouteId | null;
}
export function EconomyPageFrame({
title,
description,
result,
forms,
}: {
readonly title: string;
readonly description: string;
readonly result?: HousekeepingResult<EconomyQueryData>;
readonly forms?: ReactNode;
}) {
if (!result) {
return (
<section data-housekeeping-state="loading">
<h1>{title}</h1>
<p>{description}</p>
<p>Loading economy data…</p>
</section>
);
}
if (!result.ok) {
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
return (
<section data-housekeeping-state={state} role="alert">
<h1>{title}</h1>
<p>{result.error.messageKey}</p>
</section>
);
}
const state = result.data.items.length === 0 ? "empty" : "ready";
return (
<section data-housekeeping-state={state} className="space-y-4">
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
{forms}
{result.data.items.length === 0 ? (
<p>No matching economy records.</p>
) : (
<ul className="divide-y divide-[var(--admin-border)]">
{result.data.items.map((item) => (
<li key={item.id} className="py-2">
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
{item.status ? <span> — {item.status}</span> : null}
{item.amount ? <span> — {item.amount}</span> : null}
{item.description ? <p>{item.description}</p> : null}
</li>
))}
</ul>
)}
</section>
);
}
export function parseEconomyListInput(
searchParams: Readonly<
Record<string, string | readonly string[] | undefined>
>,
) {
const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
return {
search: first(searchParams.search),
pageSize: Number(first(searchParams.pageSize) ?? 25),
offset: Number(first(searchParams.offset) ?? 0),
};
}
@@ -0,0 +1,159 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
fail,
type HousekeepingCapabilityContext,
ok,
} from "../../../foundation/contracts";
import { EconomyCatalogPage } from "./catalog";
import { EconomyCommercePage } from "./commerce";
import { EconomyHistoryPage } from "./history";
import { EconomyItemsPage } from "./items";
import { EconomyRewardsPage } from "./rewards";
import { EconomyValuePage } from "./value";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const cases = [
["catalog", EconomyCatalogPage],
["items", EconomyItemsPage],
["commerce", EconomyCommercePage],
["history", EconomyHistoryPage],
["value", EconomyValuePage],
["rewards", EconomyRewardsPage],
] as const;
describe.each(cases)("Economy %s page", (kind, Component) => {
it("renders loading, forbidden, empty, and canonical ready states", () => {
const render = (result?: unknown) =>
renderToStaticMarkup(
<Component
context={context(Object.values(PERMS))}
result={result as never}
routeId={null}
/>,
);
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "denied")),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
ok({ kind, items: [], total: 0, partialDependencies: [] }, "empty"),
),
).toContain('data-housekeeping-state="empty"');
const ready = render(
ok(
{
kind,
items: [
{
id: "18446744073709551615",
title: "Canonical economy item",
status: "active",
href: `/ase/economy/${kind}`,
},
],
total: 1,
partialDependencies: [],
},
"ready",
),
);
expect(ready).toContain('data-housekeeping-state="ready"');
expect(ready).toContain("Canonical economy item");
expect(ready).not.toContain("/admin");
});
});
describe("Economy mutation forms", () => {
it("renders edit controls only with the exact mutation capability", () => {
const result = ok(
{
kind: "commerce" as const,
items: [{ id: "1", title: "Package", status: "active" }],
total: 1,
partialDependencies: [],
},
"shop",
);
const readOnly = renderToStaticMarkup(
<EconomyCommercePage
context={context([PERMS.SHOP_VIEW])}
result={result}
routeId="economy.commerce.shop"
/>,
);
const editor = renderToStaticMarkup(
<EconomyCommercePage
context={context([PERMS.SHOP_VIEW, PERMS.SHOP_EDIT])}
result={result}
routeId="economy.commerce.shop"
/>,
);
expect(readOnly).not.toContain("economy.commerce.shop-article.change");
expect(editor).toContain("economy.commerce.shop-article.change");
expect(editor).toContain("<form");
});
it("renders validator-shaped catalog, voucher, value, and soundtrack fields", () => {
const all = context(Object.values(PERMS));
const empty = (kind: "catalog" | "commerce" | "value" | "rewards") =>
ok({ kind, items: [], total: 0, partialDependencies: [] }, kind);
const catalog = renderToStaticMarkup(
<EconomyCatalogPage
context={all}
result={empty("catalog")}
routeId="economy.catalog.detail"
/>,
);
for (const field of ["id", "caption", "parentId", "pageLayout"]) {
expect(catalog).toContain(`name="${field}"`);
}
const commerce = renderToStaticMarkup(
<EconomyCommercePage
context={all}
result={empty("commerce")}
routeId="economy.commerce.vouchers"
/>,
);
for (const field of ["code", "amount", "maxUses", "expiresAt"]) {
expect(commerce).toContain(`name="${field}"`);
}
const value = renderToStaticMarkup(
<EconomyValuePage
context={all}
result={empty("value")}
routeId="economy.value.rare-values"
/>,
);
for (const field of [
"categoryId",
"name",
"currencyValue",
"currencyType",
]) {
expect(value).toContain(`name="${field}"`);
}
const rewards = renderToStaticMarkup(
<EconomyRewardsPage
context={all}
result={empty("rewards")}
routeId="economy.rewards.sounds"
/>,
);
for (const field of ["id", "name", "author", "track", "length"]) {
expect(rewards).toContain(`name="${field}"`);
}
});
});
@@ -0,0 +1,33 @@
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyHistoryPage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Transaction history"
description="Stable, read-only commerce history."
result={props.result}
/>
);
}
export async function renderEconomyHistoryPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.history.transactions",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyHistoryPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -0,0 +1,59 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyItemsPage(props: EconomyPageProps) {
const edit = props.context.has(PERMS.CATALOG_EDIT) ? (
<EconomyCommandForm
commandId="economy.items.base.update"
buttonLabel="Save base item"
input={{}}
fields={[
{ name: "id", label: "ID", type: "identifier", required: true },
{
name: "publicName",
label: "Public name",
type: "text",
maxLength: 56,
},
{ name: "itemName", label: "Item name", type: "text", maxLength: 70 },
{
name: "interactionType",
label: "Interaction",
type: "text",
maxLength: 500,
},
]}
/>
) : null;
return (
<EconomyPageFrame
title="Items"
description="Base item definitions and catalog linkage."
result={props.result}
forms={edit}
/>
);
}
export async function renderEconomyItemsPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.items.overview",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyItemsPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -0,0 +1,119 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
function rewardForms({ context, routeId }: EconomyPageProps) {
if (!context.has(PERMS.CATALOG_EDIT)) return null;
if (routeId === "economy.rewards.sounds") {
return (
<EconomyCommandForm
commandId="economy.rewards.soundtrack.change"
buttonLabel="Save soundtrack"
input={{ action: "update" }}
fields={[
{ name: "id", label: "ID", type: "identifier", required: true },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "author",
label: "Author",
type: "text",
required: true,
maxLength: 50,
},
{ name: "track", label: "Track", type: "textarea", required: true },
{
name: "length",
label: "Length",
type: "number",
required: true,
min: 0,
},
]}
/>
);
}
if (routeId === "economy.rewards.badges") {
return (
<div className="grid gap-3 lg:grid-cols-2">
<EconomyCommandForm
commandId="economy.rewards.badge.give"
buttonLabel="Give badge"
input={{}}
fields={[
{
name: "username",
label: "Username",
type: "text",
required: true,
maxLength: 25,
},
{
name: "badge",
label: "Badge code",
type: "text",
required: true,
maxLength: 32,
},
]}
requiresReason
/>
<EconomyCommandForm
commandId="economy.rewards.badge.upload"
buttonLabel="Upload badge"
input={{}}
fields={[
{
name: "code",
label: "Badge code",
type: "text",
required: true,
maxLength: 32,
},
{ name: "file", label: "Badge file", type: "file", required: true },
]}
requiresReason
/>
</div>
);
}
return null;
}
export function EconomyRewardsPage(props: EconomyPageProps) {
return (
<EconomyPageFrame
title="Rewards"
description="Badges, achievements, sounds and calendar rewards."
result={props.result}
forms={rewardForms(props)}
/>
);
}
export async function renderEconomyRewardsPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.rewards.badges",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyRewardsPage
context={input.context}
result={result}
routeId={routeId}
/>
);
}
@@ -0,0 +1,136 @@
import "server-only";
import { asc, eq } from "drizzle-orm";
import type { ReactNode } from "react";
import { CatalogDetailTabs } from "@/app/admin/catalog/[id]/catalog-detail-tabs";
import { BcPageDetail } from "@/app/admin/catalog/builder-club/bc-manager";
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import type { EconomyRouteId } from "../routes";
async function normalCatalogEditor(
id: number,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const [catalogPage] = await db
.select()
.from(CatalogPages)
.where(eq(CatalogPages.id, id))
.limit(1);
if (!catalogPage) return null;
const [children, itemsData] = await Promise.all([
db
.select({
id: CatalogPages.id,
caption: CatalogPages.caption,
enabled: CatalogPages.enabled,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, id))
.orderBy(asc(CatalogPages.orderNum)),
loadCatalogItemsData(id),
]);
return (
<CatalogDetailTabs
catalogPage={{
id: catalogPage.id,
caption: catalogPage.caption,
parentId: catalogPage.parentId,
pageLayout: catalogPage.pageLayout,
enabled: catalogPage.enabled,
visible: catalogPage.visible,
minRank: catalogPage.minRank,
clubOnly: catalogPage.clubOnly,
orderNum: catalogPage.orderNum,
iconImage: catalogPage.iconImage,
iconColor: catalogPage.iconColor,
pageHeadline: catalogPage.pageHeadline,
pageTeaser: catalogPage.pageTeaser,
pageSpecial: catalogPage.pageSpecial,
pageText1: catalogPage.pageText1,
pageText2: catalogPage.pageText2,
pageTextDetails: catalogPage.pageTextDetails,
pageTextTeaser: catalogPage.pageTextTeaser,
}}
items={itemsData.items}
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
childPages={children}
pageId={catalogPage.id}
canEdit={context.has(PERMS.CATALOG_EDIT)}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
interactionTypes={itemsData.interactionTypes}
gamedataHotel={itemsData.gamedataHotel}
/>
);
}
async function builderClubEditor(
id: number,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const [page] = await db
.select()
.from(CatalogPagesBc)
.where(eq(CatalogPagesBc.id, id))
.limit(1);
if (!page) return null;
const items = await db
.select()
.from(CatalogItemsBc)
.where(eq(CatalogItemsBc.pageId, id))
.orderBy(asc(CatalogItemsBc.orderNumber));
return (
<BcPageDetail
page={{
id: page.id,
parentId: page.parentId,
caption: page.caption,
pageLayout: page.pageLayout,
iconColor: page.iconColor,
iconImage: page.iconImage,
orderNum: page.orderNum,
visible: page.visible,
enabled: page.enabled,
pageHeadline: page.pageHeadline,
pageTeaser: page.pageTeaser,
pageSpecial: page.pageSpecial ?? "",
pageText1: page.pageText1 ?? "",
pageText2: page.pageText2 ?? "",
pageTextDetails: page.pageTextDetails ?? "",
pageTextTeaser: page.pageTextTeaser ?? "",
}}
items={items.map((item) => ({
id: item.id,
pageId: item.pageId,
itemIds: item.itemIds,
catalogName: item.catalogName,
orderNumber: item.orderNumber,
extradata: item.extradata,
}))}
canEdit={context.has(PERMS.CATALOG_EDIT)}
returnHref="/ase/economy/catalog"
/>
);
}
export async function renderSpecializedCatalogEditor(
routeId: EconomyRouteId,
params: Readonly<Record<string, string>>,
context: HousekeepingCapabilityContext,
): Promise<ReactNode> {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return null;
if (routeId === "economy.catalog.detail") {
return normalCatalogEditor(id, context);
}
if (routeId === "economy.catalog.builder-club-detail") {
return builderClubEditor(id, context);
}
return null;
}
@@ -0,0 +1,102 @@
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers";
import { economyQuery } from "../queries/catalog";
import { EconomyCommandForm } from "./economy-command-form";
import {
EconomyPageFrame,
type EconomyPageProps,
parseEconomyListInput,
} from "./economy-page-frame";
export function EconomyValuePage(props: EconomyPageProps) {
const forms = props.context.has(PERMS.SHOP_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
<EconomyCommandForm
commandId="economy.value.category.change"
buttonLabel="Create value category"
input={{ action: "create" }}
fields={[
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "badge",
label: "Badge",
type: "text",
required: true,
maxLength: 255,
},
{ name: "priority", label: "Priority", type: "number", min: 1 },
]}
/>
<EconomyCommandForm
commandId="economy.value.rare.change"
buttonLabel="Create rare value"
input={{ action: "create" }}
fields={[
{
name: "categoryId",
label: "Category ID",
type: "identifier",
required: true,
},
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 255,
},
{
name: "currencyValue",
label: "Currency value",
type: "text",
maxLength: 255,
},
{
name: "currencyType",
label: "Currency type",
type: "text",
required: true,
maxLength: 255,
},
{
name: "furnitureIcon",
label: "Furniture icon",
type: "text",
required: true,
maxLength: 255,
},
]}
/>
</div>
) : null;
return (
<EconomyPageFrame
title="Value tools"
description="Rare-value categories and valuations."
result={props.result}
forms={forms}
/>
);
}
export async function renderEconomyValuePage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as EconomyPageProps["routeId"];
const result = await economyQuery.run(input.context, {
routeId: routeId ?? "economy.value.rare-values",
params: input.match.params,
list: parseEconomyListInput(input.searchParams ?? {}),
});
return (
<EconomyValuePage
context={input.context}
result={result}
routeId={routeId}
/>
);
}