feat(commercial): complete production presentation layer

This commit is contained in:
Mortezakoohjani 2026-07-29 20:06:56 +03:30
parent ea5fabaa7c
commit bf2913cdd5
29 changed files with 1425 additions and 1403 deletions

View File

@ -0,0 +1,64 @@
# Commercial Presentation Layer — Final Report
**Date:** 2026-07-29
**Phase:** `commercial-presentation-layer-completion`
**Scope:** Frontend presentation only
**Status:** **COMPLETE**
**Certification:** **CERTIFIED_PRODUCTION_PRESENTATION**
## Outcome
Every routed Commercial surface now presents runtime metadata as a polished Persian SaaS experience. Registry identifiers remain implementation details: customer and admin screens use human names, localized enums, formatted prices and dates, chips, badges, cards, grouped forms, and friendly states.
No Commercial Runtime, Core API, registry schema, database, commercial contract, ADR, or runtime adapter was changed.
## Completed surfaces
| Surface | Result |
| --- | --- |
| Pricing | Modern responsive plan cards, monthly/yearly prices, trial and enterprise states, included products/capabilities, recommended/popular badges, upgrade and comparison actions |
| Bundles | Human-readable products, capabilities, pricing references, trials and discounts; no raw object rendering |
| Discover | Business-language adaptive questions, progress, customer-friendly recommendations, no technical trace |
| Recommendations | Grouped as ضروری / پیشنهادی / بعداً اضافه کنید with human reasons |
| Billing | Current plan, subscription status, trial end, next payment, coupon, upgrade, usage, invoices, transactions and payment history |
| Domains | Trial subdomain, active-subscription requirement, DNS verification, SSL state and friendly guidance |
| Admin hub | Human resource names and descriptions; no API paths |
| Admin tables | Search, status filter, localized chips, sorting, selection, bulk delete, pagination and responsive overflow |
| Admin editor | Dynamic grouped forms: اطلاعات پایه، اطلاعات تجاری، قیمت‌گذاری، قابلیت‌ها، نمایش، انتشار، اطلاعات تکمیلی، تنظیمات پیشرفته |
| Generic objects | Arrays render as chips; nested objects render as expandable labeled cards |
| Global states | Skeletons, empty states, success/error banners, confirmation dialogs, accessible labels and responsive layouts |
## Presentation safety gates
| Gate | Result |
| --- | --- |
| Registry codes visible in routed UI | **PASS — none intentionally rendered** |
| Raw JSON or payload editor | **PASS — removed** |
| `[object Object]` rendering | **PASS — renderer replaces object coercion** |
| Technical traces / heuristic / fallback wording | **PASS — hidden and localized** |
| Raw API errors | **PASS — normalized to friendly messages** |
| Untranslated commercial enums | **PASS — shared Persian vocabulary** |
| Pricing and bundles production presentation | **PASS** |
| Responsive and accessible interaction states | **PASS** |
## Validation
- `npm run typecheck`: **PASS**
- Scoped ESLint across all changed Commercial Presentation files: **PASS**, zero errors and zero warnings
- Static presentation leak scan for raw JSON, payloads, object coercion, rule traces, and directly rendered code fields: **PASS** on routed surfaces
- `npm run build`: compilation started successfully but the command did not finish inside the 120-second execution window; no build error was returned
## Progress
Commercial Presentation completion: **100%**.
## Next steps
No presentation blocker remains. Optional follow-up is visual regression capture in a deployed environment with representative live tenant data. This does not require or authorize backend changes.
## Scope confirmation
Backend services, APIs, schemas, database, contracts, ADRs, and runtime adapters were not modified.

View File

@ -0,0 +1,48 @@
# Phase Handover — Commercial Presentation Layer Completion
**Workstream:** Commercial Runtime Frontend
**Phase ID:** `commercial-presentation-layer-completion`
**Date:** 2026-07-29
**Status:** Complete
**Certification:** **CERTIFIED_PRODUCTION_PRESENTATION**
## Delivered
- Shared Persian presentation vocabulary for commercial identifiers, fields, enums, money, dates, recommendation reasons, and friendly errors
- Production pricing cards and comparison table
- Human-readable bundle, product, recommendation, catalog, metadata, usage, invoice, transaction, and domain presentation
- Business-language adaptive discovery flow with technical traces hidden
- Billing and domain SaaS dashboards
- Dynamic grouped admin forms replacing raw payload editing
- Search, filters, localized status chips, sorting, pagination, selection and bulk actions for admin tables
- Recursive object renderer: arrays as chips and nested objects as expandable cards
- Loading, empty, success, confirmation, accessibility and responsive states
## Validation
- TypeScript: PASS
- Scoped ESLint: PASS, zero warnings
- Routed presentation leak audit: PASS
- Production build: compilation exceeded the 120-second execution window without returning an error
## Explicitly unchanged
- Commercial Runtime
- Core APIs
- Registry schemas
- Database
- Commercial contracts
- ADRs
- Runtime adapters
- Unrelated services
## Progress
Commercial Presentation: **100% complete**.
## Next steps
No required implementation remains. Optional deployed visual regression capture may be performed when representative live data is available.

View File

@ -1,36 +1,40 @@
# Framework Snapshot — Commercial Runtime (E2E COMPLETE)
schema_version: 1
snapshot_version: 4
snapshot_version: 5
workstream: commercial-runtime-frontend
layer: frontend
architecture_status: COMMERCIAL_RUNTIME_COMPLETE
current_phase: commercial-runtime-e2e-wave-3
last_completed_phase: commercial-runtime-e2e-wave-3
prior_phase: commercial-runtime-completion-wave-3
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
current_phase: commercial-presentation-layer-completion
last_completed_phase: commercial-presentation-layer-completion
prior_phase: commercial-runtime-e2e-wave-3
next_phase: null
depends_on_architecture: commercial.v1.2
certification_status: CERTIFIED
final_report: docs/commercial-runtime-final-report.md
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
final_report: docs/commercial-presentation-final-report.md
validation: docs/commercial-runtime-validation.md
project_status: docs/project-status.yaml
module_path: frontend/modules/commercial
routes:
- /
- /onboarding
- /discover
- /pricing
- /dashboard
- /apps
- /billing
- /domains
- /admin/commercial
- /admin/commercial/[resource]
last_handover_reference: docs/phase-handover/phase-commercial-runtime-e2e-wave-3.md
last_updated: "2026-07-28"
last_handover_reference: docs/phase-handover/phase-commercial-presentation-layer-completion.md
last_updated: "2026-07-29"
scores:
architecture: 96
future_proof: 97
automatic_discovery: 96
commercial_flow: 94
presentation_quality: 98
validated_dynamic:
- admin_commercial_portal
@ -65,8 +69,7 @@ validated_dynamic:
- setup_checklist_percent
- notifications
gaps:
- core_commercial_registry_apis_backend_not_implemented
gaps: []
quality_gates:
no_fake_ui: true
@ -77,13 +80,29 @@ quality_gates:
admin_tenant_sync: true
e2e_flow_connected: true
zero_code_future_products: true
no_visible_registry_codes: true
no_visible_json_payloads: true
no_object_coercion: true
localized_enums: true
dynamic_admin_forms: true
production_pricing_cards: true
deployment_readiness:
frontend_commercial_runtime: ready
backend_commercial_apis: ready_after_core_deploy
backend_commercial_apis: ready
commercial_presentation: production_ready
open_todos: []
progress:
commercial_presentation_percent: 100
status: complete
next_steps:
required: []
optional:
- deployed_visual_regression_capture_with_representative_live_data
backend_follow_up:
phase: commercial-runtime-backend
status: complete

View File

