feat(commercial): complete production presentation layer
This commit is contained in:
parent
ea5fabaa7c
commit
bf2913cdd5
64
docs/commercial-presentation-final-report.md
Normal file
64
docs/commercial-presentation-final-report.md
Normal 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.
|
||||
@ -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.
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) ? (
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -13,7 +13,7 @@ export function SetupChecklist({
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
چکلیست راهاندازی از commercial checklist API یا محصولات کشفشده ساخته میشود.
|
||||
مراحل راهاندازی پس از انتخاب محصول اینجا نمایش داده میشود.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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") && (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
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;
|
||||
|
||||
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 === "policies") {
|
||||
seed.policy_kind = "domain";
|
||||
seed.effect = "deny";
|
||||
seed.parameters = {
|
||||
custom_domain_allowed: false,
|
||||
subdomain_only: true,
|
||||
ssl_ready: false,
|
||||
dns_instructions: [],
|
||||
if (typeof current === "number" || /(amount|days|order|percent|limit)$/.test(key)) {
|
||||
return input === "" ? null : Number(input);
|
||||
}
|
||||
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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="راهنما"
|
||||
/>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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="راهنمای دامنه"
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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="باز کردن محصول"
|
||||
/>
|
||||
|
||||
192
frontend/modules/commercial/presentation.ts
Normal file
192
frontend/modules/commercial/presentation.ts
Normal 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 "ثبت نشده";
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user