185 lines
6.0 KiB
TypeScript
185 lines
6.0 KiB
TypeScript
"use client";
|
||
|
||
import type { AdapterResult, RegistryAvailability } from "../types";
|
||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||
import {
|
||
fieldLabel,
|
||
friendlyMessage,
|
||
humanizeCommercialCode,
|
||
isTechnicalField,
|
||
presentValue,
|
||
} from "../presentation";
|
||
|
||
function availabilityLabel(a: RegistryAvailability): string {
|
||
switch (a) {
|
||
case "loading":
|
||
return "در حال بارگذاری…";
|
||
case "empty":
|
||
return "خالی";
|
||
case "unavailable":
|
||
return "در دسترس نیست";
|
||
case "permission_denied":
|
||
return "بدون دسترسی";
|
||
case "error":
|
||
return "خطا";
|
||
default:
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function PresentedValue({ value }: { value: unknown }) {
|
||
const presented = presentValue(value);
|
||
if (Array.isArray(presented)) {
|
||
return (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{presented.map((item, index) => (
|
||
<span key={`${item}-${index}`} className="rounded-full bg-gray-100 px-2.5 py-1 text-xs text-gray-700">
|
||
{item}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
if (presented && typeof presented === "object") {
|
||
const entries = Object.entries(presented).filter(([key]) => !isTechnicalField(key));
|
||
return (
|
||
<details className="rounded-xl border border-gray-100 bg-gray-50 p-3">
|
||
<summary className="cursor-pointer text-xs font-semibold text-secondary">مشاهده جزئیات</summary>
|
||
<dl className="mt-3 grid gap-3 sm:grid-cols-2">
|
||
{entries.map(([key, nested]) => (
|
||
<div key={key}>
|
||
<dt className="text-[11px] text-gray-500">{fieldLabel(key)}</dt>
|
||
<dd className="mt-1 text-xs text-secondary"><PresentedValue value={nested} /></dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</details>
|
||
);
|
||
}
|
||
return <>{presented}</>;
|
||
}
|
||
|
||
/** Generic human-facing catalog list. Implementation identifiers are deliberately hidden. */
|
||
export function RegistryBrowser({
|
||
title,
|
||
subtitle,
|
||
result,
|
||
codeKey,
|
||
nameKey = "display_name",
|
||
emptyTitle,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
result: AdapterResult<any[]> | null;
|
||
codeKey: string;
|
||
nameKey?: string;
|
||
emptyTitle?: string;
|
||
}) {
|
||
if (!result || result.availability === "loading") {
|
||
return (
|
||
<section>
|
||
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
||
<p className="mt-2 text-xs text-gray-500">{availabilityLabel("loading")}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
if (result.availability !== "ready" || !result.data.length) {
|
||
return (
|
||
<section>
|
||
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
||
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
|
||
<div className="mt-2">
|
||
<CommercialEmptyState
|
||
title={emptyTitle || `${title} — ${availabilityLabel(result.availability)}`}
|
||
description={friendlyMessage(result.message)}
|
||
/>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
||
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
|
||
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
||
{result.data.map((raw, idx) => {
|
||
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||
const code = String(item[codeKey] ?? idx);
|
||
const explicitName = typeof item[nameKey] === "string" ? String(item[nameKey]) : null;
|
||
const name = humanizeCommercialCode(code, explicitName);
|
||
const meta = item.metadata;
|
||
const detailEntries = Object.entries(item).filter(
|
||
([key, value]) =>
|
||
key !== codeKey &&
|
||
key !== nameKey &&
|
||
key !== "metadata" &&
|
||
!isTechnicalField(key) &&
|
||
value != null
|
||
);
|
||
return (
|
||
<li
|
||
key={code}
|
||
className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm shadow-sm"
|
||
>
|
||
<p className="font-medium text-secondary">{name}</p>
|
||
{detailEntries.length ? (
|
||
<dl className="mt-3 space-y-2">
|
||
{detailEntries.slice(0, 4).map(([key, value]) => (
|
||
<div key={key} className="flex items-start justify-between gap-3 text-xs">
|
||
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
||
<dd className="text-left text-secondary"><PresentedValue value={value} /></dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : null}
|
||
{meta && typeof meta === "object" && !Array.isArray(meta) ? (
|
||
<div className="mt-3"><PresentedValue value={meta} /></div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** Renders an arbitrary metadata bag without whitelisting attribute keys. */
|
||
export function MetadataBagView({
|
||
title,
|
||
attributes,
|
||
message,
|
||
availability,
|
||
}: {
|
||
title: string;
|
||
attributes?: Record<string, unknown> | null;
|
||
message?: string;
|
||
availability?: RegistryAvailability;
|
||
}) {
|
||
const entries = attributes ? Object.entries(attributes) : [];
|
||
if (!entries.length) {
|
||
return (
|
||
<CommercialEmptyState
|
||
title={title}
|
||
description={friendlyMessage(message, availabilityLabel(availability || "empty"))}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rounded-2xl border border-gray-100 bg-white p-4">
|
||
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
||
<dl className="mt-3 space-y-2">
|
||
{entries.filter(([key]) => !isTechnicalField(key)).map(([key, value]) => (
|
||
<div key={key} className="rounded-xl bg-gray-50 p-3 text-xs">
|
||
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
||
<dd className="mt-2 text-secondary">
|
||
<PresentedValue value={value} />
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
);
|
||
}
|