feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
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-next/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 { BcPageDetail } from "@/components/admin/catalog/builder-club/bc-manager";
|
||||
import { CatalogDetailTabs } from "@/components/admin/catalog/detail/catalog-detail-tabs";
|
||||
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-next/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user