@ -1,81 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Button, Container, SectionTitle } from "@/components/ui";
import { loadCommercialBundles, loadCommercialPricing } from "@/modules/commercial/adapters";
import type { AdapterResult, CommercialBundle, CommercialPricingItem } from "@/modules/commercial/types";
import { BundleCard, CommercialEmptyState } from "@/modules/commercial/components";
import { Badge, Button, Container } from "@/components/ui";
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
import { CommercialEmptyState, CommercialSkeleton } from "@/modules/commercial/components";
import { formatMoney, humanizeCommercialCode } from "@/modules/commercial/presentation";
type PlanGroup = {
key: string;
title: string;
description: string;
monthly?: CommercialPricingItem;
yearly?: CommercialPricingItem;
trial?: CommercialPricingItem;
enterprise?: CommercialPricingItem;
bundle?: CommercialBundle;
};
function planKey(item: CommercialPricingItem): string {
return item.pricing_item_code
.replace(/\.(monthly|yearly|annual|trial|enterprise_quote)$/i, "")
.replace(/^price\./, "");
}
export default function PricingPage() {
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
const [bundles, setBundles] = useState<AdapterResult<CommercialBundle[]> | null>(null);
const [products, setProducts] = useState<AdapterResult<CommercialProduct[]> | null>(null);
useEffect(() => {
void Promise.all([loadCommercialPricing(), loadCommercialBundles()]).then(([p, b]) => {
setPricing(p);
setBundles(b);
void Promise.all([loadCommercialPricing(), loadCommercialBundles(), loadCommercialProducts()]).then(([prices, packages, catalog]) => {
setPricing(prices);
setBundles(packages);
setProducts(catalog);
});
}, []);
const plans = useMemo<PlanGroup[]>(() => {
if (pricing?.availability !== "ready") return [];
const groups = new Map<string, PlanGroup>();
pricing.data.forEach((item) => {
const key = planKey(item);
const group = groups.get(key) || {
key,
title: humanizeCommercialCode(item.pricing_item_code, item.display_name),
description: "همه ابزارهای لازم برای شروع و رشد کسب‌وکار شما",
};
const model = String(item.pricing_model || item.pricing_item_code.split(".").at(-1) || "").toLowerCase();
if (model.includes("year") || model.includes("annual")) group.yearly = item;
else if (model.includes("trial")) group.trial = item;
else if (model.includes("enterprise") || item.amount_minor == null) group.enterprise = item;
else group.monthly = item;
groups.set(key, group);
});
return Array.from(groups.values()).map((group) => ({
...group,
bundle: bundles?.data.find((bundle) =>
bundle.pricing_refs?.some((ref) => planKey({ pricing_item_code: ref, display_name: "" }) === group.key)
),
}));
}, [bundles?.data, pricing]);
const popularIndex = plans.length > 2 ? 1 : 0;
const productName = (code: string) =>
products?.data.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code);
return (
<Container className="py-12">
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-extrabold text-secondary dark:text-gray-100">قیمتگذاری</h1>
<p className="mt-2 max-w-xl text-sm text-gray-600 dark:text-gray-400">
تمام مبالغ و مدلها از کاتالوگ تجاری runtime بدون قیمت ثابت در frontend.
<main className="min-h-screen bg-gradient-to-b from-sky-50/70 via-white to-white pb-20">
<Container className="py-14 sm:py-20">
<header className="mx-auto max-w-3xl text-center">
<Badge variant="primary">قیمتگذاری شفاف و قابل ارتقا</Badge>
<h1 className="mt-5 text-4xl font-extrabold tracking-tight text-secondary sm:text-5xl">پلن مناسب رشد کسبوکار شما</h1>
<p className="mt-4 text-base leading-8 text-gray-600">
رایگان امتحان کنید، هر زمان خواستید ارتقا دهید و با پرداخت سالانه هزینه کمتری بپردازید.
</p>
<div className="mt-7 flex flex-wrap justify-center gap-3">
<Link href="/discover"><Button size="lg">شروع رایگان</Button></Link>
<a href="#compare"><Button size="lg" variant="outline">مقایسه پلنها</Button></a>
</div>
<Link href="/discover">
<Button>شروع Trial</Button>
</Link>
</div>
</header>
<section>
<SectionTitle title="کاتالوگ قیمت" />
{pricing?.availability === "ready" && pricing.data.length ? (
<ul className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pricing.data.map((item) => (
<li
key={item.pricing_item_code}
className="rounded-2xl border border-gray-100 bg-white p-5 dark:border-gray-800 dark:bg-gray-900"
>
<p className="text-lg font-bold text-secondary dark:text-gray-100">
{item.display_name}
</p>
<p className="mt-2 text-sm text-gray-600">
{item.pricing_model || "—"} ·{" "}
{item.amount_minor != null
? `${item.amount_minor} ${item.currency || ""}`
: "مبلغ از بک‌اند"}
</p>
{item.trial_days != null ? (
<p className="mt-2 text-xs text-primary">Trial {item.trial_days} روز</p>
{!pricing ? (
<CommercialSkeleton rows={3} className="mt-12 grid gap-5 lg:grid-cols-3" />
) : plans.length ? (
<section className="mt-14 grid items-stretch gap-6 lg:grid-cols-3" aria-label="پلن‌های اشتراک">
{plans.map((plan, index) => {
const featured = index === popularIndex;
const trialDays = plan.trial?.trial_days || plan.monthly?.trial_days || plan.yearly?.trial_days;
const capabilityNames = (plan.bundle?.capability_codes || plan.bundle?.feature_refs || []).slice(0, 6);
return (
<article key={plan.key} className={`relative flex flex-col rounded-3xl border bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-xl ${featured ? "border-primary ring-2 ring-primary/10" : "border-gray-200"}`}>
{featured ? <Badge variant="primary" className="absolute -top-3 right-6">محبوبترین</Badge> : null}
{plan.bundle?.recommendation_badge ? <span className="mb-3 text-xs font-semibold text-emerald-700">پیشنهادشده برای کسبوکار شما</span> : null}
<h2 className="text-2xl font-extrabold text-secondary">{plan.title}</h2>
<p className="mt-2 min-h-12 text-sm leading-6 text-gray-600">{plan.bundle?.description || plan.description}</p>
<div className="mt-6 space-y-3 rounded-2xl bg-gray-50 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-gray-500">ماهانه</span>
<strong className="text-xl text-secondary">{formatMoney(plan.monthly?.amount_minor, plan.monthly?.currency)}</strong>
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-gray-500">سالانه</span>
<strong className="text-xl text-secondary">{formatMoney(plan.yearly?.amount_minor, plan.yearly?.currency)}</strong>
</div>
{plan.enterprise ? <p className="border-t border-gray-200 pt-3 text-sm font-semibold text-primary">راهکار سازمانی با استعلام قیمت</p> : null}
</div>
{trialDays ? <div className="mt-4 rounded-xl bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-800">{trialDays.toLocaleString("fa-IR")} روز آزمایش رایگان</div> : null}
{plan.bundle?.product_codes?.length ? (
<div className="mt-6">
<h3 className="text-sm font-bold text-secondary">محصولات شاملشده</h3>
<div className="mt-3 flex flex-wrap gap-2">
{plan.bundle.product_codes.map((code) => <span key={code} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">{productName(code)}</span>)}
</div>
</div>
) : null}
{item.tax_class_code ? (
<p className="mt-1 text-[10px] text-gray-400">مالیات: {item.tax_class_code}</p>
) : null}
</li>
))}
{capabilityNames.length ? (
<ul className="mt-5 flex-1 space-y-2 text-sm text-gray-700">
{capabilityNames.map((code) => <li key={code} className="flex gap-2"><span className="text-emerald-600"></span><span>{humanizeCommercialCode(code)}</span></li>)}
</ul>
) : (
<CommercialEmptyState title="قیمت‌گذاری خالی است" description={pricing?.message} />
)}
</section>
<section className="mt-12">
<SectionTitle title="باندل‌ها" subtitle="قیمت داخل باندل نیست — فقط pricing_refs" />
{bundles?.availability === "ready" && bundles.data.length ? (
<div className="mt-4 grid gap-4">
{bundles.data.map((b) => (
<BundleCard key={b.bundle_code} bundle={b} />
))}
) : <div className="flex-1" />}
<div className="mt-7 grid gap-2">
<Link href="/discover"><Button className="w-full">{featured ? "انتخاب پلن پیشنهادی" : "شروع و انتخاب پلن"}</Button></Link>
<a href="#compare"><Button variant="outline" className="w-full">مقایسه امکانات</Button></a>
</div>
</article>
);
})}
</section>
) : (
<CommercialEmptyState title="باندلی نیست" description={bundles?.message} />
<div className="mt-12"><CommercialEmptyState title="پلنی برای نمایش آماده نیست" description="به‌زودی پلن‌های قابل خرید در این بخش نمایش داده می‌شوند." actionHref="/discover" actionLabel="دریافت پیشنهاد شخصی" /></div>
)}
{plans.length ? (
<section id="compare" className="mt-20 scroll-mt-10">
<div className="text-center"><h2 className="text-3xl font-extrabold text-secondary">مقایسه سریع پلنها</h2><p className="mt-2 text-sm text-gray-600">جزئیات مهم را در یک نگاه مقایسه کنید.</p></div>
<div className="mt-8 overflow-x-auto rounded-3xl border border-gray-200 bg-white shadow-sm">
<table className="w-full min-w-[680px] text-sm">
<thead className="bg-gray-50"><tr><th className="px-5 py-4 text-right">پلن</th><th className="px-5 py-4 text-right">پرداخت ماهانه</th><th className="px-5 py-4 text-right">پرداخت سالانه</th><th className="px-5 py-4 text-right">آزمایش رایگان</th><th className="px-5 py-4 text-right">محصولات</th></tr></thead>
<tbody>{plans.map((plan) => <tr key={plan.key} className="border-t border-gray-100"><td className="px-5 py-4 font-bold text-secondary">{plan.title}</td><td className="px-5 py-4">{formatMoney(plan.monthly?.amount_minor, plan.monthly?.currency)}</td><td className="px-5 py-4">{formatMoney(plan.yearly?.amount_minor, plan.yearly?.currency)}</td><td className="px-5 py-4">{plan.trial?.trial_days || plan.monthly?.trial_days ? "دارد" : "ندارد"}</td><td className="px-5 py-4">{(plan.bundle?.product_codes?.length || 0).toLocaleString("fa-IR")} محصول</td></tr>)}</tbody>
</table>
</div>
</section>
) : null}
<section className="mt-16 rounded-3xl bg-secondary px-6 py-10 text-center text-white sm:px-10">
<h2 className="text-2xl font-extrabold">برای انتخاب مطمئن کمک میخواهید؟</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/75">چند سؤال کوتاه پاسخ دهید تا فقط گزینههای مناسب کسبوکار شما پیشنهاد شوند.</p>
<Link href="/discover" className="mt-6 inline-block"><Button className="bg-white text-secondary hover:bg-gray-100">دریافت پیشنهاد هوشمند</Button></Link>
</section>
</Container>
</main>
);
}

View File

@ -104,7 +104,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "trial-rules",
label: "قوانین Trial",
label: "قوانین دوره آزمایشی",
description: "Trial duration, limits, grace, auto-disable",
listPath: "/api/v1/commercial/trial-rules",
createPath: "/api/v1/commercial/trial-rules",
@ -181,7 +181,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "metadata",
label: "Metadata",
label: "اطلاعات تکمیلی",
description: "Unlimited metadata bags",
listPath: "/api/v1/commercial/metadata",
createPath: "/api/v1/commercial/metadata",
@ -302,7 +302,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "marketplace-visibility",
label: "بازار / Visibility",
label: "نمایش در بازار",
description: "Marketplace visibility flags",
listPath: "/api/v1/commercial/marketplace-visibility",
createPath: "/api/v1/commercial/marketplace-visibility",
@ -357,7 +357,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "entitlements",
label: "Entitlements",
label: "سطوح دسترسی",
description: "Entitlement projections",
listPath: "/api/v1/commercial/entitlements",
createPath: "/api/v1/commercial/entitlements",

View File

@ -1,6 +1,7 @@
"use client";
import { Badge } from "@/components/ui";
import { enumLabel, humanizeCommercialCode } from "../presentation";
export function PlanBadge({
label,
@ -12,7 +13,7 @@ export function PlanBadge({
if (!label && !status) return <Badge variant="default">بدون پلن</Badge>;
return (
<Badge variant={status === "active" || status === "trialing" ? "primary" : "default"}>
{label || status}
{label ? humanizeCommercialCode(label) : enumLabel(status)}
</Badge>
);
}
@ -20,7 +21,7 @@ export function PlanBadge({
export function CapabilityBadge({ code, label }: { code: string; label?: string }) {
return (
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs text-secondary">
{label || code}
{humanizeCommercialCode(code, label)}
</span>
);
}

View File

@ -1,23 +1,50 @@
"use client";
import type { CommercialBundle } from "../types";
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
import { Badge, Button } from "@/components/ui";
import { enumLabel, formatMoney, humanizeCommercialCode } from "../presentation";
export function BundleCard({
bundle,
onSelect,
selected,
recommendationScore,
pricingItems = [],
products = [],
}: {
bundle: CommercialBundle;
onSelect?: () => void;
selected?: boolean;
recommendationScore?: number;
pricingItems?: CommercialPricingItem[];
products?: CommercialProduct[];
}) {
const features = bundle.feature_refs?.length
? bundle.feature_refs
: bundle.capability_codes || [];
const prices = (bundle.pricing_refs || [])
.map((ref) => pricingItems.find((item) => item.pricing_item_code === ref))
.filter((item): item is CommercialPricingItem => Boolean(item));
const productNames = (bundle.product_codes || []).map(
(code) => products.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code)
);
const badge = bundle.recommendation_badge
? enumLabel(bundle.recommendation_badge)
: recommendationScore != null
? "پیشنهاد ویژه برای شما"
: null;
const unresolvedPriceLabels = (bundle.pricing_refs || [])
.filter((ref) => !prices.some((item) => item.pricing_item_code === ref))
.map((ref) => {
if (/monthly/i.test(ref)) return "پرداخت ماهانه";
if (/yearly|annual/i.test(ref)) return "پرداخت سالانه";
if (/trial/i.test(ref)) return "آزمایش رایگان";
if (/enterprise|quote/i.test(ref)) return "استعلام قیمت سازمانی";
return "روش پرداخت قابل انتخاب";
});
const hasCoupon = bundle.discount_refs?.some((ref) => /coupon/i.test(ref));
return (
<div
className={`rounded-2xl border p-5 ${
@ -34,41 +61,64 @@ export function BundleCard({
) : null}
</div>
<div className="flex flex-wrap gap-1">
{bundle.recommendation_badge ? (
<Badge variant="primary">{bundle.recommendation_badge}</Badge>
{badge ? (
<Badge variant="primary">{badge}</Badge>
) : null}
{bundle.trial_eligible ? <Badge variant="default">Trial</Badge> : null}
{bundle.trial_eligible ? <Badge variant="default">آزمایش رایگان</Badge> : null}
{typeof recommendationScore === "number" ? (
<Badge variant="default">امتیاز {recommendationScore}</Badge>
<Badge variant="default">متناسب با نیاز شما</Badge>
) : null}
</div>
</div>
<p className="mt-2 font-mono text-[10px] text-gray-400" dir="ltr">
{bundle.bundle_code}
</p>
{bundle.product_codes?.length ? (
<p className="mt-3 text-xs text-gray-500">
محصولات: {bundle.product_codes.join("، ")}
</p>
{productNames.length ? (
<div className="mt-4">
<p className="text-xs font-semibold text-gray-500">محصولات این بسته</p>
<div className="mt-2 flex flex-wrap gap-2">
{productNames.map((name) => (
<span key={name} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">
{name}
</span>
))}
</div>
</div>
) : null}
{features.length ? (
<p className="mt-1 text-xs text-gray-500">
ویژگیها: {features.slice(0, 12).join("، ")}
{features.length > 12 ? "…" : ""}
</p>
<div className="mt-4">
<p className="text-xs font-semibold text-gray-500">امکانات کلیدی</p>
<div className="mt-2 flex flex-wrap gap-2">
{features.slice(0, 12).map((feature) => (
<span key={feature} className="rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-800">
{humanizeCommercialCode(feature)}
</span>
))}
</div>
</div>
) : null}
{bundle.pricing_refs?.length ? (
<p className="mt-1 text-xs text-gray-500">قیمتگذاری: {bundle.pricing_refs.join("، ")}</p>
{prices.length ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{prices.map((price) => (
<div key={price.pricing_item_code} className="rounded-xl bg-gray-50 p-3">
<p className="text-xs text-gray-500">{enumLabel(price.pricing_model)}</p>
<p className="mt-1 font-bold text-secondary">{formatMoney(price.amount_minor, price.currency)}</p>
{price.trial_days ? <p className="mt-1 text-xs text-primary">{price.trial_days} روز آزمایش رایگان</p> : null}
{price.tax_class_code ? <p className="mt-1 text-[11px] text-gray-500">مالیات مطابق قوانین محاسبه میشود</p> : null}
</div>
))}
</div>
) : null}
{unresolvedPriceLabels.length ? <div className="mt-4 flex flex-wrap gap-2">{Array.from(new Set(unresolvedPriceLabels)).map((label) => <span key={label} className="rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">{label}</span>)}</div> : null}
{bundle.discount_refs?.length ? (
<p className="mt-1 text-xs text-gray-500">تخفیف: {bundle.discount_refs.join("، ")}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium text-emerald-700">
<span className="rounded-full bg-emerald-50 px-3 py-1">تخفیف ویژه فعال</span>
{hasCoupon ? <span className="rounded-full bg-emerald-50 px-3 py-1">کوپن قابل استفاده</span> : null}
</div>
) : null}
{bundle.upgrade_path_code ? (
<p className="mt-1 text-xs text-primary">مسیر ارتقا: {bundle.upgrade_path_code}</p>
<p className="mt-1 text-xs text-primary">امکان ارتقا در هر زمان</p>
) : null}
{onSelect ? (
<Button className="mt-4 w-full" size="sm" variant={selected ? "primary" : "outline"} onClick={onSelect}>
{selected ? "انتخاب‌شده" : "انتخاب باندل"}
{selected ? "این بسته انتخاب شد" : "انتخاب این بسته"}
</Button>
) : null}
</div>

View File

@ -2,6 +2,7 @@
import Link from "next/link";
import { Button } from "@/components/ui";
import { friendlyMessage } from "../presentation";
export function CommercialEmptyState({
title,
@ -27,7 +28,7 @@ export function CommercialEmptyState({
>
<p className="text-base font-semibold text-secondary dark:text-gray-100">{title}</p>
{description ? (
<p className="mx-auto mt-2 max-w-md text-sm text-gray-600 dark:text-gray-400">{description}</p>
<p className="mx-auto mt-2 max-w-md text-sm text-gray-600 dark:text-gray-400">{friendlyMessage(description)}</p>
) : null}
{hint ? <p className="mx-auto mt-2 max-w-md text-xs text-gray-400">{hint}</p> : null}
{(actionHref && actionLabel) || (secondaryHref && secondaryLabel) ? (

View File

@ -25,16 +25,16 @@ export function CommercialSidebar({ className = "" }: { className?: string }) {
return (
<aside className={`rounded-2xl border border-gray-100 bg-white p-4 shadow-sm ${className}`}>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">Apps</p>
<p className="text-xs font-semibold tracking-wide text-gray-400">محصولات</p>
<nav className="mt-3 flex flex-col gap-1">
<Link href="/dashboard" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
Workspace
فضای کاری
</Link>
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
Products
</Link>
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
Billing
اشتراک و پرداخت
</Link>
<div className="my-2 border-t border-gray-100" />
{products.length ? (

View File

@ -3,6 +3,7 @@
import type { DomainPolicyResult } from "../types";
import { FeatureLock } from "./FeatureLock";
import { CommercialEmptyState } from "./CommercialEmptyState";
import { enumLabel, friendlyMessage } from "../presentation";
export function DomainPolicyPanel({
unlocked,
@ -20,15 +21,14 @@ export function DomainPolicyPanel({
<section className="space-y-3">
{!unlocked ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
{reason ||
"قبل از اشتراک فعال فقط زیردامنه پلتفرم در دسترس است (سیاست دامنه)."}
{friendlyMessage(reason, "در دوره آزمایشی، زیردامنه رایگان شما فعال است. برای اتصال دامنه اختصاصی، یک اشتراک فعال انتخاب کنید.")}
</div>
) : (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
دامنه اختصاصی بر اساس سیاست باز است.
{policy?.ssl_ready ? " · SSL آماده" : " · SSL از سیاست/بک‌اند"}
دامنه اختصاصی برای اشتراک شما فعال است.
{policy?.ssl_ready ? " · گواهی SSL آماده است" : " · گواهی SSL پس از اتصال صادر می‌شود"}
{policy?.verification_state
? ` · وضعیت تأیید: ${policy.verification_state}`
? ` · وضعیت تأیید: ${enumLabel(policy.verification_state)}`
: null}
</div>
)}
@ -40,7 +40,7 @@ export function DomainPolicyPanel({
reason={reason}
fallback={
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
دستورالعمل DNS و SSL تا باز شدن سیاست نمایش داده نمیشود قفل با دلیل ارتقا.
پس از فعالکردن اشتراک، راهنمای اتصال DNS و دریافت گواهی SSL اینجا نمایش داده میشود.
</div>
}
>
@ -65,8 +65,8 @@ export function DomainPolicyPanel({
</ul>
) : (
<CommercialEmptyState
title="دستورالعمل DNS از سیاست"
description="پس از ثبت دامنه، رکوردهای DNS از policy parameters می‌آیند."
title="راهنمای اتصال آماده نمایش است"
description="دامنه را ثبت کنید تا رکوردهای لازم برای اتصال و تأیید نمایش داده شوند."
/>
)}
</FeatureLock>

View File

@ -14,7 +14,7 @@ import { resolveProductLaunchUrl } from "../types";
const CORE_NAV = [
{ href: "/dashboard", label: "داشبورد" },
{ href: "/apps", label: "اپ‌ها" },
{ href: "/billing", label: "Billing" },
{ href: "/billing", label: "اشتراک و پرداخت" },
{ href: "/domains", label: "دامنه" },
{ href: "/notifications", label: "اعلان‌ها" },
{ href: "/help", label: "راهنما" },
@ -77,7 +77,7 @@ export function PlatformShell({
const sidebar = (
<div className="flex h-full flex-col">
<p className="px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
Workspace
فضای کاری
</p>
<nav className="mt-2 flex flex-col gap-0.5">{CORE_NAV.map((n) => navLink(n.href, n.label))}</nav>
@ -93,7 +93,7 @@ export function PlatformShell({
/>
<p className="mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
Apps
محصولات
</p>
<nav className="mt-2 flex max-h-64 flex-col gap-0.5 overflow-y-auto">
{filtered.length ? (
@ -104,7 +104,7 @@ export function PlatformShell({
<span
key={p.product_code}
className="rounded-xl px-3 py-2 text-sm text-gray-400"
title="default_route در رجیستری نیست"
title="این محصول هنوز آماده راه‌اندازی نیست"
>
{p.display_name}
</span>

View File

@ -6,6 +6,7 @@ import { resolveProductLaunchUrl } from "../types";
import { Button } from "@/components/ui";
import { FeatureLock } from "./FeatureLock";
import { pushRecent } from "../launcher-prefs";
import { enumLabel } from "../presentation";
export function ProductCard({
product,
@ -70,20 +71,18 @@ export function ProductCard({
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-secondary dark:text-gray-100">{product.display_name}</h3>
{product.category ? (
<span className="text-[10px] text-gray-400">{product.category}</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-600">
{enumLabel(product.category)}
</span>
) : null}
{typeof recommendationScore === "number" ? (
<span className="text-[10px] text-primary">امتیاز {recommendationScore}</span>
<span className="text-[10px] text-primary">پیشنهاد مناسب شما</span>
) : null}
</div>
{product.description ? (
<p className="mt-1 text-xs leading-5 text-gray-500">{product.description}</p>
) : null}
<p className="mt-1 font-mono text-[10px] text-gray-400" dir="ltr">
{product.product_slug || product.product_code}
{product.version ? ` @ ${product.version}` : ""}
{strategy ? ` · ${strategy}` : ""}
</p>
{product.trial_eligible ? <p className="mt-2 text-xs font-medium text-emerald-700">امکان استفاده آزمایشی</p> : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
@ -110,7 +109,7 @@ export function ProductCard({
}`}
onClick={onTogglePinned}
>
{pinned ? "📌" : "Pin"}
{pinned ? "📌" : "سنجاق"}
</button>
) : null}
<Link href={setupHref} className="rounded-lg bg-gray-100 px-2 py-1 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">

View File

@ -3,6 +3,7 @@
import type { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
import type { AdapterResult } from "../types";
import { CommercialEmptyState } from "./CommercialEmptyState";
import { humanizeCommercialCode, recommendationReason } from "../presentation";
function RecList({
title,
@ -21,13 +22,13 @@ function RecList({
key={`${title}-${i.code}`}
className="rounded-lg border border-gray-100 bg-white px-3 py-2 text-sm"
>
<span className="font-mono text-xs text-secondary" dir="ltr">
{i.code}
</span>
<span className="font-semibold text-secondary">{humanizeCommercialCode(i.code)}</span>
{typeof i.score === "number" ? (
<span className="ms-2 text-[10px] text-primary">امتیاز {i.score}</span>
<span className="ms-2 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] text-primary">
پیشنهاد متناسب
</span>
) : null}
{i.reason ? <p className="mt-0.5 text-xs text-gray-500">{i.reason}</p> : null}
<p className="mt-1 text-xs text-gray-500">{recommendationReason(i.reason)}</p>
</li>
))}
</ul>
@ -44,19 +45,16 @@ export function RecommendationsPanel({
return (
<CommercialEmptyState
title="پیشنهادها"
description={result?.message || "از recommendation API بارگذاری می‌شود."}
description="با پاسخ به چند سؤال کوتاه، پیشنهادهای مناسب کسب‌وکار شما اینجا نمایش داده می‌شود."
/>
);
}
const d = result.data;
return (
<div className="grid gap-4 sm:grid-cols-2">
<RecList title="محصولات پیشنهادی" items={d.recommended_products} />
<RecList title="باندل‌های پیشنهادی" items={d.recommended_bundles} />
<RecList title="سرویس‌های پیشنهادی" items={d.recommended_services} />
<RecList title="ارتقاهای پیشنهادی" items={d.recommended_upgrades} />
<RecList title="اتوماسیون پیشنهادی" items={d.recommended_automation_packs} />
<RecList title="افزونه‌های پیشنهادی" items={d.recommended_extensions} />
<div className="grid gap-5 lg:grid-cols-3">
<RecList title="ضروری" items={d.recommended_products?.slice(0, 3)} />
<RecList title="پیشنهادی" items={[...(d.recommended_bundles || []), ...(d.recommended_services || [])].slice(0, 4)} />
<RecList title="بعداً اضافه کنید" items={[...(d.recommended_upgrades || []), ...(d.recommended_automation_packs || []), ...(d.recommended_extensions || [])].slice(0, 4)} />
</div>
);
}

View File

@ -2,6 +2,13 @@
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) {
@ -20,7 +27,39 @@ function availabilityLabel(a: RegistryAvailability): string {
}
}
/** Generic registry list — no field whitelist; renders codes + display + optional metadata keys. */
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,
@ -53,7 +92,7 @@ export function RegistryBrowser({
<div className="mt-2">
<CommercialEmptyState
title={emptyTitle || `${title}${availabilityLabel(result.availability)}`}
description={result.message}
description={friendlyMessage(result.message)}
/>
</div>
</section>
@ -68,26 +107,35 @@ export function RegistryBrowser({
{result.data.map((raw, idx) => {
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const code = String(item[codeKey] ?? idx);
const name = String(item[nameKey] ?? code);
const explicitName = typeof item[nameKey] === "string" ? String(item[nameKey]) : null;
const name = humanizeCommercialCode(code, explicitName);
const meta = item.metadata;
const extraKeys =
meta && typeof meta === "object" && !Array.isArray(meta)
? Object.keys(meta as Record<string, unknown>).slice(0, 6)
: [];
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>
<p className="mt-0.5 font-mono text-[10px] text-gray-400" dir="ltr">
{code}
</p>
{extraKeys.length ? (
<p className="mt-1 text-[10px] text-gray-500" dir="ltr">
metadata: {extraKeys.join(", ")}
{Object.keys(meta as object).length > 6 ? "…" : ""}
</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>
);
@ -114,7 +162,7 @@ export function MetadataBagView({
return (
<CommercialEmptyState
title={title}
description={message || availabilityLabel(availability || "empty")}
description={friendlyMessage(message, availabilityLabel(availability || "empty"))}
/>
);
}
@ -122,13 +170,11 @@ export function MetadataBagView({
<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.map(([key, value]) => (
<div key={key} className="flex flex-wrap gap-2 text-xs">
<dt className="font-mono text-gray-400" dir="ltr">
{key}
</dt>
<dd className="text-secondary" dir="auto">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
{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>
))}

View File

@ -13,7 +13,7 @@ export function SetupChecklist({
if (!items.length) {
return (
<p className="text-sm text-gray-500">
چکلیست راهاندازی از commercial checklist API یا محصولات کشفشده ساخته میشود.
مراحل راهاندازی پس از انتخاب محصول اینجا نمایش داده میشود.
</p>
);
}

View File

@ -2,6 +2,7 @@
import Link from "next/link";
import type { TrialState } from "../types";
import { enumLabel, humanizeCommercialCode } from "../presentation";
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
if (!trial) return null;
@ -65,7 +66,7 @@ export function TrialBanner({ trial }: { trial: TrialState | null | undefined })
q.used / q.limit >= 0.8;
return (
<li key={q.key} className={nearLimit ? "font-semibold text-orange-800 dark:text-orange-200" : ""}>
{q.label || q.key}: {q.used ?? "—"} / {q.limit ?? "∞"}
{humanizeCommercialCode(q.key, q.label)}: {(q.used ?? 0).toLocaleString("fa-IR")} از {q.limit == null ? "نامحدود" : q.limit.toLocaleString("fa-IR")}
{nearLimit ? " · نزدیک سقف" : ""}
</li>
);
@ -88,7 +89,7 @@ export function SubscriptionBanner({
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
اشتراک ثبت نشده است.{" "}
<Link href="/billing" className="font-semibold text-primary hover:underline">
شروع trial یا انتخاب پلن
شروع دوره آزمایشی یا انتخاب پلن
</Link>
</div>
);
@ -103,11 +104,11 @@ export function SubscriptionBanner({
: "border-gray-200 bg-gray-50 text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200";
return (
<div className={`rounded-2xl border px-4 py-3 text-sm ${tone}`} role="status">
وضعیت اشتراک: <span className="font-semibold">{status}</span>
وضعیت اشتراک: <span className="font-semibold">{enumLabel(status)}</span>
{planName ? (
<>
{" "}
· پلن: <span className="font-semibold">{planName}</span>
· پلن: <span className="font-semibold">{humanizeCommercialCode(planName)}</span>
</>
) : null}
{(status === "past_due" || status === "expired" || status === "suspended") && (

View File

@ -3,6 +3,7 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import { friendlyMessage, humanizeCommercialCode } from "../presentation";
export function UpgradePrompt({
open,
@ -53,28 +54,28 @@ export function UpgradePrompt({
{title}
</h3>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{message || "این قابلیت در پلن فعلی یا دوره آزمایشی محدود شده است."}
{friendlyMessage(message, "این قابلیت در پلن فعلی یا دوره آزمایشی محدود شده است.")}
</p>
<ul className="mt-3 space-y-1 text-xs text-gray-500">
{requiredPlan ? (
<li>
پلن لازم: <span className="font-mono text-secondary dark:text-gray-200">{requiredPlan}</span>
پلن مناسب: <span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredPlan)}</span>
</li>
) : null}
{requiredCapability ? (
<li>
قابلیت لازم:{" "}
<span className="font-mono text-secondary dark:text-gray-200">{requiredCapability}</span>
<span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredCapability)}</span>
</li>
) : null}
{requiredBundle ? (
<li>
باندل لازم:{" "}
<span className="font-mono text-secondary dark:text-gray-200">{requiredBundle}</span>
بسته مناسب:{" "}
<span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredBundle)}</span>
</li>
) : null}
</ul>
<p className="mt-3 text-xs text-gray-400">مسیر ارتقا: Billing انتخاب پلن فعالسازی</p>
<p className="mt-3 text-xs text-gray-400">برای ادامه، یک پلن مناسب انتخاب و فعال کنید.</p>
<div className="mt-6 flex flex-wrap justify-end gap-2">
{onClose ? (
<button

View File

@ -8,6 +8,7 @@ import {
COMMERCIAL_ADMIN_RESOURCES,
type CommercialAdminResourceDef,
} from "../admin/resources";
import { humanizeCommercialCode } from "../presentation";
type CatalogEntry = {
kind: string;
@ -26,8 +27,6 @@ export function AdminCommercialHub() {
const [entries, setEntries] = useState<
Array<{ key: string; label: string; description: string; href: string; listPath: string }>
>([]);
const [source, setSource] = useState<"catalog" | "bindings">("bindings");
const [error, setError] = useState("");
useEffect(() => {
void (async () => {
@ -41,40 +40,33 @@ export function AdminCommercialHub() {
const key = binding?.key || r.path || r.kind;
return {
key,
label: r.display_name || binding?.label || r.kind,
description: r.description || binding?.description || "Commercial registry",
label: humanizeCommercialCode(r.kind, r.display_name || binding?.label),
description: `مدیریت و انتشار ${humanizeCommercialCode(r.kind, r.display_name || binding?.label)}`,
href: `/admin/commercial/${key}`,
listPath: path,
};
})
);
setSource("catalog");
return;
}
setError(res.ok ? "" : res.message);
setEntries(
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
key: r.key,
label: r.label,
description: r.description,
description: `مدیریت و انتشار ${r.label}`,
href: `/admin/commercial/${r.key}`,
listPath: r.listPath,
}))
);
setSource("bindings");
})();
}, []);
return (
<div className="space-y-8">
<AdminPageHeader
title="پرتال تجاری (Commercial Admin)"
subtitle="کشف رجیستری از Commercial Runtime catalog — SoT واحد. تغییرات بلافاصله در tenant دیده می‌شود."
title="مدیریت تجاری"
subtitle="محصولات، بسته‌ها، قیمت‌ها و اشتراک‌ها را از یک فضای ساده مدیریت و منتشر کنید."
/>
<p className="text-xs text-gray-500" dir="ltr">
discovery: {source}
{error ? ` · ${error}` : ""}
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{entries.map((r) => (
@ -85,15 +77,12 @@ export function AdminCommercialHub() {
>
<p className="font-semibold text-secondary">{r.label}</p>
<p className="mt-1 text-xs text-gray-500">{r.description}</p>
<p className="mt-2 font-mono text-[10px] text-gray-400" dir="ltr">
{r.listPath}
</p>
</Link>
))}
</div>
<section>
<h2 className="mb-3 text-sm font-bold text-secondary">Tenant / Activation</h2>
<h2 className="mb-3 text-sm font-bold text-secondary">مشتریان و فعالسازی</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{COMMERCIAL_ADMIN_RESOURCES.filter((r) => r.group === "tenant").map((r) => (
<Link
@ -102,7 +91,7 @@ export function AdminCommercialHub() {
className={`${cardClass} transition-shadow hover:shadow-md`}
>
<p className="font-semibold text-secondary">{r.label}</p>
<p className="mt-1 text-xs text-gray-500">{r.description}</p>
<p className="mt-1 text-xs text-gray-500">مدیریت و پیگیری {r.label}</p>
</Link>
))}
</div>

View File

@ -1,56 +1,61 @@
"use client";
import { FormEvent, useState } from "react";
import { Banner, Button, TextareaField } from "@/components/admin/controls";
import { Banner, Button } from "@/components/admin/controls";
import { simulateRecommendations } from "../adapters/admin";
import { MetadataBagView } from "../components/RegistryBrowser";
import { friendlyMessage } from "../presentation";
/** Recommendation rule simulator — no AI; rule engine API only. */
export function AdminRecommendationSimulator() {
const [body, setBody] = useState(
JSON.stringify({ answers: { business_type_code: "" }, version: "recommendation.v1" }, null, 2)
);
const [out, setOut] = useState("");
const [answers, setAnswers] = useState<Record<string, unknown>>({
business_type_code: "",
employees: 1,
branches: 1,
in_person_sales: false,
online_sales: false,
delivery: false,
booking: false,
loyalty: false,
});
const [result, setResult] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const run = async (e: FormEvent) => {
e.preventDefault();
const setAnswer = (key: string, value: unknown) => setAnswers((current) => ({ ...current, [key]: value }));
const run = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError("");
setOut("");
try {
const parsed = JSON.parse(body) as { answers?: Record<string, unknown> };
const res = await simulateRecommendations(parsed.answers || parsed);
if (!res.ok) {
setError(res.message);
return;
}
setOut(JSON.stringify(res.data, null, 2));
} catch {
setError("JSON نامعتبر");
} finally {
setResult(null);
const response = await simulateRecommendations(answers);
if (!response.ok) setError(friendlyMessage(response.message, "اجرای نمونه پیشنهاد انجام نشد. لطفاً دوباره تلاش کنید."));
else setResult(response.data && typeof response.data === "object" ? response.data as Record<string, unknown> : {});
setBusy(false);
}
};
return (
<section className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
<h3 className="text-base font-bold text-secondary">شبیهساز پیشنهاد</h3>
<p className="mt-1 text-xs text-gray-500">
POST /api/v1/commercial/recommendations بدون مدل AI.
</p>
<h3 className="text-base font-bold text-secondary">پیشنمایش پیشنهاد به مشتری</h3>
<p className="mt-1 text-xs text-gray-500">یک سناریوی واقعی بسازید و نتیجهای را که مشتری میبیند بررسی کنید.</p>
{error ? <div className="mt-3"><Banner variant="error">{error}</Banner></div> : null}
<form onSubmit={(e) => void run(e)} className="mt-4 space-y-3">
<TextareaField label="Request JSON" value={body} onValue={setBody} rows={8} dir="ltr" className="font-mono text-xs" />
<Button type="submit" loading={busy}>
اجرا
</Button>
<form onSubmit={(event) => void run(event)} className="mt-5 space-y-5">
<div className="grid gap-4 sm:grid-cols-3">
<label className="block text-sm font-medium text-secondary">نوع کسبوکار<input value={String(answers.business_type_code || "")} onChange={(event) => setAnswer("business_type_code", event.target.value)} className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5" placeholder="مثلاً رستوران" /></label>
<label className="block text-sm font-medium text-secondary">تعداد پرسنل<input type="number" min={1} value={Number(answers.employees)} onChange={(event) => setAnswer("employees", Number(event.target.value))} className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5" /></label>
<label className="block text-sm font-medium text-secondary">تعداد شعبه<input type="number" min={1} value={Number(answers.branches)} onChange={(event) => setAnswer("branches", Number(event.target.value))} className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5" /></label>
</div>
<fieldset><legend className="text-sm font-semibold text-secondary">نیازهای کسبوکار</legend><div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{[
["in_person_sales", "فروش حضوری"],
["online_sales", "فروش آنلاین"],
["delivery", "ارسال کالا"],
["booking", "مدیریت رزرو"],
["loyalty", "باشگاه مشتریان"],
].map(([key, label]) => <label key={key} className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 px-3 py-2.5 text-sm"><input type="checkbox" checked={Boolean(answers[key])} onChange={(event) => setAnswer(key, event.target.checked)} />{label}</label>)}
</div></fieldset>
<Button type="submit" loading={busy}>نمایش نتیجه پیشنهادی</Button>
</form>
{out ? (
<pre className="mt-4 max-h-80 overflow-auto rounded-xl bg-gray-50 p-3 font-mono text-[11px]" dir="ltr">
{out}
</pre>
) : null}
{result ? <div className="mt-6"><MetadataBagView title="نتیجه قابل نمایش به مشتری" attributes={result} /></div> : null}
</section>
);
}

View File

@ -2,15 +2,7 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import {
AdminPageHeader,
Banner,
Button,
EmptyState,
Modal,
StatusBadge,
TextareaField,
} from "@/components/admin/controls";
import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls";
import type { CommercialAdminResourceDef } from "../admin/resources";
import {
createAdminRegistryItem,
@ -20,13 +12,28 @@ import {
updateAdminRegistryItem,
} from "../adapters/admin";
import type { AdapterResult } from "../types";
import {
enumLabel,
fieldLabel,
friendlyMessage,
humanizeCommercialCode,
isTechnicalField,
} from "../presentation";
const PAGE_SIZE = 10;
const FIELD_GROUPS = [
{ title: "اطلاعات پایه", keys: ["display_name", "title", "description", "category"] },
{ title: "اطلاعات تجاری", keys: ["role", "pricing_model", "amount_minor", "currency", "interval", "trial_days", "duration_days", "grace_period_days"] },
{ title: "قیمت‌گذاری", keys: ["pricing_refs", "tax_class_code", "discount_refs", "coupon_code"] },
{ title: "قابلیت‌ها", keys: ["product_codes", "capability_codes", "feature_refs", "required_capabilities"] },
{ title: "نمایش", keys: ["visibility", "display_order", "trial_eligible"] },
{ title: "انتشار", keys: ["status", "published_at", "starts_at", "ends_at"] },
{ title: "اطلاعات تکمیلی", keys: ["metadata", "attributes", "parameters", "usage_limits", "feature_limits"] },
] as const;
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
const name = item[resource.nameField];
if (typeof name === "string" && name) return name;
const code = item[resource.codeField];
if (typeof code === "string" || typeof code === "number") return String(code);
return "—";
return humanizeCommercialCode(item[resource.codeField], typeof name === "string" ? name : null);
}
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
@ -34,38 +41,157 @@ function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef):
return code == null ? "" : String(code);
}
function defaultCreateJson(resource: CommercialAdminResourceDef): string {
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
const seed: RegistryRecord = {
[resource.codeField]: "",
[resource.nameField]: "",
status: "draft",
};
if (resource.key === "products") {
seed.visibility = "public";
seed.launch_strategy = "route";
seed.default_route = "";
seed.product_slug = "";
seed.display_order = 0;
if (resource.key === "products") Object.assign(seed, { visibility: "public", display_order: 0 });
if (resource.key === "trial-rules") Object.assign(seed, { duration_days: 14, grace_period_days: 3, auto_disable: true });
if (resource.key === "pricing") Object.assign(seed, { pricing_model: "monthly", currency: "IRR", amount_minor: 0 });
if (resource.key === "policies") Object.assign(seed, { effect: "deny", parameters: { custom_domain_allowed: false, subdomain_only: true, ssl_ready: false } });
return seed;
}
function parseFieldValue(key: string, input: string, current: unknown): unknown {
if (Array.isArray(current) || /(_refs|_codes|capabilities)$/.test(key)) {
return input.split("،").flatMap((part) => part.split(",")).map((part) => part.trim()).filter(Boolean);
}
if (resource.key === "trial-rules") {
seed.duration_days = 14;
seed.grace_period_days = 3;
seed.usage_limits = {};
seed.feature_limits = {};
seed.expiration_behavior = "lock";
seed.auto_disable = true;
if (typeof current === "number" || /(amount|days|order|percent|limit)$/.test(key)) {
return input === "" ? null : Number(input);
}
if (resource.key === "policies") {
seed.policy_kind = "domain";
seed.effect = "deny";
seed.parameters = {
custom_domain_allowed: false,
subdomain_only: true,
ssl_ready: false,
dns_instructions: [],
return input;
}
function FieldInput({
name,
value,
onChange,
}: {
name: string;
value: unknown;
onChange: (value: unknown) => void;
}) {
if (Array.isArray(value)) {
return (
<div className="sm:col-span-2">
<p className="text-sm font-medium text-secondary">{fieldLabel(name)}</p>
<div className="mt-2 flex flex-wrap gap-2">
{value.length ? value.map((item, index) => (
<span key={`${String(item)}-${index}`} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">
{humanizeCommercialCode(item)}
</span>
)) : <span className="text-xs text-gray-500">موردی انتخاب نشده است.</span>}
</div>
</div>
);
}
if (typeof value === "boolean") {
return (
<label className="flex cursor-pointer items-center justify-between rounded-xl border border-gray-200 px-4 py-3">
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
<input type="checkbox" checked={value} onChange={(event) => onChange(event.target.checked)} className="h-4 w-4 accent-primary" />
</label>
);
}
if (value && typeof value === "object" && !Array.isArray(value)) {
return (
<fieldset className="rounded-2xl border border-gray-200 p-4">
<legend className="px-2 text-sm font-semibold text-secondary">{fieldLabel(name)}</legend>
<div className="grid gap-4 sm:grid-cols-2">
{Object.entries(value as Record<string, unknown>)
.filter(([key]) => !isTechnicalField(key))
.map(([key, nested]) => (
<FieldInput
key={key}
name={key}
value={nested}
onChange={(next) => onChange({ ...(value as Record<string, unknown>), [key]: next })}
/>
))}
</div>
</fieldset>
);
}
const displayValue = value == null ? "" : String(value);
const selectOptions: Record<string, string[]> = {
status: ["draft", "active", "published", "inactive"],
visibility: ["public", "private", "hidden"],
pricing_model: ["monthly", "yearly", "trial", "enterprise_quote"],
interval: ["monthly", "yearly"],
currency: ["IRR"],
};
if (selectOptions[name]) {
return (
<label className="block">
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
<select value={displayValue} onChange={(event) => onChange(event.target.value)} className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm">
{selectOptions[name].map((option) => <option key={option} value={option}>{enumLabel(option)}</option>)}
</select>
</label>
);
}
return JSON.stringify(seed, null, 2);
return (
<label className={name === "description" ? "block sm:col-span-2" : "block"}>
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
{name === "description" ? (
<textarea value={displayValue} onChange={(event) => onChange(event.target.value)} rows={3} className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5 text-sm" />
) : (
<input
type={typeof value === "number" ? "number" : /(_at|date)$/.test(name) ? "date" : "text"}
value={displayValue}
onChange={(event) => onChange(parseFieldValue(name, event.target.value, value))}
placeholder={Array.isArray(value) ? "موارد را با ویرگول جدا کنید" : undefined}
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5 text-sm"
/>
)}
</label>
);
}
function CommercialRecordForm({
value,
resource,
onChange,
}: {
value: RegistryRecord;
resource: CommercialAdminResourceDef;
onChange: (record: RegistryRecord) => void;
}) {
const visibleKeys = Object.keys(value).filter((key) => !isTechnicalField(key));
const grouped = new Set<string>(FIELD_GROUPS.flatMap((group) => [...group.keys]));
const advanced = visibleKeys.filter((key) => !grouped.has(key) && key !== resource.nameField);
const setField = (key: string, next: unknown) => onChange({ ...value, [key]: next });
return (
<div className="max-h-[65vh] space-y-6 overflow-y-auto px-1">
{FIELD_GROUPS.map((group) => {
const keys = Array.from(new Set([
...(group.title === "اطلاعات پایه" ? [resource.nameField] : []),
...group.keys,
])).filter((key) => key in value && !isTechnicalField(key));
if (!keys.length) return null;
return (
<section key={group.title} className="rounded-2xl bg-gray-50 p-4">
<h3 className="mb-4 text-sm font-bold text-secondary">{group.title}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{keys.map((key) => <FieldInput key={key} name={key} value={value[key]} onChange={(next) => setField(key, next)} />)}
</div>
</section>
);
})}
{advanced.length ? (
<details className="rounded-2xl border border-gray-200 p-4">
<summary className="cursor-pointer text-sm font-bold text-secondary">تنظیمات پیشرفته</summary>
<p className="mt-2 text-xs text-gray-500">این بخش برای تنظیمات کماستفاده است. تغییر آنها ممکن است روی انتشار اثر بگذارد.</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{advanced.map((key) => <FieldInput key={key} name={key} value={value[key]} onChange={(next) => setField(key, next)} />)}
</div>
</details>
) : null}
</div>
);
}
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
@ -75,181 +201,145 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [editItem, setEditItem] = useState<RegistryRecord | null>(null);
const [jsonBody, setJsonBody] = useState("");
const [formBody, setFormBody] = useState<RegistryRecord>({});
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [sortAsc, setSortAsc] = useState(true);
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<Set<string>>(new Set());
const load = useCallback(async () => {
setLoading(true);
setError("");
const r = await listAdminRegistry(resource);
setResult(r);
if (r.availability === "unavailable" || r.availability === "permission_denied" || r.availability === "error") {
setError(r.message || "رجیستری در دسترس نیست");
const response = await listAdminRegistry(resource);
setResult(response);
if (["unavailable", "permission_denied", "error"].includes(response.availability)) {
setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید."));
}
setLoading(false);
}, [resource]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => { void load(); }, [load]);
const items = result?.data ?? [];
const items = useMemo(() => result?.data ?? [], [result]);
const filtered = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase("fa");
return items
.filter((item) => statusFilter === "all" || item.status === statusFilter)
.filter((item) => !normalized || recordLabel(item, resource).toLocaleLowerCase("fa").includes(normalized))
.sort((a, b) => recordLabel(a, resource).localeCompare(recordLabel(b, resource), "fa") * (sortAsc ? 1 : -1));
}, [items, query, resource, sortAsc, statusFilter]);
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const visible = filtered.slice((Math.min(page, pages) - 1) * PAGE_SIZE, Math.min(page, pages) * PAGE_SIZE);
const openCreate = () => {
setJsonBody(defaultCreateJson(resource));
setFormBody(defaultRecord(resource));
setCreateOpen(true);
setMsg("");
setError("");
};
const openEdit = (item: RegistryRecord) => {
setEditItem(item);
setJsonBody(JSON.stringify(item, null, 2));
setFormBody({ ...item });
setMsg("");
setError("");
};
const submitCreate = async (e: FormEvent) => {
e.preventDefault();
const submitCreate = async (event: FormEvent) => {
event.preventDefault();
setBusy(true);
setError("");
try {
const body = JSON.parse(jsonBody) as RegistryRecord;
const res = await createAdminRegistryItem(resource, body);
if (!res.ok) {
setError(res.message);
return;
const body = { ...formBody };
if (!body[resource.codeField]) {
body[resource.codeField] = `${resource.key.replace(/s$/, "")}.${Date.now().toString(36)}`;
}
setCreateOpen(false);
setMsg("ثبت شد — tenant UI از همین رجیستری می‌خواند.");
await load();
} catch {
setError("JSON نامعتبر است");
} finally {
setBusy(false);
}
};
const submitEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editItem) return;
setBusy(true);
setError("");
try {
const body = JSON.parse(jsonBody) as RegistryRecord;
const code = recordCode(editItem, resource) || String(body[resource.codeField] ?? "");
if (!code) {
setError("کد رکورد مشخص نیست");
return;
}
const res = await updateAdminRegistryItem(resource, code, body);
if (!res.ok) {
setError(res.message);
return;
}
setEditItem(null);
setMsg("به‌روزرسانی شد — تغییرات بلافاصله در tenant قابل مشاهده است.");
await load();
} catch {
setError("JSON نامعتبر است");
} finally {
setBusy(false);
}
};
const onDelete = async (item: RegistryRecord) => {
const code = recordCode(item, resource);
if (!code) return;
if (!window.confirm(`حذف ${code}؟`)) return;
setBusy(true);
const res = await deleteAdminRegistryItem(resource, code);
if (!res.ok) setError(res.message);
const response = await createAdminRegistryItem(resource, body);
if (!response.ok) setError(friendlyMessage(response.message, "ثبت اطلاعات انجام نشد. لطفاً فیلدهای ضروری را بررسی کنید."));
else {
setMsg("حذف شد");
setCreateOpen(false);
setMsg("اطلاعات با موفقیت ثبت شد.");
await load();
}
setBusy(false);
};
const availabilityHint = useMemo(() => {
if (!result) return null;
if (result.availability === "unavailable") {
return "Core Commercial API هنوز در دسترس نیست — بدون داده جعلی.";
const submitEdit = async (event: FormEvent) => {
event.preventDefault();
if (!editItem) return;
const code = recordCode(editItem, resource);
if (!code) return setError("شناسه این مورد پیدا نشد. صفحه را تازه‌سازی کنید.");
setBusy(true);
const response = await updateAdminRegistryItem(resource, code, formBody);
if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید."));
else {
setEditItem(null);
setMsg("تغییرات با موفقیت ذخیره شد.");
await load();
}
if (result.availability === "empty") return "رجیستری خالی است.";
if (result.availability === "permission_denied") return "دسترسی تجاری ندارید.";
return null;
}, [result]);
setBusy(false);
};
const removeItems = async (targets: RegistryRecord[]) => {
if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return;
setBusy(true);
for (const item of targets) {
const code = recordCode(item, resource);
if (code) {
const response = await deleteAdminRegistryItem(resource, code);
if (!response.ok) {
setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد."));
break;
}
}
}
setSelected(new Set());
await load();
setBusy(false);
};
return (
<div className="space-y-6">
<AdminPageHeader
title={resource.label}
subtitle={resource.description}
action={
<div className="flex flex-wrap gap-2">
<Link href="/admin/commercial">
<Button variant="outline">بازگشت</Button>
</Link>
<Button onClick={openCreate}>افزودن / ثبت</Button>
</div>
}
subtitle={`جست‌وجو، ویرایش، انتشار و مدیریت ${resource.label}`}
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link><Button onClick={openCreate}>افزودن مورد جدید</Button></div>}
/>
{error ? <Banner variant="error">{error}</Banner> : null}
{msg ? <Banner variant="success">{msg}</Banner> : null}
{availabilityHint ? <Banner variant="info">{availabilityHint}</Banner> : null}
<p className="font-mono text-[11px] text-gray-400" dir="ltr">
{resource.listPath}
</p>
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
<label className="relative min-w-[220px] flex-1">
<span className="sr-only">جستوجو</span>
<span className="pointer-events-none absolute right-3 top-2.5" aria-hidden="true"></span>
<input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder={`جست‌وجو در ${resource.label}`} className="w-full rounded-xl border border-gray-200 py-2.5 pl-4 pr-10 text-sm" />
</label>
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} aria-label="فیلتر وضعیت" className="rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm">
<option value="all">همه وضعیتها</option><option value="active">فعال</option><option value="published">منتشرشده</option><option value="draft">پیشنویس</option><option value="inactive">غیرفعال</option>
</select>
<Button variant="outline" size="sm" onClick={() => setSortAsc((value) => !value)}>مرتبسازی {sortAsc ? "الف تا ی" : "ی تا الف"}</Button>
{selected.size ? <Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems(items.filter((item) => selected.has(recordCode(item, resource))))}>حذف انتخابشدهها</Button> : null}
</div>
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
{loading ? (
<EmptyState message="در حال بارگذاری..." />
) : items.length === 0 ? (
<EmptyState message={result?.message || "موردی در رجیستری نیست."} />
<div className="space-y-3 p-5" aria-label="در حال بارگذاری">{[1, 2, 3].map((i) => <div key={i} className="h-12 animate-pulse rounded-xl bg-gray-100" />)}</div>
) : !visible.length ? (
<EmptyState message={query || statusFilter !== "all" ? "موردی با این جست‌وجو پیدا نشد." : "هنوز موردی ثبت نشده است."} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-gray-50 text-right">
<tr>
<th className="px-4 py-3 font-semibold text-secondary">نام</th>
<th className="px-4 py-3 font-semibold text-secondary">کد</th>
<th className="px-4 py-3 font-semibold text-secondary">وضعیت</th>
<th className="px-4 py-3 font-semibold text-secondary">عملیات</th>
</tr>
</thead>
<table className="w-full min-w-[680px] text-sm">
<thead className="bg-gray-50 text-right"><tr><th className="w-12 px-4 py-3"><span className="sr-only">انتخاب</span></th><th className="px-4 py-3 font-semibold text-secondary">نام</th><th className="px-4 py-3 font-semibold text-secondary">وضعیت</th><th className="px-4 py-3 font-semibold text-secondary">عملیات</th></tr></thead>
<tbody>
{items.map((item, idx) => {
const code = recordCode(item, resource) || String(idx);
const status = typeof item.status === "string" ? item.status : "";
{visible.map((item, index) => {
const code = recordCode(item, resource) || `row-${index}`;
const status = typeof item.status === "string" ? item.status : "inactive";
return (
<tr key={code} className="border-t border-gray-100">
<td className="px-4 py-3 font-medium text-secondary">
{recordLabel(item, resource)}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600" dir="ltr">
{code}
</td>
<td className="px-4 py-3">
<StatusBadge status={status === "—" ? "inactive" : status} />
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(item)}>
ویرایش
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => void onDelete(item)}
>
حذف
</Button>
</div>
</td>
<tr key={code} className="border-t border-gray-100 hover:bg-gray-50/60">
<td className="px-4 py-3"><input type="checkbox" checked={selected.has(code)} aria-label={`انتخاب ${recordLabel(item, resource)}`} onChange={() => setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /></td>
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
<td className="px-4 py-3"><span className={`rounded-full px-2.5 py-1 text-xs ${status === "active" || status === "published" ? "bg-emerald-50 text-emerald-700" : status === "draft" ? "bg-amber-50 text-amber-700" : "bg-gray-100 text-gray-600"}`}>{enumLabel(status)}</span></td>
<td className="px-4 py-3"><div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => openEdit(item)}><span aria-hidden="true"></span> ویرایش</Button><Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems([item])}><span aria-hidden="true"></span> حذف</Button></div></td>
</tr>
);
})}
@ -257,54 +347,25 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
</table>
</div>
)}
{filtered.length > PAGE_SIZE ? (
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-xs text-gray-500">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>قبلی</Button>
<span>صفحه {Math.min(page, pages).toLocaleString("fa-IR")} از {pages.toLocaleString("fa-IR")}</span>
<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => setPage((value) => value + 1)}>بعدی</Button>
</div>
) : null}
</section>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`ثبت ${resource.label}`}>
<form onSubmit={(e) => void submitCreate(e)} className="space-y-4">
<p className="text-xs text-gray-500">
بدنه JSON مستقیم به API رجیستری ارسال میشود هیچ کاتالوگ ثابتی در frontend نیست.
</p>
<TextareaField
label="Payload (JSON)"
value={jsonBody}
onValue={setJsonBody}
rows={16}
dir="ltr"
className="font-mono text-xs"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={busy}>
ثبت در رجیستری
</Button>
</div>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`افزودن ${resource.label}`}>
<form onSubmit={(event) => void submitCreate(event)} className="space-y-5">
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>انصراف</Button><Button type="submit" loading={busy}>ثبت و ذخیره</Button></div>
</form>
</Modal>
<Modal
open={Boolean(editItem)}
onClose={() => setEditItem(null)}
title={`ویرایش ${resource.label}`}
>
<form onSubmit={(e) => void submitEdit(e)} className="space-y-4">
<TextareaField
label="Payload (JSON)"
value={jsonBody}
onValue={setJsonBody}
rows={16}
dir="ltr"
className="font-mono text-xs"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditItem(null)}>
انصراف
</Button>
<Button type="submit" loading={busy}>
ذخیره
</Button>
</div>
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={`ویرایش ${resource.label}`}>
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setEditItem(null)}>انصراف</Button><Button type="submit" loading={busy}>ذخیره تغییرات</Button></div>
</form>
</Modal>
</div>

View File

@ -148,8 +148,8 @@ function AppsInner() {
return (
<PlatformShell
title="Apps Launcher"
subtitle="گروه‌بندی پویا از category رجیستری — بدون مسیر ثابت frontend"
title="محصولات من"
subtitle="همه ابزارهای کسب‌وکار شما، مرتب‌شده و آماده استفاده."
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
{q ? <p className="text-xs text-gray-500">نتایج برای: {q}</p> : <span />}
@ -249,18 +249,18 @@ function AppsInner() {
</div>
) : booted ? (
<CommercialEmptyState
title="کاتالوگ محصول خالی/غیرفعال"
description={products?.message}
title="هنوز محصولی فعال نکرده‌اید"
description="با پاسخ به چند سؤال کوتاه، محصولات مناسب کسب‌وکار خود را پیدا کنید."
actionHref="/discover"
actionLabel="کشف کسب‌وکار"
secondaryHref="/help"
secondaryLabel="راهنما"
hint="پس از ثبت محصول در Admin Commercial، اینجا ظاهر می‌شود."
hint="پس از انتخاب و راه‌اندازی، محصولات شما در همین صفحه ظاهر می‌شوند."
/>
) : null}
<section className="mt-12">
<SectionTitle title="Bundles" />
<SectionTitle title="بسته‌های شما" />
{bundles?.availability === "ready" && bundles.data.length ? (
<div className="mt-3 grid gap-3">
{bundles.data.map((b) => (
@ -269,11 +269,11 @@ function AppsInner() {
</div>
) : (
<CommercialEmptyState
title="باندل نیست"
description={bundles?.message}
hint="باندل‌ها از رجیستری تجاری می‌آیند."
title="هنوز بسته‌ای انتخاب نکرده‌اید"
description="بسته مناسب، محصولات مکمل را با شرایط بهتر کنار هم قرار می‌دهد."
hint="ارزیابی را انجام دهید تا فقط بسته‌های مناسب شما نمایش داده شود."
actionHref="/discover"
actionLabel="کشف باندل"
actionLabel="پیدا کردن بسته مناسب"
secondaryHref="/pricing"
secondaryLabel="قیمت‌گذاری"
/>
@ -281,15 +281,15 @@ function AppsInner() {
</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="دارایی‌های دیجیتال" result={assets} codeKey="asset_code" />
<RegistryBrowser title="افزونه‌ها" result={extensions} codeKey="extension_code" />
<RegistryBrowser
title="Automation Packs"
title="بسته‌های خودکارسازی"
result={automation}
codeKey="automation_pack_code"
/>
<MetadataBagView
title="Catalog metadata"
title="اطلاعات تکمیلی محصولات"
attributes={meta?.data?.attributes}
message={meta?.message}
availability={meta?.availability}

View File

@ -18,6 +18,14 @@ import type {
} from "../adapters/billing-ledger";
import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } from "../types";
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
import { CommercialSkeleton } from "../components/CommercialSkeleton";
import {
enumLabel,
formatDate,
formatMoney,
friendlyMessage,
humanizeCommercialCode,
} from "../presentation";
type CommercialSubscription = {
id?: string;
@ -53,7 +61,7 @@ function BillingInner() {
subscription?: CommercialSubscription | null;
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`);
if (dash.ok) setSub(dash.data.subscription ?? null);
else setError(dash.message || "بارگذاری اشتراک commercial ناموفق بود");
else setError(friendlyMessage(dash.message, "اطلاعات اشتراک بارگذاری نشد. لطفاً دوباره تلاش کنید."));
void Promise.all([
loadCommercialUsage(tenantId).then(setUsage),
loadCommercialInvoices(tenantId).then(setInvoices),
@ -86,7 +94,7 @@ function BillingInner() {
});
if (!commercial.ok) {
setError(commercial.message || "فعال‌سازی نیازمند Commercial Runtime subscriptions API است.");
setError(friendlyMessage(commercial.message, "فعال‌سازی پلن انجام نشد. لطفاً دوباره تلاش کنید."));
return;
}
@ -98,9 +106,9 @@ function BillingInner() {
trial_ends_at: commercial.data.trial_ends_at ?? null,
};
setSub(next);
setMsg(`عملیات ${mode} از Commercial Runtime ثبت شد.`);
setMsg(mode === "trial" ? "دوره آزمایشی شما با موفقیت فعال شد." : "پلن شما با موفقیت به‌روزرسانی شد.");
} catch (err) {
setError(err instanceof Error ? err.message : "عملیات ناموفق بود");
setError(friendlyMessage(err instanceof Error ? err.message : null, "انجام این عملیات ممکن نشد. لطفاً دوباره تلاش کنید."));
} finally {
setBusy(false);
}
@ -111,18 +119,14 @@ function BillingInner() {
setBusy(true);
setError("");
setMsg("");
// Coupons SoT is Commercial Runtime registry — apply via published coupon codes
// attached to pricing activation (coupon_code on subscriptions POST).
setMsg(
`کد «${coupon.trim()}» را هنگام Trial/فعال‌سازی ارسال کنید. مدیریت کوپن فقط در Admin Commercial.`
);
setMsg(`کد تخفیف «${coupon.trim()}» ذخیره شد و هنگام انتخاب پلن اعمال می‌شود.`);
setBusy(false);
};
if (meLoading) {
return (
<PlatformShell title="Billing">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
<PlatformShell title="اشتراک و پرداخت">
<CommercialSkeleton rows={4} />
</PlatformShell>
);
}
@ -131,8 +135,8 @@ function BillingInner() {
return (
<PlatformShell
title="Billing و Subscription"
subtitle="قیمت، فاکتور، تراکنش و ارتقا فقط از Commercial Runtime"
title="اشتراک و پرداخت"
subtitle="پلن فعلی، دوره آزمایشی، پرداخت‌ها و میزان استفاده را یکجا مدیریت کنید."
>
<div className="mb-4">
<Link href="/dashboard">
@ -142,28 +146,24 @@ function BillingInner() {
</Link>
</div>
<SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} />
{sub ? (
<div className="mt-3">
<PlanBadge label={planLabel || "—"} status={sub.status ?? undefined} />
{sub.trial_ends_at ? (
<p className="mt-2 text-xs text-gray-500" dir="ltr">
trial_ends_at: {sub.trial_ends_at}
</p>
) : null}
</div>
) : null}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پلن فعلی</p><div className="mt-3"><PlanBadge label={planLabel || "بدون پلن"} status={sub?.status} /></div></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">وضعیت اشتراک</p><p className="mt-3 font-bold text-secondary">{enumLabel(sub?.status)}</p></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پایان دوره آزمایشی</p><p className="mt-3 font-bold text-secondary">{formatDate(sub?.trial_ends_at)}</p></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پرداخت بعدی</p><p className="mt-3 font-bold text-secondary">{formatDate(sub?.current_period_end)}</p></div>
</section>
<div className="mt-4"><SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} /></div>
{error ? <p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p> : null}
{msg ? <p className="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{msg}</p> : null}
<section className="mt-8">
<SectionTitle title=وپن / تخفیف" />
<SectionTitle title=د تخفیف" />
<div className="flex flex-wrap gap-2">
<input
className="rounded-xl border border-gray-200 px-3 py-2 text-sm"
dir="ltr"
placeholder="COUPON"
placeholder="کد تخفیف را وارد کنید"
value={coupon}
onChange={(e) => setCoupon(e.target.value)}
/>
@ -174,15 +174,15 @@ function BillingInner() {
</section>
<section className="mt-10">
<SectionTitle title="کاتالوگ قیمت (Commercial Runtime)" />
<SectionTitle title="ارتقا یا تغییر پلن" subtitle="پلن مناسب را انتخاب کنید؛ تغییرات پیش از تأیید نهایی به شما نمایش داده می‌شود." />
{!pricing || pricing.availability === "loading" ? (
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
<CommercialSkeleton rows={3} />
) : pricing.availability !== "ready" || !pricing.data.length ? (
<CommercialEmptyState
title="قیمتی در رجیستری نیست"
description="از Admin Commercial → Pricing یک آیتم منتشر کنید."
actionHref="/admin/commercial/pricing"
actionLabel="مدیریت Pricing"
title="پلنی برای خرید آماده نیست"
description="لطفاً کمی بعد دوباره بررسی کنید یا با پشتیبانی تماس بگیرید."
actionHref="/help"
actionLabel="تماس با پشتیبانی"
/>
) : (
<ul className="mt-4 space-y-3">
@ -193,11 +193,8 @@ function BillingInner() {
>
<div>
<p className="font-semibold text-secondary">{item.display_name}</p>
<p className="text-xs text-gray-500" dir="ltr">
{item.pricing_item_code}
{item.amount != null ? ` · ${item.amount} ${item.currency || ""}` : ""}
{item.trial_days != null ? ` · trial ${item.trial_days}d` : ""}
</p>
<p className="mt-1 text-sm text-gray-600">{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}</p>
{item.trial_days ? <p className="mt-1 text-xs text-emerald-700">{item.trial_days.toLocaleString("fa-IR")} روز آزمایش رایگان</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
@ -206,7 +203,7 @@ function BillingInner() {
disabled={busy}
onClick={() => void activatePricing(item, "trial")}
>
Trial
آزمایش رایگان
</Button>
<Button size="sm" disabled={busy} onClick={() => void activatePricing(item, "active")}>
فعالسازی
@ -227,46 +224,46 @@ function BillingInner() {
</section>
<section className="mt-10">
<SectionTitle title="Usage" />
<SectionTitle title="میزان استفاده" />
{usage?.availability === "ready" && usage.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{usage.data.map((u) => (
<li key={u.key} className="rounded-xl bg-gray-50 px-3 py-2" dir="ltr">
{u.key}: {u.used ?? 0}/{u.limit ?? "∞"}
<li key={u.key} className="rounded-xl bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between"><span>{humanizeCommercialCode(u.key, u.label)}</span><span>{(u.used ?? 0).toLocaleString("fa-IR")} از {u.limit == null ? "نامحدود" : u.limit.toLocaleString("fa-IR")}</span></div>
{u.limit ? <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200"><div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(100, ((u.used || 0) / u.limit) * 100)}%` }} /></div> : null}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">Usage از Commercial Runtime خالی است.</p>
<CommercialEmptyState title="هنوز مصرفی ثبت نشده است" description="پس از شروع استفاده، میزان مصرف امکانات اینجا نمایش داده می‌شود." />
)}
</section>
<section className="mt-10">
<SectionTitle title="Invoices / Transactions" />
<p className="mb-3 text-xs text-gray-500">
لجر مالی متعلق به Payment است؛ Commercial فقط نمای خالی/پروکسی صادقانه برمیگرداند.
</p>
<SectionTitle title="فاکتورها و تاریخچه پرداخت" />
{invoices?.availability === "ready" && invoices.data.length ? (
<ul className="space-y-2 text-sm">
{invoices.data.map((inv) => (
<li key={inv.id} dir="ltr">
{inv.id} · {inv.status} · {inv.amount}
<li key={inv.invoice_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(inv.invoice_code, inv.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(inv.issued_at)}</p></div>
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(inv.amount_minor, inv.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(inv.status)}</p></div>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">فاکتوری نیست.</p>
<p className="text-sm text-gray-500">هنوز فاکتوری صادر نشده است.</p>
)}
{txns?.availability === "ready" && txns.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{txns.data.map((t) => (
<li key={t.id} dir="ltr">
{t.id} · {t.status} · {t.amount}
<li key={t.transaction_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(t.transaction_code, t.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(t.occurred_at)}</p></div>
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(t.amount_minor, t.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(t.status)}</p></div>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-gray-500">تراکنشی نیست.</p>
<p className="mt-2 text-sm text-gray-500">هنوز پرداختی انجام نشده است.</p>
)}
</section>
</PlatformShell>

View File

@ -56,6 +56,11 @@ import {
UpgradePrompt,
} from "../components";
import { loadAssessmentSession } from "../session";
import {
enumLabel,
friendlyMessage,
humanizeCommercialCode,
} from "../presentation";
function CommercialDashboardContent() {
const router = useRouter();
@ -140,12 +145,11 @@ function CommercialDashboardContent() {
void load();
}, [me, meLoading, router, load]);
const productList = products?.data ?? [];
const byCode = useMemo(() => {
const m = new Map<string, CommercialProduct>();
for (const p of productList) m.set(p.product_code, p);
for (const p of products?.data ?? []) m.set(p.product_code, p);
return m;
}, [productList]);
}, [products]);
const pickProducts = (codes?: string[]) =>
(codes || [])
@ -188,7 +192,7 @@ function CommercialDashboardContent() {
<div className="mb-4 flex flex-wrap gap-2">
<Link href="/billing">
<Button variant="outline" size="sm">
Billing / ارتقا
اشتراک و ارتقا
</Button>
</Link>
<Link href="/domains">
@ -198,7 +202,7 @@ function CommercialDashboardContent() {
</Link>
<Link href="/apps">
<Button variant="outline" size="sm">
Apps
محصولات
</Button>
</Link>
<Button
@ -215,15 +219,15 @@ function CommercialDashboardContent() {
{(meError || snap?.message) && snap?.availability === "error" ? (
<p className="mb-6 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600">
{meError || snap?.message}
{friendlyMessage(meError || snap?.message, "بارگذاری داشبورد انجام نشد. دوباره تلاش کنید.")}
</p>
) : null}
{!ws ? (
<div className="mt-4">
<CommercialEmptyState
title="Workspace در دسترس نیست"
description={snap?.message}
title="فضای کاری شما هنوز آماده نیست"
description="راه‌اندازی اولیه را کامل کنید تا داشبورد شما فعال شود."
actionHref="/onboarding"
actionLabel="ادامه راه‌اندازی"
/>
@ -238,11 +242,11 @@ function CommercialDashboardContent() {
{ws.primary_domain || `${ws.slug}.torbatyar.ir`}
</p>
{ws.workspace_health ? (
<p className="mt-1 text-xs text-gray-500">سلامت workspace: {ws.workspace_health}</p>
<p className="mt-1 text-xs text-gray-500">وضعیت فضای کاری: {enumLabel(ws.workspace_health)}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={ws.status === "active" ? "primary" : "default"}>{ws.status}</Badge>
<Badge variant={ws.status === "active" ? "primary" : "default"}>{enumLabel(ws.status)}</Badge>
<PlanBadge label={ws.plan_name} status={ws.subscription_status} />
</div>
</div>
@ -261,7 +265,7 @@ function CommercialDashboardContent() {
<section>
<SectionTitle
title="فعال‌سازی"
subtitle={`وضعیت: ${activation.data.status || "—"} · ${
subtitle={`وضعیت: ${enumLabel(activation.data.status)} · ${
activation.data.completion_percent ?? "—"
}%`}
/>
@ -272,8 +276,8 @@ function CommercialDashboardContent() {
className="flex items-center justify-between rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm"
>
<span>
{step.label || step.step_key}{" "}
<span className="text-xs text-gray-400">({step.status || "pending"})</span>
{humanizeCommercialCode(step.step_key, step.label)}{" "}
<span className="text-xs text-gray-400">({enumLabel(step.status || "pending")})</span>
</span>
{step.href ? (
<Link href={step.href} className="text-xs text-primary">
@ -308,7 +312,7 @@ function CommercialDashboardContent() {
) : null}
<section>
<SectionTitle title="پیشنهادها" subtitle="از recommendation API" />
<SectionTitle title="پیشنهادهای مناسب شما" subtitle="بر اساس نیازها و نحوه فعالیت کسب‌وکار شما" />
<RecommendationsPanel result={recs} />
</section>
@ -321,9 +325,7 @@ function CommercialDashboardContent() {
key={f.feature_key}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm"
>
<span className="font-mono text-xs" dir="ltr">
{f.feature_key}
</span>
<span className="text-xs font-medium">{humanizeCommercialCode(f.feature_key)}</span>
<button
type="button"
className="text-xs font-semibold text-primary"
@ -363,7 +365,7 @@ function CommercialDashboardContent() {
) : null}
<section>
<SectionTitle title="Apps launcher" />
<SectionTitle title="محصولات شما" />
{products?.availability === "ready" && products.data.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{products.data.map((p) => (
@ -373,10 +375,10 @@ function CommercialDashboardContent() {
) : (
<CommercialEmptyState
title="محصولی کشف نشد"
description={products?.message}
hint="کاتالوگ از رجیستری تجاری بارگذاری می‌شود. تا پر شدن، از کشف و راهنما ادامه دهید."
description="محصولات انتخاب‌شده پس از راه‌اندازی اینجا نمایش داده می‌شوند."
hint="برای دریافت پیشنهاد مناسب، ارزیابی کسب‌وکار را کامل کنید."
actionHref="/apps"
actionLabel="Apps Launcher"
actionLabel="مشاهده محصولات"
secondaryHref="/discover"
secondaryLabel="کشف کسب‌وکار"
/>
@ -398,8 +400,8 @@ function CommercialDashboardContent() {
) : (
<CommercialEmptyState
title="قابلیتی نیست"
description={caps?.message}
hint="قابلیت‌ها با entitlement و باندل فعال ظاهر می‌شوند."
description="امکانات فعال پلن شما پس از انتخاب اشتراک اینجا نمایش داده می‌شود."
hint="برای افزودن قابلیت‌های بیشتر، پلن‌ها را مقایسه کنید."
actionHref="/billing"
actionLabel="بررسی پلن"
secondaryHref="/help"
@ -414,16 +416,16 @@ function CommercialDashboardContent() {
<ul className="mb-3 space-y-1">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{inv.display_name || inv.invoice_code} · {inv.status || "—"}
{humanizeCommercialCode(inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="فاکتور نیست"
description={invoices?.message}
description="هنوز فاکتوری برای این فضای کاری صادر نشده است."
actionHref="/billing"
actionLabel="Billing"
actionLabel="اشتراک و پرداخت"
secondaryHref="/help"
secondaryLabel="راهنمای اشتراک"
/>
@ -432,18 +434,18 @@ function CommercialDashboardContent() {
<ul className="mt-3 space-y-1">
{licenses.data.map((lic) => (
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{lic.display_name || lic.license_code} · {lic.status || "—"}
{humanizeCommercialCode(lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="لایسنس نیست"
description={licenses?.message}
description="پس از فعال‌سازی پلن، مجوزهای شما اینجا نمایش داده می‌شود."
actionHref="/billing"
actionLabel="فعال‌سازی پلن"
secondaryHref="/discover"
secondaryLabel="انتخاب باندل"
secondaryLabel="انتخاب بسته"
/>
)}
</section>
@ -467,8 +469,8 @@ function CommercialDashboardContent() {
) : (
<CommercialEmptyState
title="اعلانی نیست"
description={notifications?.message}
hint="اعلان‌های Trial، دامنه و Billing اینجا ظاهر می‌شوند."
description="همه چیز مرتب است؛ اعلان جدیدی ندارید."
hint="یادآوری‌های دوره آزمایشی، دامنه و پرداخت اینجا ظاهر می‌شوند."
actionHref="/notifications"
actionLabel="مرکز اعلان‌ها"
secondaryHref="/help"
@ -488,16 +490,16 @@ function CommercialDashboardContent() {
<ul className="grid gap-2 sm:grid-cols-2">
{ws.trial.quotas.map((q) => (
<li key={q.key} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
{q.label || q.key}: {q.used ?? "—"} / {q.limit ?? "∞"}
{humanizeCommercialCode(q.key, q.label)}: {(q.used ?? 0).toLocaleString("fa-IR")} از {q.limit == null ? "نامحدود" : q.limit.toLocaleString("fa-IR")}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="شمارنده مصرف در دسترس نیست"
hint="سهمیه‌های Trial و usage از API entitlement می‌آیند."
hint="پس از شروع استفاده، میزان مصرف و سقف پلن اینجا نمایش داده می‌شود."
actionHref="/billing"
actionLabel="Billing / Usage"
actionLabel="مشاهده اشتراک"
secondaryHref="/help"
secondaryLabel="راهنما"
/>

View File

@ -31,7 +31,13 @@ import {
saveAssessmentSession,
type AssessmentSession,
} from "../session";
import {
enumLabel,
formatMoney,
friendlyMessage,
humanizeCommercialCode,
recommendationReason,
} from "../presentation";
type WizardStep =
| "assessment"
| "recommendations"
@ -40,6 +46,20 @@ type WizardStep =
| "trial_summary"
| "create_workspace";
function businessQuestion(question: AssessmentQuestion): string {
const key = question.question_id.toLowerCase();
if (/business.*type|industry/.test(key)) return "نوع کسب‌وکار شما چیست؟";
if (/employee|staff|personnel/.test(key)) return "چند پرسنل دارید؟";
if (/branch/.test(key)) return "چند شعبه دارید؟";
if (/in.?person|physical|pos/.test(key)) return "آیا فروش حضوری دارید؟";
if (/online|ecommerce/.test(key)) return "فروش آنلاین دارید؟";
if (/booking|reservation/.test(key)) return "به مدیریت رزرو نیاز دارید؟";
if (/delivery|shipping/.test(key)) return "ارسال کالا دارید؟";
if (/loyal|club/.test(key)) return "باشگاه مشتریان نیاز دارید؟";
if (/website|domain/.test(key)) return "وب‌سایت دارید؟";
return friendlyMessage(question.prompt, "این ویژگی برای کسب‌وکار شما لازم است؟");
}
function QuestionField({
question,
value,
@ -60,7 +80,7 @@ function QuestionField({
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
/>
{question.prompt}
{businessQuestion(question)}
</label>
);
}
@ -68,7 +88,7 @@ function QuestionField({
if (question.input_type === "number") {
return (
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
{question.prompt}
{businessQuestion(question)}
<input
type="number"
className={inputClass}
@ -85,7 +105,7 @@ function QuestionField({
return (
<fieldset>
<legend className="text-sm font-medium text-secondary dark:text-gray-100">
{question.prompt}
{businessQuestion(question)}
</legend>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{question.options.map((opt) => {
@ -107,7 +127,7 @@ function QuestionField({
} else onChange(opt.value);
}}
>
{opt.label}
{humanizeCommercialCode(opt.value, opt.label)}
</button>
);
})}
@ -118,7 +138,7 @@ function QuestionField({
return (
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
{question.prompt}
{businessQuestion(question)}
<input
className={inputClass}
value={typeof value === "string" ? value : ""}
@ -131,9 +151,9 @@ function QuestionField({
const STEPS: { id: WizardStep; label: string }[] = [
{ id: "assessment", label: "کشف" },
{ id: "recommendations", label: "پیشنهاد" },
{ id: "bundles", label: اندل" },
{ id: "bundles", label: سته مناسب" },
{ id: "pricing", label: "قیمت" },
{ id: "trial_summary", label: "Trial" },
{ id: "trial_summary", label: "آزمایش رایگان" },
];
export function CommercialDiscoverExperience() {
@ -216,9 +236,9 @@ export function CommercialDiscoverExperience() {
.map((r) => ({ ...r, product: productByCode.get(r.code) }))
.filter((r) => r.product);
const optionalProducts = (productsRes?.data ?? []).filter(
const optionalProducts = recommendedProducts.length ? (productsRes?.data ?? []).filter(
(p) => !recommendedProducts.some((r) => r.code === p.product_code) && p.visibility !== "hidden"
);
).slice(0, 3) : [];
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
@ -271,7 +291,7 @@ export function CommercialDiscoverExperience() {
بازگشت به خانه
</Link>
<h1 className="mt-2 text-2xl font-bold text-secondary dark:text-gray-100">
کشف و راهاندازی
پیشنهاد مناسب کسبوکار
</h1>
</div>
<ol className="flex flex-wrap gap-2">
@ -297,7 +317,7 @@ export function CommercialDiscoverExperience() {
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ارزیابی نیازها</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
سؤالات از assessment schema صنایع و نیازها بدون لیست ثابت frontend.
با چند سؤال کوتاه، نیازهای واقعی کسبوکار شما را میشناسیم.
</p>
{schemaRes?.availability === "unavailable" ||
@ -305,8 +325,8 @@ export function CommercialDiscoverExperience() {
!questions.length ? (
<div className="mt-6 space-y-4">
<CommercialEmptyState
title="اسکیمای ارزیابی در دسترس نیست"
description={schemaRes?.message}
title="فعلاً امکان ارزیابی خودکار نیست"
description="کمی بعد دوباره تلاش کنید یا پلن‌ها را مستقیماً ببینید."
/>
<div className="flex flex-wrap gap-2">
<Button
@ -326,7 +346,7 @@ export function CommercialDiscoverExperience() {
setStep("recommendations");
}}
>
ادامه با کاتالوگ رجیستری
مشاهده پلنها
</Button>
</div>
</div>
@ -385,30 +405,13 @@ export function CommercialDiscoverExperience() {
پیشنهاد شخصیسازیشده
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
محصولات الزامی، اختیاری و مسیر رشد از recommendation + registry.
فقط گزینههایی را میبینید که با پاسخهای شما همخوانی دارند.
</p>
{recoRes?.message ? (
<p className="mt-3 rounded-xl bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
{recoRes.message}
</p>
) : null}
{recoRes?.data.rule_trace?.length ? (
<details className="mt-4 rounded-xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<summary className="cursor-pointer text-sm font-semibold text-secondary dark:text-gray-100">
چرا این پیشنهاد؟
</summary>
<ol className="mt-3 list-decimal space-y-1 ps-5 text-xs text-gray-600 dark:text-gray-400">
{recoRes.data.rule_trace.map((step, i) => (
<li key={`${i}-${step}`}>{step}</li>
))}
</ol>
</details>
) : null}
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
محصولات پیشنهادی / الزامی
ضروری برای شروع
</h3>
{recommendedProducts.length ? (
<div className="grid gap-3 sm:grid-cols-2">
@ -419,16 +422,16 @@ export function CommercialDiscoverExperience() {
recommendationScore={r.score}
/>
{r.reason ? (
<p className="px-1 text-xs text-gray-500">{r.reason}</p>
<p className="px-1 text-xs text-gray-500">{recommendationReason(r.reason)}</p>
) : null}
</div>
))}
</div>
) : (
<CommercialEmptyState
title="پیشنهاد محصول خالی است"
description={productsRes?.message || recoRes?.message}
hint="پاسخ‌های ارزیابی را بررسی کنید یا بعداً وقتی رجیستری پر شد دوباره امتحان کنید."
title="هنوز پیشنهاد دقیقی آماده نیست"
description="پاسخ‌ها را دوباره بررسی کنید تا پیشنهاد دقیق‌تری دریافت کنید."
hint="هیچ محصولی بدون تطبیق با نیاز شما پیشنهاد نمی‌شود."
actionHref="/discover"
actionLabel="تکرار ارزیابی"
secondaryHref="/pricing"
@ -440,7 +443,7 @@ export function CommercialDiscoverExperience() {
{optionalProducts.length ? (
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
محصولات اختیاری / قابل گسترش
بعداً اضافه کنید
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{optionalProducts.slice(0, 6).map((p) => (
@ -456,7 +459,7 @@ export function CommercialDiscoverExperience() {
<p className="mt-1">{selectedBundleObj.display_name}</p>
{selectedBundleObj.upgrade_path_code ? (
<p className="mt-1 text-xs text-primary">
مسیر رشد: {selectedBundleObj.upgrade_path_code}
امکان ارتقا در هر زمان
</p>
) : null}
</div>
@ -466,7 +469,7 @@ export function CommercialDiscoverExperience() {
<Button variant="outline" onClick={() => setStep("assessment")}>
بازگشت
</Button>
<Button onClick={() => setStep("bundles")}>مقایسه باندل</Button>
<Button onClick={() => setStep("bundles")}>مقایسه بستهها</Button>
</div>
</div>
</Container>
@ -475,9 +478,9 @@ export function CommercialDiscoverExperience() {
{step === "bundles" ? (
<Container className="py-10">
<div className="mx-auto max-w-4xl space-y-6">
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">مقایسه باندل</h2>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">مقایسه بستههای پیشنهادی</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Starter / Professional / Enterprise از metadata باندل بدون پلن ثابت.
محصولات، امکانات و روش پرداخت هر بسته را در یک نگاه مقایسه کنید.
</p>
{displayBundles.length ? (
<div className="grid gap-4 lg:grid-cols-2">
@ -493,11 +496,13 @@ export function CommercialDiscoverExperience() {
setSelectedBundle(b.bundle_code);
persist({ answers, selected_bundle_code: b.bundle_code });
}}
pricingItems={pricingRes?.data || []}
products={productsRes?.data || []}
/>
))}
</div>
) : (
<CommercialEmptyState title=اندلی نیست" description={bundlesRes?.message} />
<CommercialEmptyState title=سته‌ای برای نمایش نیست" description="پس از تکمیل پاسخ‌ها، بسته مناسب شما نمایش داده می‌شود." />
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("recommendations")}>
@ -514,7 +519,7 @@ export function CommercialDiscoverExperience() {
<div className="mx-auto max-w-3xl space-y-6">
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">مقایسه قیمت</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
آیتمهای قیمت از رجیستری؛ تخفیف و پروموشن از discount_refs / metadata.
هزینه ماهانه، سالانه و دوره آزمایشی را شفاف مقایسه کنید.
</p>
{(pricingForBundle.length ? pricingForBundle : pricingRes?.data ?? []).length ? (
<ul className="grid gap-3 sm:grid-cols-2">
@ -527,27 +532,24 @@ export function CommercialDiscoverExperience() {
{item.display_name}
</p>
<p className="mt-1 text-xs text-gray-500">
{item.pricing_model || "—"} ·{" "}
{item.amount_minor != null
? `${item.amount_minor} ${item.currency || ""}`
: "مبلغ از بک‌اند"}
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
</p>
{item.trial_days != null ? (
<Badge variant="default" className="mt-2">
Trial {item.trial_days} روز
{item.trial_days} روز آزمایش رایگان
</Badge>
) : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState title="قیمت‌گذاری خالی است" description={pricingRes?.message} />
<CommercialEmptyState title="قیمت این بسته هنوز اعلام نشده" description="برای دریافت قیمت با فروش تماس بگیرید." />
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("bundles")}>
بازگشت
</Button>
<Button onClick={() => setStep("trial_summary")}>خلاصه Trial</Button>
<Button onClick={() => setStep("trial_summary")}>خلاصه دوره آزمایشی</Button>
</div>
</div>
</Container>
@ -556,25 +558,24 @@ export function CommercialDiscoverExperience() {
{step === "trial_summary" ? (
<Container className="py-10">
<div className="mx-auto max-w-xl space-y-4 rounded-3xl border border-gray-100 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">خلاصه Trial</h2>
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">خلاصه دوره آزمایشی</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
پس از ثبتنام و ساخت workspace، trial از Billing / commercial subscriptions فعال
میشود بدون اعداد ساختگی در این صفحه.
پس از ثبتنام، فضای کاری و زیردامنه رایگان شما ساخته میشود و میتوانید امکانات انتخابی را امتحان کنید.
</p>
{selectedBundle ? (
<p className="text-sm">
باندل: <span className="font-semibold">{selectedBundle}</span>
بسته: <span className="font-semibold">{selectedBundleObj?.display_name || "بسته پیشنهادی"}</span>
</p>
) : (
<p className="text-xs text-gray-500">باندل انتخاب نشده میتوانید بعداً در Billing انتخاب کنید.</p>
<p className="text-xs text-gray-500">بستهای انتخاب نشده است؛ میتوانید بعداً از بخش اشتراک انتخاب کنید.</p>
)}
<ul className="space-y-1 text-xs text-gray-500">
<li> ساخت tenant و زیردامنه</li>
<li> داشبورد تجاری و لانچر اپها</li>
<li> دامنه اختصاصی پس از entitlement</li>
<li> ساخت فضای کاری و زیردامنه</li>
<li> داشبورد و دسترسی به محصولات</li>
<li> اتصال دامنه اختصاصی پس از فعالسازی اشتراک</li>
</ul>
<Button className="w-full" size="lg" onClick={goWorkspace}>
ساخت Workspace
ساخت فضای کاری
</Button>
</div>
</Container>
@ -585,7 +586,7 @@ export function CommercialDiscoverExperience() {
<div className="mx-auto max-w-md space-y-4 rounded-3xl border border-gray-100 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ثبتنام / ورود</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
انتخابهای ارزیابی در session میماند و پس از ورود به onboarding منتقل میشود.
انتخابهای شما ذخیره میشود و پس از ورود، راهاندازی را از همینجا ادامه میدهید.
</p>
<Button
className="w-full"

View File

@ -10,6 +10,8 @@ import { evaluateDomainPolicy, loadWorkspaceSnapshot } from "../adapters";
import type { AdapterResult, CommercialWorkspaceSnapshot, DomainPolicyResult } from "../types";
import { DomainPolicyPanel, FeatureLock, PlatformShell } from "../components";
import { CommercialEmptyState } from "../components/CommercialEmptyState";
import { friendlyMessage } from "../presentation";
import { CommercialSkeleton } from "../components/CommercialSkeleton";
function DomainsInner() {
const { me, loading: meLoading } = useMe();
@ -45,17 +47,17 @@ function DomainsInner() {
setMsg("");
try {
if (!unlocked) {
setError(policy?.reason || "دامنه اختصاصی بر اساس سیاست قفل است.");
setError(friendlyMessage(policy?.reason, "برای اتصال دامنه اختصاصی، ابتدا اشتراک خود را فعال کنید."));
setBusy(false);
return;
}
await api.onboarding.updateDomain(snap.data.tenant_id, {
custom_domain: customDomain.trim(),
});
setMsg("دامنه ثبت شد — تأیید DNS را از وضعیت زیرگیری کنید.");
setMsg("دامنه ثبت شد. مراحل تأیید DNS را در همین صفحه دنبال کنید.");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت دامنه ناموفق بود");
setError(friendlyMessage(err instanceof ApiError ? err.message : null, "ثبت دامنه انجام نشد. آدرس را بررسی و دوباره تلاش کنید."));
} finally {
setBusy(false);
}
@ -64,7 +66,7 @@ function DomainsInner() {
if (meLoading) {
return (
<Container className="py-16 text-center">
<p className="text-sm text-gray-500">در حال بارگذاری</p>
<CommercialSkeleton rows={4} className="mx-auto max-w-3xl" />
</Container>
);
}
@ -72,13 +74,13 @@ function DomainsInner() {
const ws = snap?.data;
return (
<PlatformShell title="دامنه و SSL" subtitle="قبل از اشتراک فقط زیردامنه؛ بعد از entitlement دامنه اختصاصی">
<PlatformShell title="دامنه و امنیت" subtitle="در دوره آزمایشی زیردامنه رایگان دارید؛ دامنه اختصاصی با اشتراک فعال قابل اتصال است.">
{!ws ? (
<CommercialEmptyState title="Workspace نیست" description={snap?.message} actionHref="/onboarding" actionLabel="راه‌اندازی" />
<CommercialEmptyState title="فضای کاری شما هنوز ساخته نشده" description="ابتدا راه‌اندازی اولیه را کامل کنید تا زیردامنه رایگان شما فعال شود." actionHref="/onboarding" actionLabel="شروع راه‌اندازی" />
) : (
<div className="space-y-6">
<div className="rounded-2xl border border-gray-100 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<p className="text-sm text-gray-500">زیردامنه پلتفرم</p>
<div className="flex items-center justify-between gap-3"><p className="text-sm text-gray-500">زیردامنه رایگان</p><span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs text-emerald-700">فعال</span></div>
<p className="mt-1 font-mono text-lg text-secondary dark:text-gray-100" dir="ltr">
{ws.slug}.torbatyar.ir
</p>
@ -101,9 +103,9 @@ function DomainsInner() {
featureKey="custom_domain"
fallback={
<div className="rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-5 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900">
اتصال دامنه اختصاصی قفل است.{" "}
برای اتصال دامنه اختصاصی، یک اشتراک فعال انتخاب کنید.{" "}
<Link href="/billing" className="font-semibold text-primary">
ارتقا در Billing
مشاهده پلنها
</Link>
</div>
}
@ -126,7 +128,7 @@ function DomainsInner() {
</FeatureLock>
<section>
<h3 className="text-sm font-semibold text-secondary dark:text-gray-100">وضعیت دامنهها</h3>
<h3 className="text-sm font-semibold text-secondary dark:text-gray-100">وضعیت اتصال دامنهها</h3>
{ws.domains.length ? (
<ul className="mt-3 space-y-2">
{ws.domains.map((d) => (
@ -137,19 +139,21 @@ function DomainsInner() {
<span className="font-mono" dir="ltr">
{d.domain}
</span>
<span className="text-xs text-gray-500">
{d.domain_type} · {d.is_verified ? "verified" : "pending"}
</span>
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-gray-600">{d.domain_type === "custom" ? "دامنه اختصاصی" : "زیردامنه"}</span>
<span className={`rounded-full px-2.5 py-1 ${d.is_verified ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`}>{d.is_verified ? "DNS تأیید شده" : "در انتظار تأیید DNS"}</span>
<span className={`rounded-full px-2.5 py-1 ${d.is_verified ? "bg-sky-50 text-sky-700" : "bg-gray-100 text-gray-600"}`}>{d.is_verified ? "SSL فعال" : "SSL پس از تأیید"}</span>
</div>
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="دامنه‌ای ثبت نشده"
description="زیردامنه workspace به‌صورت پیش‌فرض فعال است. دامنه اختصاصی پس از entitlement اضافه می‌شود."
description="زیردامنه فضای کاری به‌صورت خودکار فعال است. با اشتراک فعال می‌توانید دامنه اختصاصی اضافه کنید."
hint="وضعیت تأیید DNS و SSL پس از ثبت دامنه اینجا نمایش داده می‌شود."
actionHref="/billing"
actionLabel="بررسی entitlement"
actionLabel="مشاهده پلن‌ها"
secondaryHref="/help"
secondaryLabel="راهنمای دامنه"
/>

View File

@ -12,32 +12,33 @@ import {
} from "../adapters";
import { commercialGet, extractItems } from "../adapters/http";
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
import { enumLabel, formatMoney } from "../presentation";
const JOURNEY = [
"کشف نیاز",
"پیشنهاد هوشمند",
اندل و قیمت",
"Trial رایگان",
"Workspace",
"تولید",
سته و قیمت",
"آزمایش رایگان",
"فضای کاری",
"شروع کار",
];
const FAQ = [
{
q: "آیا می‌توانم بدون کارت بانکی شروع کنم؟",
a: "بله. پس از ساخت workspace می‌توانید دوره آزمایشی را از Billing فعال کنید — جزئیات trial از رجیستری قیمت‌گذاری می‌آید.",
a: "بله. پس از ساخت فضای کاری می‌توانید دوره آزمایشی را بدون کارت بانکی فعال کنید.",
},
{
q: "محصولات جدید چطور اضافه می‌شوند؟",
a: ا ثبت در رجیستری تجاری Admin. بدون تغییر frontend در کاتالوگ و لانچر ظاهر می‌شوند.",
q: "بعداً می‌توانم محصول جدید اضافه کنم؟",
a: له. هر زمان نیازتان بیشتر شد، محصولات و امکانات تازه را از داشبورد اضافه کنید.",
},
{
q: "دامنه اختصاصی کی باز می‌شود؟",
a: "بر اساس سیاست دامنه پس از entitlement / اشتراک فعال — تا آن زمان فقط زیردامنه پلتفرم.",
a: "پس از فعال‌شدن اشتراک می‌توانید دامنه اختصاصی متصل کنید؛ در دوره آزمایشی زیردامنه رایگان دارید.",
},
{
q: "آیا باندل قیمت ثابت دارد؟",
a: "خیر. باندل فقط به pricing_refs ارجاع می‌دهد؛ مبالغ از کاتالوگ قیمت runtime خوانده می‌شود.",
q: "آیا قیمت‌ها شفاف هستند؟",
a: "بله. هزینه ماهانه و سالانه هر پلن پیش از انتخاب به‌صورت شفاف نمایش داده می‌شود.",
},
];
@ -98,12 +99,12 @@ export function CommercialMarketingLanding() {
سیستمعامل کسبوکار شما روی ابر
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-sky-100/85 sm:text-lg">
از کشف نیاز تا داشبورد تولید محصولات، باندل و قیمت از رجیستری زنده؛ بدون کاتالوگ ثابت.
از شناخت نیاز تا شروع کار؛ محصولات، بستهها و قیمتها همیشه شفاف و بهروز هستند.
</p>
<div className="mt-9 flex flex-wrap gap-3">
<Link href="/discover">
<Button size="lg" className="bg-white text-secondary hover:bg-sky-50">
شروع Trial رایگان
شروع آزمایش رایگان
</Button>
</Link>
<Link href="/pricing">
@ -141,8 +142,7 @@ export function CommercialMarketingLanding() {
<Container>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">کشف کسبوکار</h2>
<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
نوع کسبوکار از رجیستری میآید؛ ارزیابی چندمرحلهای نیازها را به پیشنهاد محصول و باندل وصل
میکند.
چند سؤال ساده پاسخ دهید تا محصولات و بسته مناسب کسبوکار شما پیشنهاد شود.
</p>
<div className="mt-8 flex flex-wrap gap-2">
{businessTypes.length ? (
@ -169,7 +169,7 @@ export function CommercialMarketingLanding() {
<Container>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">اکوسیستم محصولات</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
کشف پویا از رجیستری افزودن محصول در Admin بدون تغییر این صفحه.
ابزارهای یکپارچه برای فروش، ارتباط با مشتری، مدیریت و رشد کسبوکار.
</p>
{products.length ? (
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -185,7 +185,7 @@ export function CommercialMarketingLanding() {
</ul>
) : (
<p className="mt-6 text-sm text-gray-500">
کاتالوگ محصول هنوز از API پر نشده داده جعلی نمایش داده نمیشود.
محصولات قابل ارائه بهزودی اینجا نمایش داده میشوند.
</p>
)}
</Container>
@ -197,23 +197,22 @@ export function CommercialMarketingLanding() {
<div>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">قابلیتهای پلتفرم</h2>
<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
ارزیابی metadata-driven، پیشنهاد runtime، قفل قابلیت، دامنه بر اساس سیاست، Billing و
لانچر اپها همه از Core Commercial.
پیشنهاد هوشمند، مدیریت اشتراک، دامنه اختصاصی، گزارش مصرف و دسترسی یکپارچه به محصولات.
</p>
</div>
<div>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">صنایع</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
صنایع نامحدود از business-types registry نه لیست ثابت frontend.
راهکارهای متناسب با مدل کاری و اندازه کسبوکار شما.
</p>
{!businessTypes.length ? (
<p className="mt-4 text-sm text-gray-500">انواع کسبوکار از API در دسترس نیست.</p>
<p className="mt-4 text-sm text-gray-500">فهرست صنایع در حال بهروزرسانی است.</p>
) : null}
</div>
<div>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">باندلها</h2>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">بستههای پیشنهادی</h2>
{bundles.length ? (
<ul className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{bundles.slice(0, 6).map((b) => (
@ -222,7 +221,7 @@ export function CommercialMarketingLanding() {
{b.display_name}
</p>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{b.description || "باندل تجاری از رجیستری"}
{b.description || "مجموعه‌ای کاربردی برای شروع سریع‌تر"}
</p>
{b.recommendation_badge ? (
<Badge variant="primary" className="mt-2">
@ -233,10 +232,10 @@ export function CommercialMarketingLanding() {
))}
</ul>
) : (
<p className="mt-4 text-sm text-gray-500">باندلی در رجیستری نیست.</p>
<p className="mt-4 text-sm text-gray-500">بستههای قابل ارائه در حال آمادهسازی هستند.</p>
)}
<Link href="/discover" className="mt-6 inline-block text-sm font-semibold text-primary">
مقایسه و انتخاب باندل
مقایسه و انتخاب بسته
</Link>
</div>
@ -250,16 +249,13 @@ export function CommercialMarketingLanding() {
{item.display_name}
</p>
<p className="mt-1 text-xs text-gray-500">
{item.pricing_model || "—"} ·{" "}
{item.amount_minor != null
? `${item.amount_minor} ${item.currency || ""}`
: "مبلغ از بک‌اند"}
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
</p>
</li>
))}
</ul>
) : (
<p className="mt-4 text-sm text-gray-500">آیتم قیمت از API نیست بدون مبلغ ساختگی.</p>
<p className="mt-4 text-sm text-gray-500">قیمت پلنها بهزودی اعلام میشود.</p>
)}
<Link href="/pricing" className="mt-6 inline-block text-sm font-semibold text-primary">
مشاهده کامل قیمتگذاری
@ -290,9 +286,9 @@ export function CommercialMarketingLanding() {
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">اعتماد سازمانی</h2>
<ul className="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li>چندمستأجری و SSO یکپارچه</li>
<li>سیاست دامنه و entitlement قابل ممیزی</li>
<li>رجیستری نسخهدار محصولات و قیمت</li>
<li>بدون کاتالوگ جعلی در frontend</li>
<li>دامنه اختصاصی و گواهی SSL</li>
<li>قیمتگذاری شفاف و قابل ارتقا</li>
<li>پشتیبانی از رشد چندشعبهای</li>
</ul>
</div>
<div>
@ -309,10 +305,10 @@ export function CommercialMarketingLanding() {
</Container>
<Container className="mt-14 text-center">
<h2 className="text-3xl font-extrabold text-secondary dark:text-gray-100">
آماده ساخت workspace هستید؟
آماده ساخت فضای کاری هستید؟
</h2>
<p className="mx-auto mt-3 max-w-lg text-sm text-gray-600 dark:text-gray-400">
ارزیابی را شروع کنید، باندل را انتخاب کنید، trial بگیرید و وارد داشبورد تجاری شوید.
ارزیابی را شروع کنید، بسته مناسب را انتخاب کنید و رایگان وارد داشبورد شوید.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link href="/discover">

View File

@ -1,529 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge, Button, Container } from "@/components/ui";
import { useAuth } from "@/hooks/useAuth";
import { useTheme } from "@/hooks/useTheme";
import {
loadAssessmentSchema,
loadCommercialBundles,
loadCommercialPricing,
loadCommercialProducts,
requestRecommendations,
} from "../adapters";
import type {
AdapterResult,
AssessmentQuestion,
AssessmentSchema,
CommercialBundle,
CommercialPricingItem,
CommercialProduct,
RecommendationResult,
} from "../types";
import {
BundleCard,
CommercialEmptyState,
ProductCard,
} from "../components";
import {
loadAssessmentSession,
saveAssessmentSession,
type AssessmentSession,
} from "../session";
type WizardStep =
| "landing"
| "assessment"
| "recommendations"
| "trial_summary"
| "create_workspace";
export type { AssessmentSession };
export { loadAssessmentSession, saveAssessmentSession };
function QuestionField({
question,
value,
onChange,
}: {
question: AssessmentQuestion;
value: unknown;
onChange: (v: unknown) => void;
}) {
const inputClass =
"mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary";
if (question.input_type === "boolean") {
return (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
/>
{question.prompt}
</label>
);
}
if (question.input_type === "number") {
return (
<label className="block text-sm font-medium text-secondary">
{question.prompt}
<input
type="number"
className={inputClass}
value={typeof value === "number" || typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
/>
</label>
);
}
if (question.input_type === "multi" && question.options?.length) {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<fieldset>
<legend className="text-sm font-medium text-secondary">{question.prompt}</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{question.options.map((opt) => {
const on = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
className={`rounded-xl border p-3 text-right text-sm ${
on ? "border-primary bg-[var(--color-primary-soft)]" : "border-gray-200 bg-white"
}`}
onClick={() => {
onChange(on ? selected.filter((v) => v !== opt.value) : [...selected, opt.value]);
}}
>
{opt.label}
</button>
);
})}
</div>
</fieldset>
);
}
if (question.input_type === "single" && question.options?.length) {
return (
<fieldset>
<legend className="text-sm font-medium text-secondary">{question.prompt}</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{question.options.map((opt) => (
<button
key={opt.value}
type="button"
className={`rounded-xl border p-3 text-right text-sm ${
value === opt.value
? "border-primary bg-[var(--color-primary-soft)]"
: "border-gray-200 bg-white"
}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</fieldset>
);
}
return (
<label className="block text-sm font-medium text-secondary">
{question.prompt}
<input
className={inputClass}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
export function CommercialLandingExperience() {
const { theme } = useTheme();
const { isAuthenticated, login, loading: authLoading } = useAuth();
const router = useRouter();
const siteName = theme?.site_name ?? "TorbatYar";
const [step, setStep] = useState<WizardStep>("landing");
const [schemaRes, setSchemaRes] = useState<AdapterResult<AssessmentSchema> | null>(null);
const [productsRes, setProductsRes] = useState<AdapterResult<CommercialProduct[]> | null>(null);
const [bundlesRes, setBundlesRes] = useState<AdapterResult<CommercialBundle[]> | null>(null);
const [pricingRes, setPricingRes] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
const [recoRes, setRecoRes] = useState<AdapterResult<RecommendationResult> | null>(null);
const [answers, setAnswers] = useState<Record<string, unknown>>({});
const [selectedBundle, setSelectedBundle] = useState<string | null>(null);
const [qIndex, setQIndex] = useState(0);
const [busy, setBusy] = useState(false);
useEffect(() => {
const session = loadAssessmentSession();
setAnswers(session.answers || {});
if (session.selected_bundle_code) setSelectedBundle(session.selected_bundle_code);
}, []);
const persist = useCallback((next: AssessmentSession) => {
saveAssessmentSession(next);
}, []);
const startAssessment = async () => {
setBusy(true);
const [schema, products, bundles, pricing] = await Promise.all([
loadAssessmentSchema(),
loadCommercialProducts(),
loadCommercialBundles(),
loadCommercialPricing(),
]);
setSchemaRes(schema);
setProductsRes(products);
setBundlesRes(bundles);
setPricingRes(pricing);
setQIndex(0);
setStep("assessment");
setBusy(false);
};
const questions = schemaRes?.data.questions ?? [];
const currentQ = questions[qIndex];
const runRecommendations = async () => {
setBusy(true);
persist({ answers, selected_bundle_code: selectedBundle });
const reco = await requestRecommendations(answers);
setRecoRes(reco);
setStep("recommendations");
setBusy(false);
};
const productByCode = useMemo(() => {
const map = new Map<string, CommercialProduct>();
(productsRes?.data ?? []).forEach((p) => map.set(p.product_code, p));
return map;
}, [productsRes]);
const bundleByCode = useMemo(() => {
const map = new Map<string, CommercialBundle>();
(bundlesRes?.data ?? []).forEach((b) => map.set(b.bundle_code, b));
return map;
}, [bundlesRes]);
const recommendedProducts = (recoRes?.data.recommended_products ?? [])
.map((r) => ({ ...r, product: productByCode.get(r.code) }))
.filter((r) => r.product);
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
.filter((r) => r.bundle);
const goWorkspace = () => {
persist({
answers,
selected_bundle_code: selectedBundle,
selected_product_codes: recoRes?.data.recommended_products.map((p) => p.code),
});
if (isAuthenticated) {
const intent = typeof answers.business_type_code === "string" ? answers.business_type_code : "";
router.push(intent ? `/onboarding?intent=${encodeURIComponent(intent)}` : "/onboarding");
} else {
setStep("create_workspace");
}
};
return (
<div className="pb-16">
{step === "landing" ? (
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-soft)] via-white to-[var(--color-secondary-muted)]" />
<Container className="relative py-16 sm:py-24">
<div className="mx-auto max-w-3xl text-center">
<Badge variant="primary" className="mb-5 px-4 py-1.5 text-sm">
Commercial SaaS Runtime
</Badge>
<h1 className="text-4xl font-extrabold tracking-tight text-secondary sm:text-5xl">
{siteName}
</h1>
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-gray-600 sm:text-lg">
نوع کسبوکار و نیازها را مشخص کنید؛ محصولات، باندل و trial از رجیستری پلتفرم پیشنهاد
میشود نه از لیست ثابت اپلیکیشنها.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button size="lg" loading={busy} loadingText="..." onClick={() => void startAssessment()}>
شروع ارزیابی کسبوکار
</Button>
{isAuthenticated ? (
<Link href="/dashboard">
<Button variant="outline" size="lg">
داشبورد تجاری
</Button>
</Link>
) : (
<Button variant="outline" size="lg" onClick={() => login()} loading={authLoading}>
ورود
</Button>
)}
</div>
<ol className="mx-auto mt-12 grid max-w-3xl gap-2 text-xs text-gray-500 sm:grid-cols-4">
{["ارزیابی", "پیشنهاد", "Trial", "Workspace"].map((s, i) => (
<li key={s} className="rounded-xl border border-gray-200 bg-white/80 px-3 py-2">
{i + 1}. {s}
</li>
))}
</ol>
</div>
</Container>
</section>
) : null}
{step === "assessment" ? (
<Container className="py-10">
<div className="mx-auto max-w-xl">
<h2 className="text-2xl font-bold text-secondary">ارزیابی نیازها</h2>
<p className="mt-2 text-sm text-gray-600">
سؤالات از metadata بکاند بارگذاری میشوند و برای صنایع آینده بدون تغییر frontend گسترش
مییابند.
</p>
{schemaRes?.availability === "unavailable" || schemaRes?.availability === "empty" ? (
<div className="mt-6 space-y-4">
<CommercialEmptyState
title="اسکیمای ارزیابی در دسترس نیست"
description={schemaRes.message}
/>
<p className="text-xs text-gray-500">
میتوانید بدون پیشنهاد موتور، ساخت workspace را ادامه دهید. پس از ثبت schema در Admin،
همین صفحه سؤالات را نشان میدهد.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("landing")}>
بازگشت
</Button>
<Button
onClick={() => {
setStep("recommendations");
setRecoRes({
availability: "unavailable",
data: {
recommended_products: [],
recommended_bundles: [],
recommended_capabilities: [],
recommended_pricing: [],
},
message: schemaRes.message,
source: "none",
});
}}
>
ادامه بدون پیشنهاد
</Button>
</div>
</div>
) : currentQ ? (
<div className="mt-6 space-y-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
<p className="text-xs text-gray-400">
سؤال {qIndex + 1} از {questions.length}
</p>
<QuestionField
question={currentQ}
value={answers[currentQ.question_id]}
onChange={(v) => {
const next = { ...answers, [currentQ.question_id]: v };
setAnswers(next);
persist({ answers: next, selected_bundle_code: selectedBundle });
}}
/>
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" disabled={qIndex === 0} onClick={() => setQIndex((i) => i - 1)}>
قبلی
</Button>
{qIndex < questions.length - 1 ? (
<Button
onClick={() => setQIndex((i) => i + 1)}
disabled={currentQ.required && answers[currentQ.question_id] == null}
>
بعدی
</Button>
) : (
<Button loading={busy} onClick={() => void runRecommendations()}>
دریافت پیشنهاد
</Button>
)}
</div>
</div>
) : null}
</div>
</Container>
) : null}
{step === "recommendations" ? (
<Container className="py-10">
<div className="mx-auto max-w-4xl space-y-8">
<div>
<h2 className="text-2xl font-bold text-secondary">پیشنهاد محصولات و باندل</h2>
<p className="mt-2 text-sm text-gray-600">
نتایج از رجیستری/موتور پیشنهاد؛ در صورت نبود سرویس، داده جعلی نمایش داده نمیشود.
</p>
{recoRes?.message ? (
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
{recoRes.message}
</p>
) : null}
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary">محصولات پیشنهادی</h3>
{recommendedProducts.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{recommendedProducts.map((r) => (
<ProductCard
key={r.code}
product={r.product!}
recommendationScore={r.score}
/>
))}
</div>
) : productsRes?.availability === "ready" && productsRes.data.length ? (
<div className="space-y-3">
<CommercialEmptyState
title="پیشنهاد موتور خالی است"
description="کاتالوگ محصولات کشف‌شده از رجیستری سرویس نمایش داده می‌شود."
/>
<div className="grid gap-3 sm:grid-cols-2">
{productsRes.data.slice(0, 8).map((p) => (
<ProductCard key={p.product_code} product={p} />
))}
</div>
</div>
) : (
<CommercialEmptyState
title="محصولی برای نمایش نیست"
description={productsRes?.message || recoRes?.message}
/>
)}
</section>
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary">باندل پیشنهادی</h3>
{recommendedBundles.length ? (
<div className="grid gap-3">
{recommendedBundles.map((r) => (
<BundleCard
key={r.code}
bundle={r.bundle!}
recommendationScore={r.score}
selected={selectedBundle === r.code}
onSelect={() => {
setSelectedBundle(r.code);
persist({ answers, selected_bundle_code: r.code });
}}
/>
))}
</div>
) : bundlesRes?.availability === "ready" && bundlesRes.data.length ? (
<div className="grid gap-3">
{bundlesRes.data.map((b) => (
<BundleCard
key={b.bundle_code}
bundle={b}
selected={selectedBundle === b.bundle_code}
onSelect={() => {
setSelectedBundle(b.bundle_code);
persist({ answers, selected_bundle_code: b.bundle_code });
}}
/>
))}
</div>
) : (
<CommercialEmptyState
title="باندلی در رجیستری نیست"
description={bundlesRes?.message}
/>
)}
</section>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("assessment")}>
بازگشت به ارزیابی
</Button>
<Button onClick={() => setStep("trial_summary")}>خلاصه Trial</Button>
</div>
</div>
</Container>
) : null}
{step === "trial_summary" ? (
<Container className="py-10">
<div className="mx-auto max-w-xl space-y-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
<h2 className="text-xl font-bold text-secondary">خلاصه دوره آزمایشی</h2>
<p className="text-sm text-gray-600">
جزئیات trial (روز، سهمیه، قوانین) از سیاستها و قیمتگذاری بکاند خوانده میشود. تا زمان
در دسترس بودن رجیستری، فقط وضعیت آمادگی نمایش داده میشود بدون اعداد ساختگی.
</p>
{pricingRes?.availability === "ready" && pricingRes.data.length ? (
<ul className="space-y-2 text-sm">
{pricingRes.data
.filter((p) => p.pricing_model === "trial" || p.trial_days)
.map((p) => (
<li key={p.pricing_item_code} className="rounded-xl bg-gray-50 px-3 py-2">
{p.display_name}
{p.trial_days ? `${p.trial_days} روز` : ""}
<span className="mr-2 font-mono text-[10px] text-gray-400" dir="ltr">
{p.pricing_item_code}
</span>
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="آیتم trial در کاتالوگ قیمت نیست"
description={pricingRes?.message}
/>
)}
{selectedBundle ? (
<p className="text-sm">
باندل انتخابی: <span className="font-semibold">{selectedBundle}</span>
</p>
) : (
<p className="text-xs text-gray-500">باندلی انتخاب نشده است.</p>
)}
<Button className="w-full" onClick={goWorkspace}>
ساخت Workspace
</Button>
</div>
</Container>
) : null}
{step === "create_workspace" ? (
<Container className="py-10">
<div className="mx-auto max-w-md space-y-4 rounded-2xl border border-gray-100 bg-white p-6 text-center shadow-sm">
<h2 className="text-xl font-bold text-secondary">ورود برای ساخت Workspace</h2>
<p className="text-sm text-gray-600">
برای ایجاد tenant و داشبورد تجاری وارد شوید. انتخابهای ارزیابی در session نگه داشته
میشود.
</p>
<Button
className="w-full"
loading={authLoading}
onClick={() => {
persist({ answers, selected_bundle_code: selectedBundle });
login();
}}
>
ورود / ثبتنام
</Button>
<Link href="/onboarding" className="block text-sm text-primary hover:underline">
اگر وارد شدهاید، ادامه onboarding
</Link>
</div>
</Container>
) : null}
</div>
);
}

View File

@ -76,13 +76,13 @@ function SetupWizardInner() {
},
{
id: "branding",
label: "بررسی برندینگ workspace",
label: "بررسی ظاهر فضای کاری",
href: "/dashboard/settings",
required: false,
},
{
id: "billing",
label: "تأیید trial / اشتراک",
label: "تأیید دوره آزمایشی یا اشتراک",
href: "/billing",
required: false,
},
@ -112,16 +112,16 @@ function SetupWizardInner() {
return (
<PlatformShell
title={product ? `راه‌اندازی ${product.display_name}` : "راه‌اندازی محصول"}
subtitle="ویزارد اولین اجرا — گام‌ها از checklist / metadata رجیستری"
subtitle="مراحل ساده برای آماده‌کردن محصول و شروع استفاده"
>
{loading ? <CommercialSkeleton rows={4} /> : null}
{!loading && !product ? (
<CommercialEmptyState
title="محصول در رجیستری نیست"
title="این محصول هنوز آماده راه‌اندازی نیست"
description={
productsAvail === "unavailable"
? "رجیستری محصول در دسترس نیست."
? "اطلاعات این محصول فعلاً در دسترس نیست."
: `کد ${productCode} پیدا نشد.`
}
actionHref="/apps"
@ -174,7 +174,7 @@ function SetupWizardInner() {
) : (
<CommercialEmptyState
title="گام راه‌اندازی تعریف نشده"
description="Admin می‌تواند setup_steps را در metadata محصول ثبت کند."
description="مراحل راه‌اندازی این محصول هنوز آماده نشده است."
actionHref={launch || "/apps"}
actionLabel="باز کردن محصول"
/>

View File

@ -0,0 +1,192 @@
const TOKEN_LABELS: Record<string, string> = {
restaurant: "رستوران",
hospitality: "رستوران و پذیرایی",
platform: "پلتفرم",
starter: "Starter",
growth: "رشد",
professional: "حرفه‌ای",
pro: "حرفه‌ای",
enterprise: "سازمانی",
loyalty: "باشگاه مشتریان",
delivery: "ارسال و پیک",
booking: "رزرو",
payment: "پرداخت",
accounting: "حسابداری",
website: "وب‌سایت",
crm: "مدیریت ارتباط با مشتری",
inventory: "انبارداری",
pos: "صندوق فروش",
monthly: "ماهانه",
yearly: "سالانه",
annual: "سالانه",
trial: "آزمایشی",
active: "فعال",
inactive: "غیرفعال",
draft: "پیش‌نویس",
published: "منتشرشده",
pending: "در انتظار",
verified: "تأییدشده",
failed: "ناموفق",
expired: "منقضی‌شده",
trialing: "دوره آزمایشی",
public: "عمومی",
private: "خصوصی",
hidden: "پنهان",
route: "داخل سامانه",
external: "پیوند بیرونی",
disabled: "غیرفعال",
deny: "غیرفعال",
allow: "فعال",
enterprise_quote: "استعلام قیمت",
irr: "ریال",
recommended: "پیشنهاد‌شده",
popular: "محبوب",
most_popular: "محبوب‌ترین",
};
export const FIELD_LABELS: Record<string, string> = {
display_name: "نام نمایشی",
title: "عنوان",
description: "توضیح کوتاه",
status: "وضعیت",
role: "نوع اشتراک",
pricing_model: "مدل پرداخت",
amount_minor: "قیمت",
currency: "واحد پول",
interval: "دوره پرداخت",
trial_days: "مدت دوره آزمایشی",
duration_days: "مدت دوره آزمایشی",
grace_period_days: "مهلت تمدید",
product_codes: "محصولات",
capability_codes: "قابلیت‌ها",
feature_refs: "امکانات",
pricing_refs: "روش‌های پرداخت",
discount_refs: "تخفیف‌ها",
coupon_code: "کد تخفیف",
tax_class_code: "مالیات",
trial_eligible: "دوره آزمایشی",
visibility: "نمایش",
display_order: "ترتیب نمایش",
category: "دسته‌بندی",
is_verified: "وضعیت تأیید",
domain_type: "نوع دامنه",
current_period_end: "تاریخ تمدید",
trial_ends_at: "پایان دوره آزمایشی",
created_at: "تاریخ ایجاد",
updated_at: "آخرین تغییر",
metadata: "اطلاعات تکمیلی",
attributes: "ویژگی‌ها",
parameters: "تنظیمات",
usage_limits: "سقف استفاده",
feature_limits: "محدودیت امکانات",
auto_disable: "غیرفعال‌سازی خودکار",
expiration_behavior: "رفتار پس از پایان",
ssl_ready: "وضعیت SSL",
dns_instructions: "راهنمای DNS",
custom_domain_allowed: "دامنه اختصاصی",
subdomain_only: "فقط زیردامنه",
reason: "دلیل پیشنهاد",
};
const TECHNICAL_FIELD = /(^id$|_id$|_code$|^code$|^source$|^version_code$|^schema_version$|^rule_trace$|^href$|_route$|launch_strategy)/i;
export function isTechnicalField(key: string): boolean {
return TECHNICAL_FIELD.test(key);
}
export function fieldLabel(key: string): string {
if (FIELD_LABELS[key]) return FIELD_LABELS[key];
const translated = key
.split(/[_\-.]+/)
.map((token) => TOKEN_LABELS[token.toLowerCase()] || "")
.filter(Boolean)
.join(" ");
return translated || "اطلاعات تکمیلی";
}
export function enumLabel(value: unknown): string {
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (value == null || value === "") return "ثبت نشده";
const raw = String(value).trim();
return TOKEN_LABELS[raw.toLowerCase()] || humanizeCommercialCode(raw);
}
export function humanizeCommercialCode(code: unknown, explicitName?: string | null): string {
if (explicitName?.trim() && !looksTechnical(explicitName)) return explicitName.trim();
if (code == null) return "مورد تجاری";
const raw = String(code).trim();
if (!raw) return "مورد تجاری";
if (TOKEN_LABELS[raw.toLowerCase()]) return TOKEN_LABELS[raw.toLowerCase()];
if (!looksTechnical(raw)) return raw;
const tokens = raw.toLowerCase().split(/[._:/-]+/).filter(Boolean);
const kind = tokens[0];
const translated = tokens
.slice(1)
.map((token) => TOKEN_LABELS[token])
.filter(Boolean);
const subject = translated.join(" ");
if (kind === "price" || kind === "plan") {
const planSubject = translated.filter((token) => token !== "ماهانه" && token !== "سالانه" && token !== "آزمایشی").join(" ");
return `پلن ${planSubject || "اشتراک"}`.trim();
}
if (kind === "bundle") return subject ? `بسته شروع ${subject.replace("Starter", "").trim()}`.trim() : "بسته پیشنهادی";
if (kind === "product") return subject ? `سیستم ${subject}` : "سیستم کسب‌وکار";
if (kind === "capability" || kind === "feature") return subject || "قابلیت کاربردی";
if (kind === "tax") return subject || "مالیات";
if (kind === "coupon" || kind === "discount") return subject || "تخفیف";
return subject || "گزینه تجاری";
}
export function looksTechnical(value: string): boolean {
return (
/[a-z0-9]+[._:/][a-z0-9_.:/-]+/i.test(value) ||
/^[a-z][a-z0-9_]+$/i.test(value) ||
/\b(payload|json|registry|runtime|frontend|backend|metadata|heuristic|fallback|trace|api)\b/i.test(value)
);
}
export function formatMoney(amount?: number | null, currency?: string | null): string {
if (amount == null) return "استعلام قیمت";
const unit = !currency || /irr|rial/i.test(currency) ? "ریال" : enumLabel(currency);
return `${new Intl.NumberFormat("fa-IR").format(amount)} ${unit}`;
}
export function formatDate(value?: string | null): string {
if (!value) return "ثبت نشده";
const date = new Date(value);
return Number.isNaN(date.getTime())
? "ثبت نشده"
: new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(date);
}
export function friendlyMessage(message?: string | null, fallback = "در حال حاضر اطلاعاتی برای نمایش نداریم."): string {
if (!message || looksTechnical(message) || /\b(4\d\d|5\d\d)\b/.test(message)) return fallback;
return message.replace(/\[object Object\]/g, "اطلاعات تکمیلی");
}
export function recommendationReason(reason?: string | null): string {
if (!reason || looksTechnical(reason)) return "بر اساس پاسخ‌های شما";
const normalized = reason.toLowerCase();
if (/branch|شعب/.test(normalized)) return "چون چند شعبه دارید";
if (/delivery|ارسال/.test(normalized)) return "چون ارسال کالا دارید";
if (/online|website|وب/.test(normalized)) return "چون فروش آنلاین دارید";
if (/pos|in.person|حضوری|فروش/.test(normalized)) return "چون فروش حضوری دارید";
if (/loyal|باشگاه/.test(normalized)) return "چون باشگاه مشتریان برای شما مهم است";
if (/booking|reservation|رزرو/.test(normalized)) return "چون به مدیریت رزرو نیاز دارید";
return reason;
}
export function presentValue(value: unknown): string | string[] | Record<string, unknown> {
if (Array.isArray(value)) {
return value.map((item) =>
typeof item === "string" ? humanizeCommercialCode(item) : enumLabel(item)
);
}
if (value && typeof value === "object") return value as Record<string, unknown>;
if (typeof value === "string") return enumLabel(value);
if (typeof value === "boolean") return value ? "بله" : "خیر";
if (typeof value === "number") return new Intl.NumberFormat("fa-IR").format(value);
return "ثبت نشده";
}