309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { AuthGuard } from "@/components/AuthGuard";
|
||
import { Button, SectionTitle } from "@/components/ui";
|
||
import { useMe } from "@/hooks/useMe";
|
||
import {
|
||
loadCommercialAssets,
|
||
loadCommercialAutomationPacks,
|
||
loadCommercialBundles,
|
||
loadCommercialExtensions,
|
||
loadCommercialMetadata,
|
||
loadCommercialProducts,
|
||
} from "../adapters";
|
||
import type {
|
||
AdapterResult,
|
||
CommercialAsset,
|
||
CommercialAutomationPack,
|
||
CommercialBundle,
|
||
CommercialExtension,
|
||
CommercialMetadataBag,
|
||
CommercialProduct,
|
||
} from "../types";
|
||
import {
|
||
BundleCard,
|
||
CommercialEmptyState,
|
||
CommercialSkeleton,
|
||
MetadataBagView,
|
||
PlatformShell,
|
||
ProductCard,
|
||
RegistryBrowser,
|
||
} from "../components";
|
||
import {
|
||
loadLauncherPrefs,
|
||
toggleFavorite,
|
||
togglePinned,
|
||
type LauncherPrefs,
|
||
} from "../launcher-prefs";
|
||
|
||
const GROUP_ORDER = [
|
||
"Operations",
|
||
"Sales",
|
||
"Marketing",
|
||
"Finance",
|
||
"Customers",
|
||
"Online Presence",
|
||
"Automation",
|
||
"AI",
|
||
"operations",
|
||
"sales",
|
||
"marketing",
|
||
"finance",
|
||
"customers",
|
||
"online",
|
||
"automation",
|
||
"ai",
|
||
];
|
||
|
||
function groupLabel(raw: string): string {
|
||
const map: Record<string, string> = {
|
||
operations: "عملیات",
|
||
sales: "فروش",
|
||
marketing: "بازاریابی",
|
||
finance: "مالی",
|
||
customers: "مشتریان",
|
||
"online presence": "حضور آنلاین",
|
||
online: "حضور آنلاین",
|
||
automation: "اتوماسیون",
|
||
ai: "هوش مصنوعی",
|
||
};
|
||
return map[raw.toLowerCase()] || raw;
|
||
}
|
||
|
||
function AppsInner() {
|
||
const { me } = useMe();
|
||
const searchParams = useSearchParams();
|
||
const q = (searchParams.get("q") || "").trim().toLowerCase();
|
||
const [products, setProducts] = useState<AdapterResult<CommercialProduct[]> | null>(null);
|
||
const [bundles, setBundles] = useState<AdapterResult<CommercialBundle[]> | null>(null);
|
||
const [assets, setAssets] = useState<AdapterResult<CommercialAsset[]> | null>(null);
|
||
const [extensions, setExtensions] = useState<AdapterResult<CommercialExtension[]> | null>(null);
|
||
const [automation, setAutomation] = useState<AdapterResult<CommercialAutomationPack[]> | null>(
|
||
null
|
||
);
|
||
const [meta, setMeta] = useState<AdapterResult<CommercialMetadataBag | null> | null>(null);
|
||
const [prefs, setPrefs] = useState<LauncherPrefs>({ favorites: [], pinned: [], recent: [] });
|
||
const [booted, setBooted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setPrefs(loadLauncherPrefs());
|
||
void Promise.all([
|
||
loadCommercialProducts(),
|
||
loadCommercialBundles(),
|
||
loadCommercialAssets(),
|
||
loadCommercialExtensions(),
|
||
loadCommercialAutomationPacks(),
|
||
loadCommercialMetadata({ target_kind: "catalog" }),
|
||
]).then(([p, b, a, e, auto, m]) => {
|
||
setProducts(p);
|
||
setBundles(b);
|
||
setAssets(a);
|
||
setExtensions(e);
|
||
setAutomation(auto);
|
||
setMeta(m);
|
||
setBooted(true);
|
||
});
|
||
}, []);
|
||
|
||
const byCode = useMemo(() => {
|
||
const m = new Map<string, CommercialProduct>();
|
||
for (const p of products?.data ?? []) m.set(p.product_code, p);
|
||
return m;
|
||
}, [products]);
|
||
|
||
const pick = (codes: string[]) =>
|
||
codes.map((c) => byCode.get(c)).filter((p): p is CommercialProduct => Boolean(p));
|
||
|
||
const favorites = pick(prefs.favorites);
|
||
const pinned = pick(prefs.pinned);
|
||
const recent = pick(prefs.recent);
|
||
|
||
const groups = useMemo(() => {
|
||
let list = (products?.data ?? []).filter((p) => p.visibility !== "hidden");
|
||
if (q) {
|
||
list = list.filter(
|
||
(p) =>
|
||
p.display_name.toLowerCase().includes(q) ||
|
||
p.product_code.toLowerCase().includes(q) ||
|
||
(p.category || "").toLowerCase().includes(q)
|
||
);
|
||
}
|
||
const map = new Map<string, CommercialProduct[]>();
|
||
for (const p of list) {
|
||
const key = p.category?.trim() || "Operations";
|
||
const arr = map.get(key) || [];
|
||
arr.push(p);
|
||
map.set(key, arr);
|
||
}
|
||
const keys = Array.from(map.keys()).sort((a, b) => {
|
||
const ia = GROUP_ORDER.findIndex((g) => g.toLowerCase() === a.toLowerCase());
|
||
const ib = GROUP_ORDER.findIndex((g) => g.toLowerCase() === b.toLowerCase());
|
||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
||
});
|
||
return keys.map((k) => ({ key: k, label: groupLabel(k), items: map.get(k)! }));
|
||
}, [products, q]);
|
||
|
||
return (
|
||
<PlatformShell
|
||
title="Apps Launcher"
|
||
subtitle="گروهبندی پویا از category رجیستری — بدون مسیر ثابت frontend"
|
||
>
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||
{q ? <p className="text-xs text-gray-500">نتایج برای: {q}</p> : <span />}
|
||
<div className="flex gap-2">
|
||
<Link href="/search">
|
||
<Button variant="ghost" size="sm">
|
||
جستجوی سراسری
|
||
</Button>
|
||
</Link>
|
||
<Link href="/discover">
|
||
<Button variant="outline" size="sm">
|
||
کشف محصول جدید
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{!booted ? <CommercialSkeleton rows={4} /> : null}
|
||
|
||
{booted && pinned.length ? (
|
||
<section className="mb-8">
|
||
<SectionTitle title="پین شده" />
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{pinned.map((p) => (
|
||
<ProductCard
|
||
key={`pin-${p.product_code}`}
|
||
product={p}
|
||
tenantId={me?.current_tenant_id || undefined}
|
||
favorite={prefs.favorites.includes(p.product_code)}
|
||
pinned
|
||
onToggleFavorite={() => setPrefs(toggleFavorite(p.product_code))}
|
||
onTogglePinned={() => setPrefs(togglePinned(p.product_code))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{booted && favorites.length ? (
|
||
<section className="mb-8">
|
||
<SectionTitle title="علاقهمندیها" />
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{favorites.map((p) => (
|
||
<ProductCard
|
||
key={`fav-${p.product_code}`}
|
||
product={p}
|
||
tenantId={me?.current_tenant_id || undefined}
|
||
favorite
|
||
pinned={prefs.pinned.includes(p.product_code)}
|
||
onToggleFavorite={() => setPrefs(toggleFavorite(p.product_code))}
|
||
onTogglePinned={() => setPrefs(togglePinned(p.product_code))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{booted && recent.length ? (
|
||
<section className="mb-8">
|
||
<SectionTitle title="اخیر" />
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{recent.map((p) => (
|
||
<ProductCard
|
||
key={`recent-${p.product_code}`}
|
||
product={p}
|
||
tenantId={me?.current_tenant_id || undefined}
|
||
favorite={prefs.favorites.includes(p.product_code)}
|
||
pinned={prefs.pinned.includes(p.product_code)}
|
||
onToggleFavorite={() => setPrefs(toggleFavorite(p.product_code))}
|
||
onTogglePinned={() => setPrefs(togglePinned(p.product_code))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{booted && products?.availability === "ready" && groups.length ? (
|
||
<div className="space-y-10">
|
||
{groups.map((g) => (
|
||
<section key={g.key}>
|
||
<SectionTitle title={g.label} />
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||
{g.items.map((p) => (
|
||
<ProductCard
|
||
key={p.product_code}
|
||
product={p}
|
||
tenantId={me?.current_tenant_id || undefined}
|
||
favorite={prefs.favorites.includes(p.product_code)}
|
||
pinned={prefs.pinned.includes(p.product_code)}
|
||
onToggleFavorite={() => setPrefs(toggleFavorite(p.product_code))}
|
||
onTogglePinned={() => setPrefs(togglePinned(p.product_code))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
) : booted ? (
|
||
<CommercialEmptyState
|
||
title="کاتالوگ محصول خالی/غیرفعال"
|
||
description={products?.message}
|
||
actionHref="/discover"
|
||
actionLabel="کشف کسبوکار"
|
||
secondaryHref="/help"
|
||
secondaryLabel="راهنما"
|
||
hint="پس از ثبت محصول در Admin Commercial، اینجا ظاهر میشود."
|
||
/>
|
||
) : null}
|
||
|
||
<section className="mt-12">
|
||
<SectionTitle title="Bundles" />
|
||
{bundles?.availability === "ready" && bundles.data.length ? (
|
||
<div className="mt-3 grid gap-3">
|
||
{bundles.data.map((b) => (
|
||
<BundleCard key={b.bundle_code} bundle={b} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="باندل نیست"
|
||
description={bundles?.message}
|
||
hint="باندلها از رجیستری تجاری میآیند."
|
||
actionHref="/discover"
|
||
actionLabel="کشف باندل"
|
||
secondaryHref="/pricing"
|
||
secondaryLabel="قیمتگذاری"
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<div className="mt-10 space-y-8">
|
||
<RegistryBrowser title="Assets" result={assets} codeKey="asset_code" />
|
||
<RegistryBrowser title="Extensions" result={extensions} codeKey="extension_code" />
|
||
<RegistryBrowser
|
||
title="Automation Packs"
|
||
result={automation}
|
||
codeKey="automation_pack_code"
|
||
/>
|
||
<MetadataBagView
|
||
title="Catalog metadata"
|
||
attributes={meta?.data?.attributes}
|
||
message={meta?.message}
|
||
availability={meta?.availability}
|
||
/>
|
||
</div>
|
||
</PlatformShell>
|
||
);
|
||
}
|
||
|
||
export function CommercialAppsPage() {
|
||
return (
|
||
<AuthGuard>
|
||
<AppsInner />
|
||
</AuthGuard>
|
||
);
|
||
}
|