TorbatYar/frontend/modules/commercial/features/apps.tsx
2026-07-28 20:39:10 +03:30

309 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}