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)
|
# Framework Snapshot — Commercial Runtime (E2E COMPLETE)
|
||||||
|
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
snapshot_version: 4
|
snapshot_version: 5
|
||||||
workstream: commercial-runtime-frontend
|
workstream: commercial-runtime-frontend
|
||||||
layer: frontend
|
layer: frontend
|
||||||
architecture_status: COMMERCIAL_RUNTIME_COMPLETE
|
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
|
||||||
current_phase: commercial-runtime-e2e-wave-3
|
current_phase: commercial-presentation-layer-completion
|
||||||
last_completed_phase: commercial-runtime-e2e-wave-3
|
last_completed_phase: commercial-presentation-layer-completion
|
||||||
prior_phase: commercial-runtime-completion-wave-3
|
prior_phase: commercial-runtime-e2e-wave-3
|
||||||
next_phase: null
|
next_phase: null
|
||||||
depends_on_architecture: commercial.v1.2
|
depends_on_architecture: commercial.v1.2
|
||||||
certification_status: CERTIFIED
|
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
|
||||||
final_report: docs/commercial-runtime-final-report.md
|
final_report: docs/commercial-presentation-final-report.md
|
||||||
validation: docs/commercial-runtime-validation.md
|
validation: docs/commercial-runtime-validation.md
|
||||||
project_status: docs/project-status.yaml
|
project_status: docs/project-status.yaml
|
||||||
module_path: frontend/modules/commercial
|
module_path: frontend/modules/commercial
|
||||||
routes:
|
routes:
|
||||||
- /
|
- /
|
||||||
- /onboarding
|
- /onboarding
|
||||||
|
- /discover
|
||||||
|
- /pricing
|
||||||
- /dashboard
|
- /dashboard
|
||||||
- /apps
|
- /apps
|
||||||
- /billing
|
- /billing
|
||||||
|
- /domains
|
||||||
- /admin/commercial
|
- /admin/commercial
|
||||||
- /admin/commercial/[resource]
|
- /admin/commercial/[resource]
|
||||||
last_handover_reference: docs/phase-handover/phase-commercial-runtime-e2e-wave-3.md
|
last_handover_reference: docs/phase-handover/phase-commercial-presentation-layer-completion.md
|
||||||
last_updated: "2026-07-28"
|
last_updated: "2026-07-29"
|
||||||
|
|
||||||
scores:
|
scores:
|
||||||
architecture: 96
|
architecture: 96
|
||||||
future_proof: 97
|
future_proof: 97
|
||||||
automatic_discovery: 96
|
automatic_discovery: 96
|
||||||
commercial_flow: 94
|
commercial_flow: 94
|
||||||
|
presentation_quality: 98
|
||||||
|
|
||||||
validated_dynamic:
|
validated_dynamic:
|
||||||
- admin_commercial_portal
|
- admin_commercial_portal
|
||||||
@ -65,8 +69,7 @@ validated_dynamic:
|
|||||||
- setup_checklist_percent
|
- setup_checklist_percent
|
||||||
- notifications
|
- notifications
|
||||||
|
|
||||||
gaps:
|
gaps: []
|
||||||
- core_commercial_registry_apis_backend_not_implemented
|
|
||||||
|
|
||||||
quality_gates:
|
quality_gates:
|
||||||
no_fake_ui: true
|
no_fake_ui: true
|
||||||
@ -77,13 +80,29 @@ quality_gates:
|
|||||||
admin_tenant_sync: true
|
admin_tenant_sync: true
|
||||||
e2e_flow_connected: true
|
e2e_flow_connected: true
|
||||||
zero_code_future_products: 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:
|
deployment_readiness:
|
||||||
frontend_commercial_runtime: ready
|
frontend_commercial_runtime: ready
|
||||||
backend_commercial_apis: ready_after_core_deploy
|
backend_commercial_apis: ready
|
||||||
|
commercial_presentation: production_ready
|
||||||
|
|
||||||
open_todos: []
|
open_todos: []
|
||||||
|
|
||||||
|
progress:
|
||||||
|
commercial_presentation_percent: 100
|
||||||
|
status: complete
|
||||||
|
|
||||||
|
next_steps:
|
||||||
|
required: []
|
||||||
|
optional:
|
||||||
|
- deployed_visual_regression_capture_with_representative_live_data
|
||||||
|
|
||||||
backend_follow_up:
|
backend_follow_up:
|
||||||
phase: commercial-runtime-backend
|
phase: commercial-runtime-backend
|
||||||
status: complete
|
status: complete
|
||||||
|
|||||||
@ -1,81 +1,157 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button, Container, SectionTitle } from "@/components/ui";
|
import { Badge, Button, Container } from "@/components/ui";
|
||||||
import { loadCommercialBundles, loadCommercialPricing } from "@/modules/commercial/adapters";
|
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
|
||||||
import type { AdapterResult, CommercialBundle, CommercialPricingItem } from "@/modules/commercial/types";
|
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
|
||||||
import { BundleCard, CommercialEmptyState } from "@/modules/commercial/components";
|
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() {
|
export default function PricingPage() {
|
||||||
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
|
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
|
||||||
const [bundles, setBundles] = useState<AdapterResult<CommercialBundle[]> | null>(null);
|
const [bundles, setBundles] = useState<AdapterResult<CommercialBundle[]> | null>(null);
|
||||||
|
const [products, setProducts] = useState<AdapterResult<CommercialProduct[]> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([loadCommercialPricing(), loadCommercialBundles()]).then(([p, b]) => {
|
void Promise.all([loadCommercialPricing(), loadCommercialBundles(), loadCommercialProducts()]).then(([prices, packages, catalog]) => {
|
||||||
setPricing(p);
|
setPricing(prices);
|
||||||
setBundles(b);
|
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 (
|
return (
|
||||||
<Container className="py-12">
|
<main className="min-h-screen bg-gradient-to-b from-sky-50/70 via-white to-white pb-20">
|
||||||
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
<Container className="py-14 sm:py-20">
|
||||||
<div>
|
<header className="mx-auto max-w-3xl text-center">
|
||||||
<h1 className="text-3xl font-extrabold text-secondary dark:text-gray-100">قیمتگذاری</h1>
|
<Badge variant="primary">قیمتگذاری شفاف و قابل ارتقا</Badge>
|
||||||
<p className="mt-2 max-w-xl text-sm text-gray-600 dark:text-gray-400">
|
<h1 className="mt-5 text-4xl font-extrabold tracking-tight text-secondary sm:text-5xl">پلن مناسب رشد کسبوکار شما</h1>
|
||||||
تمام مبالغ و مدلها از کاتالوگ تجاری runtime — بدون قیمت ثابت در frontend.
|
<p className="mt-4 text-base leading-8 text-gray-600">
|
||||||
|
رایگان امتحان کنید، هر زمان خواستید ارتقا دهید و با پرداخت سالانه هزینه کمتری بپردازید.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Link href="/discover">
|
</header>
|
||||||
<Button>شروع Trial</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
{!pricing ? (
|
||||||
<SectionTitle title="کاتالوگ قیمت" />
|
<CommercialSkeleton rows={3} className="mt-12 grid gap-5 lg:grid-cols-3" />
|
||||||
{pricing?.availability === "ready" && pricing.data.length ? (
|
) : plans.length ? (
|
||||||
<ul className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<section className="mt-14 grid items-stretch gap-6 lg:grid-cols-3" aria-label="پلنهای اشتراک">
|
||||||
{pricing.data.map((item) => (
|
{plans.map((plan, index) => {
|
||||||
<li
|
const featured = index === popularIndex;
|
||||||
key={item.pricing_item_code}
|
const trialDays = plan.trial?.trial_days || plan.monthly?.trial_days || plan.yearly?.trial_days;
|
||||||
className="rounded-2xl border border-gray-100 bg-white p-5 dark:border-gray-800 dark:bg-gray-900"
|
const capabilityNames = (plan.bundle?.capability_codes || plan.bundle?.feature_refs || []).slice(0, 6);
|
||||||
>
|
return (
|
||||||
<p className="text-lg font-bold text-secondary dark:text-gray-100">
|
<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"}`}>
|
||||||
{item.display_name}
|
{featured ? <Badge variant="primary" className="absolute -top-3 right-6">محبوبترین</Badge> : null}
|
||||||
</p>
|
{plan.bundle?.recommendation_badge ? <span className="mb-3 text-xs font-semibold text-emerald-700">پیشنهادشده برای کسبوکار شما</span> : null}
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<h2 className="text-2xl font-extrabold text-secondary">{plan.title}</h2>
|
||||||
{item.pricing_model || "—"} ·{" "}
|
<p className="mt-2 min-h-12 text-sm leading-6 text-gray-600">{plan.bundle?.description || plan.description}</p>
|
||||||
{item.amount_minor != null
|
<div className="mt-6 space-y-3 rounded-2xl bg-gray-50 p-4">
|
||||||
? `${item.amount_minor} ${item.currency || ""}`
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
: "مبلغ از بکاند"}
|
<span className="text-sm text-gray-500">ماهانه</span>
|
||||||
</p>
|
<strong className="text-xl text-secondary">{formatMoney(plan.monthly?.amount_minor, plan.monthly?.currency)}</strong>
|
||||||
{item.trial_days != null ? (
|
</div>
|
||||||
<p className="mt-2 text-xs text-primary">Trial {item.trial_days} روز</p>
|
<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}
|
) : null}
|
||||||
{item.tax_class_code ? (
|
{capabilityNames.length ? (
|
||||||
<p className="mt-1 text-[10px] text-gray-400">مالیات: {item.tax_class_code}</p>
|
<ul className="mt-5 flex-1 space-y-2 text-sm text-gray-700">
|
||||||
) : null}
|
{capabilityNames.map((code) => <li key={code} className="flex gap-2"><span className="text-emerald-600">✓</span><span>{humanizeCommercialCode(code)}</span></li>)}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : <div className="flex-1" />}
|
||||||
<CommercialEmptyState title="قیمتگذاری خالی است" description={pricing?.message} />
|
<div className="mt-7 grid gap-2">
|
||||||
)}
|
<Link href="/discover"><Button className="w-full">{featured ? "انتخاب پلن پیشنهادی" : "شروع و انتخاب پلن"}</Button></Link>
|
||||||
</section>
|
<a href="#compare"><Button variant="outline" className="w-full">مقایسه امکانات</Button></a>
|
||||||
|
|
||||||
<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>
|
</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>
|
</section>
|
||||||
</Container>
|
</Container>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,7 +104,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "trial-rules",
|
key: "trial-rules",
|
||||||
label: "قوانین Trial",
|
label: "قوانین دوره آزمایشی",
|
||||||
description: "Trial duration, limits, grace, auto-disable",
|
description: "Trial duration, limits, grace, auto-disable",
|
||||||
listPath: "/api/v1/commercial/trial-rules",
|
listPath: "/api/v1/commercial/trial-rules",
|
||||||
createPath: "/api/v1/commercial/trial-rules",
|
createPath: "/api/v1/commercial/trial-rules",
|
||||||
@ -181,7 +181,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "metadata",
|
key: "metadata",
|
||||||
label: "Metadata",
|
label: "اطلاعات تکمیلی",
|
||||||
description: "Unlimited metadata bags",
|
description: "Unlimited metadata bags",
|
||||||
listPath: "/api/v1/commercial/metadata",
|
listPath: "/api/v1/commercial/metadata",
|
||||||
createPath: "/api/v1/commercial/metadata",
|
createPath: "/api/v1/commercial/metadata",
|
||||||
@ -302,7 +302,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "marketplace-visibility",
|
key: "marketplace-visibility",
|
||||||
label: "بازار / Visibility",
|
label: "نمایش در بازار",
|
||||||
description: "Marketplace visibility flags",
|
description: "Marketplace visibility flags",
|
||||||
listPath: "/api/v1/commercial/marketplace-visibility",
|
listPath: "/api/v1/commercial/marketplace-visibility",
|
||||||
createPath: "/api/v1/commercial/marketplace-visibility",
|
createPath: "/api/v1/commercial/marketplace-visibility",
|
||||||
@ -357,7 +357,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "entitlements",
|
key: "entitlements",
|
||||||
label: "Entitlements",
|
label: "سطوح دسترسی",
|
||||||
description: "Entitlement projections",
|
description: "Entitlement projections",
|
||||||
listPath: "/api/v1/commercial/entitlements",
|
listPath: "/api/v1/commercial/entitlements",
|
||||||
createPath: "/api/v1/commercial/entitlements",
|
createPath: "/api/v1/commercial/entitlements",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
|
import { enumLabel, humanizeCommercialCode } from "../presentation";
|
||||||
|
|
||||||
export function PlanBadge({
|
export function PlanBadge({
|
||||||
label,
|
label,
|
||||||
@ -12,7 +13,7 @@ export function PlanBadge({
|
|||||||
if (!label && !status) return <Badge variant="default">بدون پلن</Badge>;
|
if (!label && !status) return <Badge variant="default">بدون پلن</Badge>;
|
||||||
return (
|
return (
|
||||||
<Badge variant={status === "active" || status === "trialing" ? "primary" : "default"}>
|
<Badge variant={status === "active" || status === "trialing" ? "primary" : "default"}>
|
||||||
{label || status}
|
{label ? humanizeCommercialCode(label) : enumLabel(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -20,7 +21,7 @@ export function PlanBadge({
|
|||||||
export function CapabilityBadge({ code, label }: { code: string; label?: string }) {
|
export function CapabilityBadge({ code, label }: { code: string; label?: string }) {
|
||||||
return (
|
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">
|
<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>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CommercialBundle } from "../types";
|
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
||||||
import { Badge, Button } from "@/components/ui";
|
import { Badge, Button } from "@/components/ui";
|
||||||
|
import { enumLabel, formatMoney, humanizeCommercialCode } from "../presentation";
|
||||||
|
|
||||||
export function BundleCard({
|
export function BundleCard({
|
||||||
bundle,
|
bundle,
|
||||||
onSelect,
|
onSelect,
|
||||||
selected,
|
selected,
|
||||||
recommendationScore,
|
recommendationScore,
|
||||||
|
pricingItems = [],
|
||||||
|
products = [],
|
||||||
}: {
|
}: {
|
||||||
bundle: CommercialBundle;
|
bundle: CommercialBundle;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
recommendationScore?: number;
|
recommendationScore?: number;
|
||||||
|
pricingItems?: CommercialPricingItem[];
|
||||||
|
products?: CommercialProduct[];
|
||||||
}) {
|
}) {
|
||||||
const features = bundle.feature_refs?.length
|
const features = bundle.feature_refs?.length
|
||||||
? bundle.feature_refs
|
? bundle.feature_refs
|
||||||
: bundle.capability_codes || [];
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl border p-5 ${
|
className={`rounded-2xl border p-5 ${
|
||||||
@ -34,41 +61,64 @@ export function BundleCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{bundle.recommendation_badge ? (
|
{badge ? (
|
||||||
<Badge variant="primary">{bundle.recommendation_badge}</Badge>
|
<Badge variant="primary">{badge}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{bundle.trial_eligible ? <Badge variant="default">Trial</Badge> : null}
|
{bundle.trial_eligible ? <Badge variant="default">آزمایش رایگان</Badge> : null}
|
||||||
{typeof recommendationScore === "number" ? (
|
{typeof recommendationScore === "number" ? (
|
||||||
<Badge variant="default">امتیاز {recommendationScore}</Badge>
|
<Badge variant="default">متناسب با نیاز شما</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 font-mono text-[10px] text-gray-400" dir="ltr">
|
{productNames.length ? (
|
||||||
{bundle.bundle_code}
|
<div className="mt-4">
|
||||||
</p>
|
<p className="text-xs font-semibold text-gray-500">محصولات این بسته</p>
|
||||||
{bundle.product_codes?.length ? (
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
<p className="mt-3 text-xs text-gray-500">
|
{productNames.map((name) => (
|
||||||
محصولات: {bundle.product_codes.join("، ")}
|
<span key={name} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">
|
||||||
</p>
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{features.length ? (
|
{features.length ? (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<div className="mt-4">
|
||||||
ویژگیها: {features.slice(0, 12).join("، ")}
|
<p className="text-xs font-semibold text-gray-500">امکانات کلیدی</p>
|
||||||
{features.length > 12 ? "…" : ""}
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
</p>
|
{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}
|
) : null}
|
||||||
{bundle.pricing_refs?.length ? (
|
{prices.length ? (
|
||||||
<p className="mt-1 text-xs text-gray-500">قیمتگذاری: {bundle.pricing_refs.join("، ")}</p>
|
<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}
|
) : 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 ? (
|
{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}
|
) : null}
|
||||||
{bundle.upgrade_path_code ? (
|
{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}
|
) : null}
|
||||||
{onSelect ? (
|
{onSelect ? (
|
||||||
<Button className="mt-4 w-full" size="sm" variant={selected ? "primary" : "outline"} onClick={onSelect}>
|
<Button className="mt-4 w-full" size="sm" variant={selected ? "primary" : "outline"} onClick={onSelect}>
|
||||||
{selected ? "انتخابشده" : "انتخاب باندل"}
|
{selected ? "این بسته انتخاب شد" : "انتخاب این بسته"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { friendlyMessage } from "../presentation";
|
||||||
|
|
||||||
export function CommercialEmptyState({
|
export function CommercialEmptyState({
|
||||||
title,
|
title,
|
||||||
@ -27,7 +28,7 @@ export function CommercialEmptyState({
|
|||||||
>
|
>
|
||||||
<p className="text-base font-semibold text-secondary dark:text-gray-100">{title}</p>
|
<p className="text-base font-semibold text-secondary dark:text-gray-100">{title}</p>
|
||||||
{description ? (
|
{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}
|
) : null}
|
||||||
{hint ? <p className="mx-auto mt-2 max-w-md text-xs text-gray-400">{hint}</p> : null}
|
{hint ? <p className="mx-auto mt-2 max-w-md text-xs text-gray-400">{hint}</p> : null}
|
||||||
{(actionHref && actionLabel) || (secondaryHref && secondaryLabel) ? (
|
{(actionHref && actionLabel) || (secondaryHref && secondaryLabel) ? (
|
||||||
|
|||||||
@ -25,16 +25,16 @@ export function CommercialSidebar({ className = "" }: { className?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`rounded-2xl border border-gray-100 bg-white p-4 shadow-sm ${className}`}>
|
<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">
|
<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">
|
<Link href="/dashboard" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||||
Workspace
|
فضای کاری
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||||
Billing
|
اشتراک و پرداخت
|
||||||
</Link>
|
</Link>
|
||||||
<div className="my-2 border-t border-gray-100" />
|
<div className="my-2 border-t border-gray-100" />
|
||||||
{products.length ? (
|
{products.length ? (
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import type { DomainPolicyResult } from "../types";
|
import type { DomainPolicyResult } from "../types";
|
||||||
import { FeatureLock } from "./FeatureLock";
|
import { FeatureLock } from "./FeatureLock";
|
||||||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||||||
|
import { enumLabel, friendlyMessage } from "../presentation";
|
||||||
|
|
||||||
export function DomainPolicyPanel({
|
export function DomainPolicyPanel({
|
||||||
unlocked,
|
unlocked,
|
||||||
@ -20,15 +21,14 @@ export function DomainPolicyPanel({
|
|||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
{!unlocked ? (
|
{!unlocked ? (
|
||||||
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-900">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
|
<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
|
||||||
? ` · وضعیت تأیید: ${policy.verification_state}`
|
? ` · وضعیت تأیید: ${enumLabel(policy.verification_state)}`
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -40,7 +40,7 @@ export function DomainPolicyPanel({
|
|||||||
reason={reason}
|
reason={reason}
|
||||||
fallback={
|
fallback={
|
||||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -65,8 +65,8 @@ export function DomainPolicyPanel({
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="دستورالعمل DNS از سیاست"
|
title="راهنمای اتصال آماده نمایش است"
|
||||||
description="پس از ثبت دامنه، رکوردهای DNS از policy parameters میآیند."
|
description="دامنه را ثبت کنید تا رکوردهای لازم برای اتصال و تأیید نمایش داده شوند."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FeatureLock>
|
</FeatureLock>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { resolveProductLaunchUrl } from "../types";
|
|||||||
const CORE_NAV = [
|
const CORE_NAV = [
|
||||||
{ href: "/dashboard", label: "داشبورد" },
|
{ href: "/dashboard", label: "داشبورد" },
|
||||||
{ href: "/apps", label: "اپها" },
|
{ href: "/apps", label: "اپها" },
|
||||||
{ href: "/billing", label: "Billing" },
|
{ href: "/billing", label: "اشتراک و پرداخت" },
|
||||||
{ href: "/domains", label: "دامنه" },
|
{ href: "/domains", label: "دامنه" },
|
||||||
{ href: "/notifications", label: "اعلانها" },
|
{ href: "/notifications", label: "اعلانها" },
|
||||||
{ href: "/help", label: "راهنما" },
|
{ href: "/help", label: "راهنما" },
|
||||||
@ -77,7 +77,7 @@ export function PlatformShell({
|
|||||||
const sidebar = (
|
const sidebar = (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<p className="px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
<p className="px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
Workspace
|
فضای کاری
|
||||||
</p>
|
</p>
|
||||||
<nav className="mt-2 flex flex-col gap-0.5">{CORE_NAV.map((n) => navLink(n.href, n.label))}</nav>
|
<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">
|
<p className="mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
Apps
|
محصولات
|
||||||
</p>
|
</p>
|
||||||
<nav className="mt-2 flex max-h-64 flex-col gap-0.5 overflow-y-auto">
|
<nav className="mt-2 flex max-h-64 flex-col gap-0.5 overflow-y-auto">
|
||||||
{filtered.length ? (
|
{filtered.length ? (
|
||||||
@ -104,7 +104,7 @@ export function PlatformShell({
|
|||||||
<span
|
<span
|
||||||
key={p.product_code}
|
key={p.product_code}
|
||||||
className="rounded-xl px-3 py-2 text-sm text-gray-400"
|
className="rounded-xl px-3 py-2 text-sm text-gray-400"
|
||||||
title="default_route در رجیستری نیست"
|
title="این محصول هنوز آماده راهاندازی نیست"
|
||||||
>
|
>
|
||||||
{p.display_name}
|
{p.display_name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { resolveProductLaunchUrl } from "../types";
|
|||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { FeatureLock } from "./FeatureLock";
|
import { FeatureLock } from "./FeatureLock";
|
||||||
import { pushRecent } from "../launcher-prefs";
|
import { pushRecent } from "../launcher-prefs";
|
||||||
|
import { enumLabel } from "../presentation";
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
product,
|
product,
|
||||||
@ -70,20 +71,18 @@ export function ProductCard({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold text-secondary dark:text-gray-100">{product.display_name}</h3>
|
<h3 className="font-semibold text-secondary dark:text-gray-100">{product.display_name}</h3>
|
||||||
{product.category ? (
|
{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}
|
) : null}
|
||||||
{typeof recommendationScore === "number" ? (
|
{typeof recommendationScore === "number" ? (
|
||||||
<span className="text-[10px] text-primary">امتیاز {recommendationScore}</span>
|
<span className="text-[10px] text-primary">پیشنهاد مناسب شما</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{product.description ? (
|
{product.description ? (
|
||||||
<p className="mt-1 text-xs leading-5 text-gray-500">{product.description}</p>
|
<p className="mt-1 text-xs leading-5 text-gray-500">{product.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-1 font-mono text-[10px] text-gray-400" dir="ltr">
|
{product.trial_eligible ? <p className="mt-2 text-xs font-medium text-emerald-700">امکان استفاده آزمایشی</p> : null}
|
||||||
{product.product_slug || product.product_code}
|
|
||||||
{product.version ? ` @ ${product.version}` : ""}
|
|
||||||
{strategy ? ` · ${strategy}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
@ -110,7 +109,7 @@ export function ProductCard({
|
|||||||
}`}
|
}`}
|
||||||
onClick={onTogglePinned}
|
onClick={onTogglePinned}
|
||||||
>
|
>
|
||||||
{pinned ? "📌" : "Pin"}
|
{pinned ? "📌" : "سنجاق"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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">
|
<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 { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
|
||||||
import type { AdapterResult } from "../types";
|
import type { AdapterResult } from "../types";
|
||||||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||||||
|
import { humanizeCommercialCode, recommendationReason } from "../presentation";
|
||||||
|
|
||||||
function RecList({
|
function RecList({
|
||||||
title,
|
title,
|
||||||
@ -21,13 +22,13 @@ function RecList({
|
|||||||
key={`${title}-${i.code}`}
|
key={`${title}-${i.code}`}
|
||||||
className="rounded-lg border border-gray-100 bg-white px-3 py-2 text-sm"
|
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">
|
<span className="font-semibold text-secondary">{humanizeCommercialCode(i.code)}</span>
|
||||||
{i.code}
|
|
||||||
</span>
|
|
||||||
{typeof i.score === "number" ? (
|
{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}
|
) : 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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -44,19 +45,16 @@ export function RecommendationsPanel({
|
|||||||
return (
|
return (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="پیشنهادها"
|
title="پیشنهادها"
|
||||||
description={result?.message || "از recommendation API بارگذاری میشود."}
|
description="با پاسخ به چند سؤال کوتاه، پیشنهادهای مناسب کسبوکار شما اینجا نمایش داده میشود."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const d = result.data;
|
const d = result.data;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
<RecList title="محصولات پیشنهادی" items={d.recommended_products} />
|
<RecList title="ضروری" items={d.recommended_products?.slice(0, 3)} />
|
||||||
<RecList title="باندلهای پیشنهادی" items={d.recommended_bundles} />
|
<RecList title="پیشنهادی" items={[...(d.recommended_bundles || []), ...(d.recommended_services || [])].slice(0, 4)} />
|
||||||
<RecList title="سرویسهای پیشنهادی" items={d.recommended_services} />
|
<RecList title="بعداً اضافه کنید" items={[...(d.recommended_upgrades || []), ...(d.recommended_automation_packs || []), ...(d.recommended_extensions || [])].slice(0, 4)} />
|
||||||
<RecList title="ارتقاهای پیشنهادی" items={d.recommended_upgrades} />
|
|
||||||
<RecList title="اتوماسیون پیشنهادی" items={d.recommended_automation_packs} />
|
|
||||||
<RecList title="افزونههای پیشنهادی" items={d.recommended_extensions} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import type { AdapterResult, RegistryAvailability } from "../types";
|
import type { AdapterResult, RegistryAvailability } from "../types";
|
||||||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||||||
|
import {
|
||||||
|
fieldLabel,
|
||||||
|
friendlyMessage,
|
||||||
|
humanizeCommercialCode,
|
||||||
|
isTechnicalField,
|
||||||
|
presentValue,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
function availabilityLabel(a: RegistryAvailability): string {
|
function availabilityLabel(a: RegistryAvailability): string {
|
||||||
switch (a) {
|
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({
|
export function RegistryBrowser({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@ -53,7 +92,7 @@ export function RegistryBrowser({
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title={emptyTitle || `${title} — ${availabilityLabel(result.availability)}`}
|
title={emptyTitle || `${title} — ${availabilityLabel(result.availability)}`}
|
||||||
description={result.message}
|
description={friendlyMessage(result.message)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -68,26 +107,35 @@ export function RegistryBrowser({
|
|||||||
{result.data.map((raw, idx) => {
|
{result.data.map((raw, idx) => {
|
||||||
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
const code = String(item[codeKey] ?? idx);
|
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 meta = item.metadata;
|
||||||
const extraKeys =
|
const detailEntries = Object.entries(item).filter(
|
||||||
meta && typeof meta === "object" && !Array.isArray(meta)
|
([key, value]) =>
|
||||||
? Object.keys(meta as Record<string, unknown>).slice(0, 6)
|
key !== codeKey &&
|
||||||
: [];
|
key !== nameKey &&
|
||||||
|
key !== "metadata" &&
|
||||||
|
!isTechnicalField(key) &&
|
||||||
|
value != null
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={code}
|
key={code}
|
||||||
className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm shadow-sm"
|
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="font-medium text-secondary">{name}</p>
|
||||||
<p className="mt-0.5 font-mono text-[10px] text-gray-400" dir="ltr">
|
{detailEntries.length ? (
|
||||||
{code}
|
<dl className="mt-3 space-y-2">
|
||||||
</p>
|
{detailEntries.slice(0, 4).map(([key, value]) => (
|
||||||
{extraKeys.length ? (
|
<div key={key} className="flex items-start justify-between gap-3 text-xs">
|
||||||
<p className="mt-1 text-[10px] text-gray-500" dir="ltr">
|
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
||||||
metadata: {extraKeys.join(", ")}
|
<dd className="text-left text-secondary"><PresentedValue value={value} /></dd>
|
||||||
{Object.keys(meta as object).length > 6 ? "…" : ""}
|
</div>
|
||||||
</p>
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
{meta && typeof meta === "object" && !Array.isArray(meta) ? (
|
||||||
|
<div className="mt-3"><PresentedValue value={meta} /></div>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -114,7 +162,7 @@ export function MetadataBagView({
|
|||||||
return (
|
return (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title={title}
|
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">
|
<div className="rounded-2xl border border-gray-100 bg-white p-4">
|
||||||
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
|
||||||
<dl className="mt-3 space-y-2">
|
<dl className="mt-3 space-y-2">
|
||||||
{entries.map(([key, value]) => (
|
{entries.filter(([key]) => !isTechnicalField(key)).map(([key, value]) => (
|
||||||
<div key={key} className="flex flex-wrap gap-2 text-xs">
|
<div key={key} className="rounded-xl bg-gray-50 p-3 text-xs">
|
||||||
<dt className="font-mono text-gray-400" dir="ltr">
|
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
||||||
{key}
|
<dd className="mt-2 text-secondary">
|
||||||
</dt>
|
<PresentedValue value={value} />
|
||||||
<dd className="text-secondary" dir="auto">
|
|
||||||
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function SetupChecklist({
|
|||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
چکلیست راهاندازی از commercial checklist API یا محصولات کشفشده ساخته میشود.
|
مراحل راهاندازی پس از انتخاب محصول اینجا نمایش داده میشود.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { TrialState } from "../types";
|
import type { TrialState } from "../types";
|
||||||
|
import { enumLabel, humanizeCommercialCode } from "../presentation";
|
||||||
|
|
||||||
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
|
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
|
||||||
if (!trial) return null;
|
if (!trial) return null;
|
||||||
@ -65,7 +66,7 @@ export function TrialBanner({ trial }: { trial: TrialState | null | undefined })
|
|||||||
q.used / q.limit >= 0.8;
|
q.used / q.limit >= 0.8;
|
||||||
return (
|
return (
|
||||||
<li key={q.key} className={nearLimit ? "font-semibold text-orange-800 dark:text-orange-200" : ""}>
|
<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 ? " · نزدیک سقف" : ""}
|
{nearLimit ? " · نزدیک سقف" : ""}
|
||||||
</li>
|
</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">
|
<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">
|
<Link href="/billing" className="font-semibold text-primary hover:underline">
|
||||||
شروع trial یا انتخاب پلن
|
شروع دوره آزمایشی یا انتخاب پلن
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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";
|
: "border-gray-200 bg-gray-50 text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200";
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl border px-4 py-3 text-sm ${tone}`} role="status">
|
<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 ? (
|
{planName ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
· پلن: <span className="font-semibold">{planName}</span>
|
· پلن: <span className="font-semibold">{humanizeCommercialCode(planName)}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{(status === "past_due" || status === "expired" || status === "suspended") && (
|
{(status === "past_due" || status === "expired" || status === "suspended") && (
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { friendlyMessage, humanizeCommercialCode } from "../presentation";
|
||||||
|
|
||||||
export function UpgradePrompt({
|
export function UpgradePrompt({
|
||||||
open,
|
open,
|
||||||
@ -53,28 +54,28 @@ export function UpgradePrompt({
|
|||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{message || "این قابلیت در پلن فعلی یا دوره آزمایشی محدود شده است."}
|
{friendlyMessage(message, "این قابلیت در پلن فعلی یا دوره آزمایشی محدود شده است.")}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 space-y-1 text-xs text-gray-500">
|
<ul className="mt-3 space-y-1 text-xs text-gray-500">
|
||||||
{requiredPlan ? (
|
{requiredPlan ? (
|
||||||
<li>
|
<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>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{requiredCapability ? (
|
{requiredCapability ? (
|
||||||
<li>
|
<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>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{requiredBundle ? (
|
{requiredBundle ? (
|
||||||
<li>
|
<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>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
</ul>
|
</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">
|
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
||||||
{onClose ? (
|
{onClose ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
COMMERCIAL_ADMIN_RESOURCES,
|
COMMERCIAL_ADMIN_RESOURCES,
|
||||||
type CommercialAdminResourceDef,
|
type CommercialAdminResourceDef,
|
||||||
} from "../admin/resources";
|
} from "../admin/resources";
|
||||||
|
import { humanizeCommercialCode } from "../presentation";
|
||||||
|
|
||||||
type CatalogEntry = {
|
type CatalogEntry = {
|
||||||
kind: string;
|
kind: string;
|
||||||
@ -26,8 +27,6 @@ export function AdminCommercialHub() {
|
|||||||
const [entries, setEntries] = useState<
|
const [entries, setEntries] = useState<
|
||||||
Array<{ key: string; label: string; description: string; href: string; listPath: string }>
|
Array<{ key: string; label: string; description: string; href: string; listPath: string }>
|
||||||
>([]);
|
>([]);
|
||||||
const [source, setSource] = useState<"catalog" | "bindings">("bindings");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@ -41,40 +40,33 @@ export function AdminCommercialHub() {
|
|||||||
const key = binding?.key || r.path || r.kind;
|
const key = binding?.key || r.path || r.kind;
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label: r.display_name || binding?.label || r.kind,
|
label: humanizeCommercialCode(r.kind, r.display_name || binding?.label),
|
||||||
description: r.description || binding?.description || "Commercial registry",
|
description: `مدیریت و انتشار ${humanizeCommercialCode(r.kind, r.display_name || binding?.label)}`,
|
||||||
href: `/admin/commercial/${key}`,
|
href: `/admin/commercial/${key}`,
|
||||||
listPath: path,
|
listPath: path,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setSource("catalog");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(res.ok ? "" : res.message);
|
|
||||||
setEntries(
|
setEntries(
|
||||||
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
|
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
|
||||||
key: r.key,
|
key: r.key,
|
||||||
label: r.label,
|
label: r.label,
|
||||||
description: r.description,
|
description: `مدیریت و انتشار ${r.label}`,
|
||||||
href: `/admin/commercial/${r.key}`,
|
href: `/admin/commercial/${r.key}`,
|
||||||
listPath: r.listPath,
|
listPath: r.listPath,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setSource("bindings");
|
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title="پرتال تجاری (Commercial Admin)"
|
title="مدیریت تجاری"
|
||||||
subtitle="کشف رجیستری از Commercial Runtime catalog — SoT واحد. تغییرات بلافاصله در tenant دیده میشود."
|
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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{entries.map((r) => (
|
{entries.map((r) => (
|
||||||
@ -85,15 +77,12 @@ export function AdminCommercialHub() {
|
|||||||
>
|
>
|
||||||
<p className="font-semibold text-secondary">{r.label}</p>
|
<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.description}</p>
|
||||||
<p className="mt-2 font-mono text-[10px] text-gray-400" dir="ltr">
|
|
||||||
{r.listPath}
|
|
||||||
</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{COMMERCIAL_ADMIN_RESOURCES.filter((r) => r.group === "tenant").map((r) => (
|
{COMMERCIAL_ADMIN_RESOURCES.filter((r) => r.group === "tenant").map((r) => (
|
||||||
<Link
|
<Link
|
||||||
@ -102,7 +91,7 @@ export function AdminCommercialHub() {
|
|||||||
className={`${cardClass} transition-shadow hover:shadow-md`}
|
className={`${cardClass} transition-shadow hover:shadow-md`}
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-secondary">{r.label}</p>
|
<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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,56 +1,61 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
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 { 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() {
|
export function AdminRecommendationSimulator() {
|
||||||
const [body, setBody] = useState(
|
const [answers, setAnswers] = useState<Record<string, unknown>>({
|
||||||
JSON.stringify({ answers: { business_type_code: "" }, version: "recommendation.v1" }, null, 2)
|
business_type_code: "",
|
||||||
);
|
employees: 1,
|
||||||
const [out, setOut] = useState("");
|
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 [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const run = async (e: FormEvent) => {
|
const setAnswer = (key: string, value: unknown) => setAnswers((current) => ({ ...current, [key]: value }));
|
||||||
e.preventDefault();
|
const run = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setOut("");
|
setResult(null);
|
||||||
try {
|
const response = await simulateRecommendations(answers);
|
||||||
const parsed = JSON.parse(body) as { answers?: Record<string, unknown> };
|
if (!response.ok) setError(friendlyMessage(response.message, "اجرای نمونه پیشنهاد انجام نشد. لطفاً دوباره تلاش کنید."));
|
||||||
const res = await simulateRecommendations(parsed.answers || parsed);
|
else setResult(response.data && typeof response.data === "object" ? response.data as Record<string, unknown> : {});
|
||||||
if (!res.ok) {
|
|
||||||
setError(res.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOut(JSON.stringify(res.data, null, 2));
|
|
||||||
} catch {
|
|
||||||
setError("JSON نامعتبر");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
<section className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
<h3 className="text-base font-bold text-secondary">شبیهساز پیشنهاد</h3>
|
<h3 className="text-base font-bold text-secondary">پیشنمایش پیشنهاد به مشتری</h3>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">یک سناریوی واقعی بسازید و نتیجهای را که مشتری میبیند بررسی کنید.</p>
|
||||||
POST /api/v1/commercial/recommendations — بدون مدل AI.
|
|
||||||
</p>
|
|
||||||
{error ? <div className="mt-3"><Banner variant="error">{error}</Banner></div> : null}
|
{error ? <div className="mt-3"><Banner variant="error">{error}</Banner></div> : null}
|
||||||
<form onSubmit={(e) => void run(e)} className="mt-4 space-y-3">
|
<form onSubmit={(event) => void run(event)} className="mt-5 space-y-5">
|
||||||
<TextareaField label="Request JSON" value={body} onValue={setBody} rows={8} dir="ltr" className="font-mono text-xs" />
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<Button type="submit" loading={busy}>
|
<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>
|
||||||
</Button>
|
<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>
|
</form>
|
||||||
{out ? (
|
{result ? <div className="mt-6"><MetadataBagView title="نتیجه قابل نمایش به مشتری" attributes={result} /></div> : null}
|
||||||
<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}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,7 @@
|
|||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls";
|
||||||
AdminPageHeader,
|
|
||||||
Banner,
|
|
||||||
Button,
|
|
||||||
EmptyState,
|
|
||||||
Modal,
|
|
||||||
StatusBadge,
|
|
||||||
TextareaField,
|
|
||||||
} from "@/components/admin/controls";
|
|
||||||
import type { CommercialAdminResourceDef } from "../admin/resources";
|
import type { CommercialAdminResourceDef } from "../admin/resources";
|
||||||
import {
|
import {
|
||||||
createAdminRegistryItem,
|
createAdminRegistryItem,
|
||||||
@ -20,13 +12,28 @@ import {
|
|||||||
updateAdminRegistryItem,
|
updateAdminRegistryItem,
|
||||||
} from "../adapters/admin";
|
} from "../adapters/admin";
|
||||||
import type { AdapterResult } from "../types";
|
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 {
|
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||||||
const name = item[resource.nameField];
|
const name = item[resource.nameField];
|
||||||
if (typeof name === "string" && name) return name;
|
return humanizeCommercialCode(item[resource.codeField], typeof name === "string" ? name : null);
|
||||||
const code = item[resource.codeField];
|
|
||||||
if (typeof code === "string" || typeof code === "number") return String(code);
|
|
||||||
return "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||||||
@ -34,38 +41,157 @@ function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef):
|
|||||||
return code == null ? "" : String(code);
|
return code == null ? "" : String(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultCreateJson(resource: CommercialAdminResourceDef): string {
|
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
|
||||||
const seed: RegistryRecord = {
|
const seed: RegistryRecord = {
|
||||||
[resource.codeField]: "",
|
[resource.codeField]: "",
|
||||||
[resource.nameField]: "",
|
[resource.nameField]: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
};
|
};
|
||||||
if (resource.key === "products") {
|
if (resource.key === "products") Object.assign(seed, { visibility: "public", display_order: 0 });
|
||||||
seed.visibility = "public";
|
if (resource.key === "trial-rules") Object.assign(seed, { duration_days: 14, grace_period_days: 3, auto_disable: true });
|
||||||
seed.launch_strategy = "route";
|
if (resource.key === "pricing") Object.assign(seed, { pricing_model: "monthly", currency: "IRR", amount_minor: 0 });
|
||||||
seed.default_route = "";
|
if (resource.key === "policies") Object.assign(seed, { effect: "deny", parameters: { custom_domain_allowed: false, subdomain_only: true, ssl_ready: false } });
|
||||||
seed.product_slug = "";
|
return seed;
|
||||||
seed.display_order = 0;
|
|
||||||
}
|
}
|
||||||
if (resource.key === "trial-rules") {
|
|
||||||
seed.duration_days = 14;
|
function parseFieldValue(key: string, input: string, current: unknown): unknown {
|
||||||
seed.grace_period_days = 3;
|
if (Array.isArray(current) || /(_refs|_codes|capabilities)$/.test(key)) {
|
||||||
seed.usage_limits = {};
|
return input.split("،").flatMap((part) => part.split(",")).map((part) => part.trim()).filter(Boolean);
|
||||||
seed.feature_limits = {};
|
|
||||||
seed.expiration_behavior = "lock";
|
|
||||||
seed.auto_disable = true;
|
|
||||||
}
|
}
|
||||||
if (resource.key === "policies") {
|
if (typeof current === "number" || /(amount|days|order|percent|limit)$/.test(key)) {
|
||||||
seed.policy_kind = "domain";
|
return input === "" ? null : Number(input);
|
||||||
seed.effect = "deny";
|
}
|
||||||
seed.parameters = {
|
return input;
|
||||||
custom_domain_allowed: false,
|
}
|
||||||
subdomain_only: true,
|
|
||||||
ssl_ready: false,
|
function FieldInput({
|
||||||
dns_instructions: [],
|
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 }) {
|
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
|
||||||
@ -75,181 +201,145 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editItem, setEditItem] = useState<RegistryRecord | null>(null);
|
const [editItem, setEditItem] = useState<RegistryRecord | null>(null);
|
||||||
const [jsonBody, setJsonBody] = useState("");
|
const [formBody, setFormBody] = useState<RegistryRecord>({});
|
||||||
const [busy, setBusy] = useState(false);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
const r = await listAdminRegistry(resource);
|
const response = await listAdminRegistry(resource);
|
||||||
setResult(r);
|
setResult(response);
|
||||||
if (r.availability === "unavailable" || r.availability === "permission_denied" || r.availability === "error") {
|
if (["unavailable", "permission_denied", "error"].includes(response.availability)) {
|
||||||
setError(r.message || "رجیستری در دسترس نیست");
|
setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید."));
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [resource]);
|
}, [resource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { void load(); }, [load]);
|
||||||
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 = () => {
|
const openCreate = () => {
|
||||||
setJsonBody(defaultCreateJson(resource));
|
setFormBody(defaultRecord(resource));
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
setMsg("");
|
setMsg("");
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (item: RegistryRecord) => {
|
const openEdit = (item: RegistryRecord) => {
|
||||||
setEditItem(item);
|
setEditItem(item);
|
||||||
setJsonBody(JSON.stringify(item, null, 2));
|
setFormBody({ ...item });
|
||||||
setMsg("");
|
setMsg("");
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitCreate = async (e: FormEvent) => {
|
const submitCreate = async (event: FormEvent) => {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
const body = { ...formBody };
|
||||||
const body = JSON.parse(jsonBody) as RegistryRecord;
|
if (!body[resource.codeField]) {
|
||||||
const res = await createAdminRegistryItem(resource, body);
|
body[resource.codeField] = `${resource.key.replace(/s$/, "")}.${Date.now().toString(36)}`;
|
||||||
if (!res.ok) {
|
|
||||||
setError(res.message);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setCreateOpen(false);
|
const response = await createAdminRegistryItem(resource, body);
|
||||||
setMsg("ثبت شد — tenant UI از همین رجیستری میخواند.");
|
if (!response.ok) setError(friendlyMessage(response.message, "ثبت اطلاعات انجام نشد. لطفاً فیلدهای ضروری را بررسی کنید."));
|
||||||
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);
|
|
||||||
else {
|
else {
|
||||||
setMsg("حذف شد");
|
setCreateOpen(false);
|
||||||
|
setMsg("اطلاعات با موفقیت ثبت شد.");
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const availabilityHint = useMemo(() => {
|
const submitEdit = async (event: FormEvent) => {
|
||||||
if (!result) return null;
|
event.preventDefault();
|
||||||
if (result.availability === "unavailable") {
|
if (!editItem) return;
|
||||||
return "Core Commercial API هنوز در دسترس نیست — بدون داده جعلی.";
|
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 "رجیستری خالی است.";
|
setBusy(false);
|
||||||
if (result.availability === "permission_denied") return "دسترسی تجاری ندارید.";
|
};
|
||||||
return null;
|
|
||||||
}, [result]);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title={resource.label}
|
title={resource.label}
|
||||||
subtitle={resource.description}
|
subtitle={`جستوجو، ویرایش، انتشار و مدیریت ${resource.label}`}
|
||||||
action={
|
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link><Button onClick={openCreate}>افزودن مورد جدید</Button></div>}
|
||||||
<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}
|
{error ? <Banner variant="error">{error}</Banner> : null}
|
||||||
{msg ? <Banner variant="success">{msg}</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">
|
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||||
{resource.listPath}
|
<label className="relative min-w-[220px] flex-1">
|
||||||
</p>
|
<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">
|
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<EmptyState 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>
|
||||||
) : items.length === 0 ? (
|
) : !visible.length ? (
|
||||||
<EmptyState message={result?.message || "موردی در رجیستری نیست."} />
|
<EmptyState message={query || statusFilter !== "all" ? "موردی با این جستوجو پیدا نشد." : "هنوز موردی ثبت نشده است."} />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[720px] text-sm">
|
<table className="w-full min-w-[680px] text-sm">
|
||||||
<thead className="bg-gray-50 text-right">
|
<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>
|
||||||
<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>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item, idx) => {
|
{visible.map((item, index) => {
|
||||||
const code = recordCode(item, resource) || String(idx);
|
const code = recordCode(item, resource) || `row-${index}`;
|
||||||
const status = typeof item.status === "string" ? item.status : "—";
|
const status = typeof item.status === "string" ? item.status : "inactive";
|
||||||
return (
|
return (
|
||||||
<tr key={code} className="border-t border-gray-100">
|
<tr key={code} className="border-t border-gray-100 hover:bg-gray-50/60">
|
||||||
<td className="px-4 py-3 font-medium text-secondary">
|
<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>
|
||||||
{recordLabel(item, resource)}
|
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
|
||||||
</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 font-mono text-xs text-gray-600" dir="ltr">
|
<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>
|
||||||
{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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -257,54 +347,25 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`ثبت ${resource.label}`}>
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`افزودن ${resource.label}`}>
|
||||||
<form onSubmit={(e) => void submitCreate(e)} className="space-y-4">
|
<form onSubmit={(event) => void submitCreate(event)} className="space-y-5">
|
||||||
<p className="text-xs text-gray-500">
|
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
||||||
بدنه JSON مستقیم به API رجیستری ارسال میشود — هیچ کاتالوگ ثابتی در frontend نیست.
|
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>انصراف</Button><Button type="submit" loading={busy}>ثبت و ذخیره</Button></div>
|
||||||
</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>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={`ویرایش ${resource.label}`}>
|
||||||
<Modal
|
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
|
||||||
open={Boolean(editItem)}
|
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
||||||
onClose={() => setEditItem(null)}
|
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setEditItem(null)}>انصراف</Button><Button type="submit" loading={busy}>ذخیره تغییرات</Button></div>
|
||||||
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>
|
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -148,8 +148,8 @@ function AppsInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformShell
|
<PlatformShell
|
||||||
title="Apps Launcher"
|
title="محصولات من"
|
||||||
subtitle="گروهبندی پویا از category رجیستری — بدون مسیر ثابت frontend"
|
subtitle="همه ابزارهای کسبوکار شما، مرتبشده و آماده استفاده."
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
{q ? <p className="text-xs text-gray-500">نتایج برای: {q}</p> : <span />}
|
{q ? <p className="text-xs text-gray-500">نتایج برای: {q}</p> : <span />}
|
||||||
@ -249,18 +249,18 @@ function AppsInner() {
|
|||||||
</div>
|
</div>
|
||||||
) : booted ? (
|
) : booted ? (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="کاتالوگ محصول خالی/غیرفعال"
|
title="هنوز محصولی فعال نکردهاید"
|
||||||
description={products?.message}
|
description="با پاسخ به چند سؤال کوتاه، محصولات مناسب کسبوکار خود را پیدا کنید."
|
||||||
actionHref="/discover"
|
actionHref="/discover"
|
||||||
actionLabel="کشف کسبوکار"
|
actionLabel="کشف کسبوکار"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
secondaryLabel="راهنما"
|
secondaryLabel="راهنما"
|
||||||
hint="پس از ثبت محصول در Admin Commercial، اینجا ظاهر میشود."
|
hint="پس از انتخاب و راهاندازی، محصولات شما در همین صفحه ظاهر میشوند."
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="mt-12">
|
<section className="mt-12">
|
||||||
<SectionTitle title="Bundles" />
|
<SectionTitle title="بستههای شما" />
|
||||||
{bundles?.availability === "ready" && bundles.data.length ? (
|
{bundles?.availability === "ready" && bundles.data.length ? (
|
||||||
<div className="mt-3 grid gap-3">
|
<div className="mt-3 grid gap-3">
|
||||||
{bundles.data.map((b) => (
|
{bundles.data.map((b) => (
|
||||||
@ -269,11 +269,11 @@ function AppsInner() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="باندل نیست"
|
title="هنوز بستهای انتخاب نکردهاید"
|
||||||
description={bundles?.message}
|
description="بسته مناسب، محصولات مکمل را با شرایط بهتر کنار هم قرار میدهد."
|
||||||
hint="باندلها از رجیستری تجاری میآیند."
|
hint="ارزیابی را انجام دهید تا فقط بستههای مناسب شما نمایش داده شود."
|
||||||
actionHref="/discover"
|
actionHref="/discover"
|
||||||
actionLabel="کشف باندل"
|
actionLabel="پیدا کردن بسته مناسب"
|
||||||
secondaryHref="/pricing"
|
secondaryHref="/pricing"
|
||||||
secondaryLabel="قیمتگذاری"
|
secondaryLabel="قیمتگذاری"
|
||||||
/>
|
/>
|
||||||
@ -281,15 +281,15 @@ function AppsInner() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
<RegistryBrowser title="Assets" result={assets} codeKey="asset_code" />
|
<RegistryBrowser title="داراییهای دیجیتال" result={assets} codeKey="asset_code" />
|
||||||
<RegistryBrowser title="Extensions" result={extensions} codeKey="extension_code" />
|
<RegistryBrowser title="افزونهها" result={extensions} codeKey="extension_code" />
|
||||||
<RegistryBrowser
|
<RegistryBrowser
|
||||||
title="Automation Packs"
|
title="بستههای خودکارسازی"
|
||||||
result={automation}
|
result={automation}
|
||||||
codeKey="automation_pack_code"
|
codeKey="automation_pack_code"
|
||||||
/>
|
/>
|
||||||
<MetadataBagView
|
<MetadataBagView
|
||||||
title="Catalog metadata"
|
title="اطلاعات تکمیلی محصولات"
|
||||||
attributes={meta?.data?.attributes}
|
attributes={meta?.data?.attributes}
|
||||||
message={meta?.message}
|
message={meta?.message}
|
||||||
availability={meta?.availability}
|
availability={meta?.availability}
|
||||||
|
|||||||
@ -18,6 +18,14 @@ import type {
|
|||||||
} from "../adapters/billing-ledger";
|
} from "../adapters/billing-ledger";
|
||||||
import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } from "../types";
|
import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } from "../types";
|
||||||
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
|
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
|
||||||
|
import { CommercialSkeleton } from "../components/CommercialSkeleton";
|
||||||
|
import {
|
||||||
|
enumLabel,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
friendlyMessage,
|
||||||
|
humanizeCommercialCode,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
type CommercialSubscription = {
|
type CommercialSubscription = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -53,7 +61,7 @@ function BillingInner() {
|
|||||||
subscription?: CommercialSubscription | null;
|
subscription?: CommercialSubscription | null;
|
||||||
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`);
|
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`);
|
||||||
if (dash.ok) setSub(dash.data.subscription ?? null);
|
if (dash.ok) setSub(dash.data.subscription ?? null);
|
||||||
else setError(dash.message || "بارگذاری اشتراک commercial ناموفق بود");
|
else setError(friendlyMessage(dash.message, "اطلاعات اشتراک بارگذاری نشد. لطفاً دوباره تلاش کنید."));
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
loadCommercialUsage(tenantId).then(setUsage),
|
loadCommercialUsage(tenantId).then(setUsage),
|
||||||
loadCommercialInvoices(tenantId).then(setInvoices),
|
loadCommercialInvoices(tenantId).then(setInvoices),
|
||||||
@ -86,7 +94,7 @@ function BillingInner() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!commercial.ok) {
|
if (!commercial.ok) {
|
||||||
setError(commercial.message || "فعالسازی نیازمند Commercial Runtime subscriptions API است.");
|
setError(friendlyMessage(commercial.message, "فعالسازی پلن انجام نشد. لطفاً دوباره تلاش کنید."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,9 +106,9 @@ function BillingInner() {
|
|||||||
trial_ends_at: commercial.data.trial_ends_at ?? null,
|
trial_ends_at: commercial.data.trial_ends_at ?? null,
|
||||||
};
|
};
|
||||||
setSub(next);
|
setSub(next);
|
||||||
setMsg(`عملیات ${mode} از Commercial Runtime ثبت شد.`);
|
setMsg(mode === "trial" ? "دوره آزمایشی شما با موفقیت فعال شد." : "پلن شما با موفقیت بهروزرسانی شد.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "عملیات ناموفق بود");
|
setError(friendlyMessage(err instanceof Error ? err.message : null, "انجام این عملیات ممکن نشد. لطفاً دوباره تلاش کنید."));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -111,18 +119,14 @@ function BillingInner() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMsg("");
|
setMsg("");
|
||||||
// Coupons SoT is Commercial Runtime registry — apply via published coupon codes
|
setMsg(`کد تخفیف «${coupon.trim()}» ذخیره شد و هنگام انتخاب پلن اعمال میشود.`);
|
||||||
// attached to pricing activation (coupon_code on subscriptions POST).
|
|
||||||
setMsg(
|
|
||||||
`کد «${coupon.trim()}» را هنگام Trial/فعالسازی ارسال کنید. مدیریت کوپن فقط در Admin Commercial.`
|
|
||||||
);
|
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (meLoading) {
|
if (meLoading) {
|
||||||
return (
|
return (
|
||||||
<PlatformShell title="Billing">
|
<PlatformShell title="اشتراک و پرداخت">
|
||||||
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
<CommercialSkeleton rows={4} />
|
||||||
</PlatformShell>
|
</PlatformShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -131,8 +135,8 @@ function BillingInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformShell
|
<PlatformShell
|
||||||
title="Billing و Subscription"
|
title="اشتراک و پرداخت"
|
||||||
subtitle="قیمت، فاکتور، تراکنش و ارتقا فقط از Commercial Runtime"
|
subtitle="پلن فعلی، دوره آزمایشی، پرداختها و میزان استفاده را یکجا مدیریت کنید."
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
@ -142,28 +146,24 @@ function BillingInner() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} />
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{sub ? (
|
<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="mt-3">
|
<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>
|
||||||
<PlanBadge label={planLabel || "—"} status={sub.status ?? undefined} />
|
<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>
|
||||||
{sub.trial_ends_at ? (
|
<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>
|
||||||
<p className="mt-2 text-xs text-gray-500" dir="ltr">
|
</section>
|
||||||
trial_ends_at: {sub.trial_ends_at}
|
<div className="mt-4"><SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} /></div>
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? <p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p> : null}
|
{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}
|
{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">
|
<section className="mt-8">
|
||||||
<SectionTitle title="کوپن / تخفیف" />
|
<SectionTitle title="کد تخفیف" />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm"
|
className="rounded-xl border border-gray-200 px-3 py-2 text-sm"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
placeholder="COUPON"
|
placeholder="کد تخفیف را وارد کنید"
|
||||||
value={coupon}
|
value={coupon}
|
||||||
onChange={(e) => setCoupon(e.target.value)}
|
onChange={(e) => setCoupon(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -174,15 +174,15 @@ function BillingInner() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<SectionTitle title="کاتالوگ قیمت (Commercial Runtime)" />
|
<SectionTitle title="ارتقا یا تغییر پلن" subtitle="پلن مناسب را انتخاب کنید؛ تغییرات پیش از تأیید نهایی به شما نمایش داده میشود." />
|
||||||
{!pricing || pricing.availability === "loading" ? (
|
{!pricing || pricing.availability === "loading" ? (
|
||||||
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
<CommercialSkeleton rows={3} />
|
||||||
) : pricing.availability !== "ready" || !pricing.data.length ? (
|
) : pricing.availability !== "ready" || !pricing.data.length ? (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="قیمتی در رجیستری نیست"
|
title="پلنی برای خرید آماده نیست"
|
||||||
description="از Admin Commercial → Pricing یک آیتم منتشر کنید."
|
description="لطفاً کمی بعد دوباره بررسی کنید یا با پشتیبانی تماس بگیرید."
|
||||||
actionHref="/admin/commercial/pricing"
|
actionHref="/help"
|
||||||
actionLabel="مدیریت Pricing"
|
actionLabel="تماس با پشتیبانی"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-4 space-y-3">
|
<ul className="mt-4 space-y-3">
|
||||||
@ -193,11 +193,8 @@ function BillingInner() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-secondary">{item.display_name}</p>
|
<p className="font-semibold text-secondary">{item.display_name}</p>
|
||||||
<p className="text-xs text-gray-500" dir="ltr">
|
<p className="mt-1 text-sm text-gray-600">{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}</p>
|
||||||
{item.pricing_item_code}
|
{item.trial_days ? <p className="mt-1 text-xs text-emerald-700">{item.trial_days.toLocaleString("fa-IR")} روز آزمایش رایگان</p> : null}
|
||||||
{item.amount != null ? ` · ${item.amount} ${item.currency || ""}` : ""}
|
|
||||||
{item.trial_days != null ? ` · trial ${item.trial_days}d` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -206,7 +203,7 @@ function BillingInner() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void activatePricing(item, "trial")}
|
onClick={() => void activatePricing(item, "trial")}
|
||||||
>
|
>
|
||||||
Trial
|
آزمایش رایگان
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" disabled={busy} onClick={() => void activatePricing(item, "active")}>
|
<Button size="sm" disabled={busy} onClick={() => void activatePricing(item, "active")}>
|
||||||
فعالسازی
|
فعالسازی
|
||||||
@ -227,46 +224,46 @@ function BillingInner() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<SectionTitle title="Usage" />
|
<SectionTitle title="میزان استفاده" />
|
||||||
{usage?.availability === "ready" && usage.data.length ? (
|
{usage?.availability === "ready" && usage.data.length ? (
|
||||||
<ul className="mt-3 space-y-2 text-sm">
|
<ul className="mt-3 space-y-2 text-sm">
|
||||||
{usage.data.map((u) => (
|
{usage.data.map((u) => (
|
||||||
<li key={u.key} className="rounded-xl bg-gray-50 px-3 py-2" dir="ltr">
|
<li key={u.key} className="rounded-xl bg-gray-50 px-4 py-3">
|
||||||
{u.key}: {u.used ?? 0}/{u.limit ?? "∞"}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500">Usage از Commercial Runtime خالی است.</p>
|
<CommercialEmptyState title="هنوز مصرفی ثبت نشده است" description="پس از شروع استفاده، میزان مصرف امکانات اینجا نمایش داده میشود." />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<SectionTitle title="Invoices / Transactions" />
|
<SectionTitle title="فاکتورها و تاریخچه پرداخت" />
|
||||||
<p className="mb-3 text-xs text-gray-500">
|
|
||||||
لجر مالی متعلق به Payment است؛ Commercial فقط نمای خالی/پروکسی صادقانه برمیگرداند.
|
|
||||||
</p>
|
|
||||||
{invoices?.availability === "ready" && invoices.data.length ? (
|
{invoices?.availability === "ready" && invoices.data.length ? (
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{invoices.data.map((inv) => (
|
{invoices.data.map((inv) => (
|
||||||
<li key={inv.id} dir="ltr">
|
<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">
|
||||||
{inv.id} · {inv.status} · {inv.amount}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500">فاکتوری نیست.</p>
|
<p className="text-sm text-gray-500">هنوز فاکتوری صادر نشده است.</p>
|
||||||
)}
|
)}
|
||||||
{txns?.availability === "ready" && txns.data.length ? (
|
{txns?.availability === "ready" && txns.data.length ? (
|
||||||
<ul className="mt-3 space-y-2 text-sm">
|
<ul className="mt-3 space-y-2 text-sm">
|
||||||
{txns.data.map((t) => (
|
{txns.data.map((t) => (
|
||||||
<li key={t.id} dir="ltr">
|
<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">
|
||||||
{t.id} · {t.status} · {t.amount}
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-sm text-gray-500">تراکنشی نیست.</p>
|
<p className="mt-2 text-sm text-gray-500">هنوز پرداختی انجام نشده است.</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</PlatformShell>
|
</PlatformShell>
|
||||||
|
|||||||
@ -56,6 +56,11 @@ import {
|
|||||||
UpgradePrompt,
|
UpgradePrompt,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { loadAssessmentSession } from "../session";
|
import { loadAssessmentSession } from "../session";
|
||||||
|
import {
|
||||||
|
enumLabel,
|
||||||
|
friendlyMessage,
|
||||||
|
humanizeCommercialCode,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
function CommercialDashboardContent() {
|
function CommercialDashboardContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -140,12 +145,11 @@ function CommercialDashboardContent() {
|
|||||||
void load();
|
void load();
|
||||||
}, [me, meLoading, router, load]);
|
}, [me, meLoading, router, load]);
|
||||||
|
|
||||||
const productList = products?.data ?? [];
|
|
||||||
const byCode = useMemo(() => {
|
const byCode = useMemo(() => {
|
||||||
const m = new Map<string, CommercialProduct>();
|
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;
|
return m;
|
||||||
}, [productList]);
|
}, [products]);
|
||||||
|
|
||||||
const pickProducts = (codes?: string[]) =>
|
const pickProducts = (codes?: string[]) =>
|
||||||
(codes || [])
|
(codes || [])
|
||||||
@ -188,7 +192,7 @@ function CommercialDashboardContent() {
|
|||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
<Link href="/billing">
|
<Link href="/billing">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Billing / ارتقا
|
اشتراک و ارتقا
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/domains">
|
<Link href="/domains">
|
||||||
@ -198,7 +202,7 @@ function CommercialDashboardContent() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link href="/apps">
|
<Link href="/apps">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Apps
|
محصولات
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
@ -215,15 +219,15 @@ function CommercialDashboardContent() {
|
|||||||
|
|
||||||
{(meError || snap?.message) && snap?.availability === "error" ? (
|
{(meError || snap?.message) && snap?.availability === "error" ? (
|
||||||
<p className="mb-6 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600">
|
<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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!ws ? (
|
{!ws ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="Workspace در دسترس نیست"
|
title="فضای کاری شما هنوز آماده نیست"
|
||||||
description={snap?.message}
|
description="راهاندازی اولیه را کامل کنید تا داشبورد شما فعال شود."
|
||||||
actionHref="/onboarding"
|
actionHref="/onboarding"
|
||||||
actionLabel="ادامه راهاندازی"
|
actionLabel="ادامه راهاندازی"
|
||||||
/>
|
/>
|
||||||
@ -238,11 +242,11 @@ function CommercialDashboardContent() {
|
|||||||
{ws.primary_domain || `${ws.slug}.torbatyar.ir`}
|
{ws.primary_domain || `${ws.slug}.torbatyar.ir`}
|
||||||
</p>
|
</p>
|
||||||
{ws.workspace_health ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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} />
|
<PlanBadge label={ws.plan_name} status={ws.subscription_status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -261,7 +265,7 @@ function CommercialDashboardContent() {
|
|||||||
<section>
|
<section>
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
title="فعالسازی"
|
title="فعالسازی"
|
||||||
subtitle={`وضعیت: ${activation.data.status || "—"} · ${
|
subtitle={`وضعیت: ${enumLabel(activation.data.status)} · ${
|
||||||
activation.data.completion_percent ?? "—"
|
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"
|
className="flex items-center justify-between rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{step.label || step.step_key}{" "}
|
{humanizeCommercialCode(step.step_key, step.label)}{" "}
|
||||||
<span className="text-xs text-gray-400">({step.status || "pending"})</span>
|
<span className="text-xs text-gray-400">({enumLabel(step.status || "pending")})</span>
|
||||||
</span>
|
</span>
|
||||||
{step.href ? (
|
{step.href ? (
|
||||||
<Link href={step.href} className="text-xs text-primary">
|
<Link href={step.href} className="text-xs text-primary">
|
||||||
@ -308,7 +312,7 @@ function CommercialDashboardContent() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle title="پیشنهادها" subtitle="از recommendation API" />
|
<SectionTitle title="پیشنهادهای مناسب شما" subtitle="بر اساس نیازها و نحوه فعالیت کسبوکار شما" />
|
||||||
<RecommendationsPanel result={recs} />
|
<RecommendationsPanel result={recs} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -321,9 +325,7 @@ function CommercialDashboardContent() {
|
|||||||
key={f.feature_key}
|
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"
|
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">
|
<span className="text-xs font-medium">{humanizeCommercialCode(f.feature_key)}</span>
|
||||||
{f.feature_key}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs font-semibold text-primary"
|
className="text-xs font-semibold text-primary"
|
||||||
@ -363,7 +365,7 @@ function CommercialDashboardContent() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle title="Apps launcher" />
|
<SectionTitle title="محصولات شما" />
|
||||||
{products?.availability === "ready" && products.data.length ? (
|
{products?.availability === "ready" && products.data.length ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{products.data.map((p) => (
|
{products.data.map((p) => (
|
||||||
@ -373,10 +375,10 @@ function CommercialDashboardContent() {
|
|||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="محصولی کشف نشد"
|
title="محصولی کشف نشد"
|
||||||
description={products?.message}
|
description="محصولات انتخابشده پس از راهاندازی اینجا نمایش داده میشوند."
|
||||||
hint="کاتالوگ از رجیستری تجاری بارگذاری میشود. تا پر شدن، از کشف و راهنما ادامه دهید."
|
hint="برای دریافت پیشنهاد مناسب، ارزیابی کسبوکار را کامل کنید."
|
||||||
actionHref="/apps"
|
actionHref="/apps"
|
||||||
actionLabel="Apps Launcher"
|
actionLabel="مشاهده محصولات"
|
||||||
secondaryHref="/discover"
|
secondaryHref="/discover"
|
||||||
secondaryLabel="کشف کسبوکار"
|
secondaryLabel="کشف کسبوکار"
|
||||||
/>
|
/>
|
||||||
@ -398,8 +400,8 @@ function CommercialDashboardContent() {
|
|||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="قابلیتی نیست"
|
title="قابلیتی نیست"
|
||||||
description={caps?.message}
|
description="امکانات فعال پلن شما پس از انتخاب اشتراک اینجا نمایش داده میشود."
|
||||||
hint="قابلیتها با entitlement و باندل فعال ظاهر میشوند."
|
hint="برای افزودن قابلیتهای بیشتر، پلنها را مقایسه کنید."
|
||||||
actionHref="/billing"
|
actionHref="/billing"
|
||||||
actionLabel="بررسی پلن"
|
actionLabel="بررسی پلن"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
@ -414,16 +416,16 @@ function CommercialDashboardContent() {
|
|||||||
<ul className="mb-3 space-y-1">
|
<ul className="mb-3 space-y-1">
|
||||||
{invoices.data.map((inv) => (
|
{invoices.data.map((inv) => (
|
||||||
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="فاکتور نیست"
|
title="فاکتور نیست"
|
||||||
description={invoices?.message}
|
description="هنوز فاکتوری برای این فضای کاری صادر نشده است."
|
||||||
actionHref="/billing"
|
actionHref="/billing"
|
||||||
actionLabel="Billing"
|
actionLabel="اشتراک و پرداخت"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
secondaryLabel="راهنمای اشتراک"
|
secondaryLabel="راهنمای اشتراک"
|
||||||
/>
|
/>
|
||||||
@ -432,18 +434,18 @@ function CommercialDashboardContent() {
|
|||||||
<ul className="mt-3 space-y-1">
|
<ul className="mt-3 space-y-1">
|
||||||
{licenses.data.map((lic) => (
|
{licenses.data.map((lic) => (
|
||||||
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="لایسنس نیست"
|
title="لایسنس نیست"
|
||||||
description={licenses?.message}
|
description="پس از فعالسازی پلن، مجوزهای شما اینجا نمایش داده میشود."
|
||||||
actionHref="/billing"
|
actionHref="/billing"
|
||||||
actionLabel="فعالسازی پلن"
|
actionLabel="فعالسازی پلن"
|
||||||
secondaryHref="/discover"
|
secondaryHref="/discover"
|
||||||
secondaryLabel="انتخاب باندل"
|
secondaryLabel="انتخاب بسته"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@ -467,8 +469,8 @@ function CommercialDashboardContent() {
|
|||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="اعلانی نیست"
|
title="اعلانی نیست"
|
||||||
description={notifications?.message}
|
description="همه چیز مرتب است؛ اعلان جدیدی ندارید."
|
||||||
hint="اعلانهای Trial، دامنه و Billing اینجا ظاهر میشوند."
|
hint="یادآوریهای دوره آزمایشی، دامنه و پرداخت اینجا ظاهر میشوند."
|
||||||
actionHref="/notifications"
|
actionHref="/notifications"
|
||||||
actionLabel="مرکز اعلانها"
|
actionLabel="مرکز اعلانها"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
@ -488,16 +490,16 @@ function CommercialDashboardContent() {
|
|||||||
<ul className="grid gap-2 sm:grid-cols-2">
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
{ws.trial.quotas.map((q) => (
|
{ws.trial.quotas.map((q) => (
|
||||||
<li key={q.key} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="شمارنده مصرف در دسترس نیست"
|
title="شمارنده مصرف در دسترس نیست"
|
||||||
hint="سهمیههای Trial و usage از API entitlement میآیند."
|
hint="پس از شروع استفاده، میزان مصرف و سقف پلن اینجا نمایش داده میشود."
|
||||||
actionHref="/billing"
|
actionHref="/billing"
|
||||||
actionLabel="Billing / Usage"
|
actionLabel="مشاهده اشتراک"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
secondaryLabel="راهنما"
|
secondaryLabel="راهنما"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -31,7 +31,13 @@ import {
|
|||||||
saveAssessmentSession,
|
saveAssessmentSession,
|
||||||
type AssessmentSession,
|
type AssessmentSession,
|
||||||
} from "../session";
|
} from "../session";
|
||||||
|
import {
|
||||||
|
enumLabel,
|
||||||
|
formatMoney,
|
||||||
|
friendlyMessage,
|
||||||
|
humanizeCommercialCode,
|
||||||
|
recommendationReason,
|
||||||
|
} from "../presentation";
|
||||||
type WizardStep =
|
type WizardStep =
|
||||||
| "assessment"
|
| "assessment"
|
||||||
| "recommendations"
|
| "recommendations"
|
||||||
@ -40,6 +46,20 @@ type WizardStep =
|
|||||||
| "trial_summary"
|
| "trial_summary"
|
||||||
| "create_workspace";
|
| "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({
|
function QuestionField({
|
||||||
question,
|
question,
|
||||||
value,
|
value,
|
||||||
@ -60,7 +80,7 @@ function QuestionField({
|
|||||||
checked={Boolean(value)}
|
checked={Boolean(value)}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
{question.prompt}
|
{businessQuestion(question)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -68,7 +88,7 @@ function QuestionField({
|
|||||||
if (question.input_type === "number") {
|
if (question.input_type === "number") {
|
||||||
return (
|
return (
|
||||||
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
|
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
|
||||||
{question.prompt}
|
{businessQuestion(question)}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
@ -85,7 +105,7 @@ function QuestionField({
|
|||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="text-sm font-medium text-secondary dark:text-gray-100">
|
<legend className="text-sm font-medium text-secondary dark:text-gray-100">
|
||||||
{question.prompt}
|
{businessQuestion(question)}
|
||||||
</legend>
|
</legend>
|
||||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
{question.options.map((opt) => {
|
{question.options.map((opt) => {
|
||||||
@ -107,7 +127,7 @@ function QuestionField({
|
|||||||
} else onChange(opt.value);
|
} else onChange(opt.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{humanizeCommercialCode(opt.value, opt.label)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -118,7 +138,7 @@ function QuestionField({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
|
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
|
||||||
{question.prompt}
|
{businessQuestion(question)}
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
value={typeof value === "string" ? value : ""}
|
value={typeof value === "string" ? value : ""}
|
||||||
@ -131,9 +151,9 @@ function QuestionField({
|
|||||||
const STEPS: { id: WizardStep; label: string }[] = [
|
const STEPS: { id: WizardStep; label: string }[] = [
|
||||||
{ id: "assessment", label: "کشف" },
|
{ id: "assessment", label: "کشف" },
|
||||||
{ id: "recommendations", label: "پیشنهاد" },
|
{ id: "recommendations", label: "پیشنهاد" },
|
||||||
{ id: "bundles", label: "باندل" },
|
{ id: "bundles", label: "بسته مناسب" },
|
||||||
{ id: "pricing", label: "قیمت" },
|
{ id: "pricing", label: "قیمت" },
|
||||||
{ id: "trial_summary", label: "Trial" },
|
{ id: "trial_summary", label: "آزمایش رایگان" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CommercialDiscoverExperience() {
|
export function CommercialDiscoverExperience() {
|
||||||
@ -216,9 +236,9 @@ export function CommercialDiscoverExperience() {
|
|||||||
.map((r) => ({ ...r, product: productByCode.get(r.code) }))
|
.map((r) => ({ ...r, product: productByCode.get(r.code) }))
|
||||||
.filter((r) => r.product);
|
.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"
|
(p) => !recommendedProducts.some((r) => r.code === p.product_code) && p.visibility !== "hidden"
|
||||||
);
|
).slice(0, 3) : [];
|
||||||
|
|
||||||
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
|
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
|
||||||
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
|
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
|
||||||
@ -271,7 +291,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
← بازگشت به خانه
|
← بازگشت به خانه
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="mt-2 text-2xl font-bold text-secondary dark:text-gray-100">
|
<h1 className="mt-2 text-2xl font-bold text-secondary dark:text-gray-100">
|
||||||
کشف و راهاندازی
|
پیشنهاد مناسب کسبوکار
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<ol className="flex flex-wrap gap-2">
|
<ol className="flex flex-wrap gap-2">
|
||||||
@ -297,7 +317,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
<div className="mx-auto max-w-xl">
|
<div className="mx-auto max-w-xl">
|
||||||
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ارزیابی نیازها</h2>
|
<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">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
سؤالات از assessment schema — صنایع و نیازها بدون لیست ثابت frontend.
|
با چند سؤال کوتاه، نیازهای واقعی کسبوکار شما را میشناسیم.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{schemaRes?.availability === "unavailable" ||
|
{schemaRes?.availability === "unavailable" ||
|
||||||
@ -305,8 +325,8 @@ export function CommercialDiscoverExperience() {
|
|||||||
!questions.length ? (
|
!questions.length ? (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="اسکیمای ارزیابی در دسترس نیست"
|
title="فعلاً امکان ارزیابی خودکار نیست"
|
||||||
description={schemaRes?.message}
|
description="کمی بعد دوباره تلاش کنید یا پلنها را مستقیماً ببینید."
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@ -326,7 +346,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
setStep("recommendations");
|
setStep("recommendations");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ادامه با کاتالوگ رجیستری
|
مشاهده پلنها
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -385,30 +405,13 @@ export function CommercialDiscoverExperience() {
|
|||||||
پیشنهاد شخصیسازیشده
|
پیشنهاد شخصیسازیشده
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
محصولات الزامی، اختیاری و مسیر رشد از recommendation + registry.
|
فقط گزینههایی را میبینید که با پاسخهای شما همخوانی دارند.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
|
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
|
||||||
محصولات پیشنهادی / الزامی
|
ضروری برای شروع
|
||||||
</h3>
|
</h3>
|
||||||
{recommendedProducts.length ? (
|
{recommendedProducts.length ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@ -419,16 +422,16 @@ export function CommercialDiscoverExperience() {
|
|||||||
recommendationScore={r.score}
|
recommendationScore={r.score}
|
||||||
/>
|
/>
|
||||||
{r.reason ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="پیشنهاد محصول خالی است"
|
title="هنوز پیشنهاد دقیقی آماده نیست"
|
||||||
description={productsRes?.message || recoRes?.message}
|
description="پاسخها را دوباره بررسی کنید تا پیشنهاد دقیقتری دریافت کنید."
|
||||||
hint="پاسخهای ارزیابی را بررسی کنید یا بعداً وقتی رجیستری پر شد دوباره امتحان کنید."
|
hint="هیچ محصولی بدون تطبیق با نیاز شما پیشنهاد نمیشود."
|
||||||
actionHref="/discover"
|
actionHref="/discover"
|
||||||
actionLabel="تکرار ارزیابی"
|
actionLabel="تکرار ارزیابی"
|
||||||
secondaryHref="/pricing"
|
secondaryHref="/pricing"
|
||||||
@ -440,7 +443,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
{optionalProducts.length ? (
|
{optionalProducts.length ? (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
|
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
|
||||||
محصولات اختیاری / قابل گسترش
|
بعداً اضافه کنید
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{optionalProducts.slice(0, 6).map((p) => (
|
{optionalProducts.slice(0, 6).map((p) => (
|
||||||
@ -456,7 +459,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
<p className="mt-1">{selectedBundleObj.display_name}</p>
|
<p className="mt-1">{selectedBundleObj.display_name}</p>
|
||||||
{selectedBundleObj.upgrade_path_code ? (
|
{selectedBundleObj.upgrade_path_code ? (
|
||||||
<p className="mt-1 text-xs text-primary">
|
<p className="mt-1 text-xs text-primary">
|
||||||
مسیر رشد: {selectedBundleObj.upgrade_path_code}
|
امکان ارتقا در هر زمان
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -466,7 +469,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
<Button variant="outline" onClick={() => setStep("assessment")}>
|
<Button variant="outline" onClick={() => setStep("assessment")}>
|
||||||
بازگشت
|
بازگشت
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("bundles")}>مقایسه باندل</Button>
|
<Button onClick={() => setStep("bundles")}>مقایسه بستهها</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@ -475,9 +478,9 @@ export function CommercialDiscoverExperience() {
|
|||||||
{step === "bundles" ? (
|
{step === "bundles" ? (
|
||||||
<Container className="py-10">
|
<Container className="py-10">
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Starter / Professional / Enterprise از metadata باندل — بدون پلن ثابت.
|
محصولات، امکانات و روش پرداخت هر بسته را در یک نگاه مقایسه کنید.
|
||||||
</p>
|
</p>
|
||||||
{displayBundles.length ? (
|
{displayBundles.length ? (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
@ -493,11 +496,13 @@ export function CommercialDiscoverExperience() {
|
|||||||
setSelectedBundle(b.bundle_code);
|
setSelectedBundle(b.bundle_code);
|
||||||
persist({ answers, selected_bundle_code: b.bundle_code });
|
persist({ answers, selected_bundle_code: b.bundle_code });
|
||||||
}}
|
}}
|
||||||
|
pricingItems={pricingRes?.data || []}
|
||||||
|
products={productsRes?.data || []}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState title="باندلی نیست" description={bundlesRes?.message} />
|
<CommercialEmptyState title="بستهای برای نمایش نیست" description="پس از تکمیل پاسخها، بسته مناسب شما نمایش داده میشود." />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={() => setStep("recommendations")}>
|
<Button variant="outline" onClick={() => setStep("recommendations")}>
|
||||||
@ -514,7 +519,7 @@ export function CommercialDiscoverExperience() {
|
|||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl 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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
آیتمهای قیمت از رجیستری؛ تخفیف و پروموشن از discount_refs / metadata.
|
هزینه ماهانه، سالانه و دوره آزمایشی را شفاف مقایسه کنید.
|
||||||
</p>
|
</p>
|
||||||
{(pricingForBundle.length ? pricingForBundle : pricingRes?.data ?? []).length ? (
|
{(pricingForBundle.length ? pricingForBundle : pricingRes?.data ?? []).length ? (
|
||||||
<ul className="grid gap-3 sm:grid-cols-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
@ -527,27 +532,24 @@ export function CommercialDiscoverExperience() {
|
|||||||
{item.display_name}
|
{item.display_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
{item.pricing_model || "—"} ·{" "}
|
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
||||||
{item.amount_minor != null
|
|
||||||
? `${item.amount_minor} ${item.currency || ""}`
|
|
||||||
: "مبلغ از بکاند"}
|
|
||||||
</p>
|
</p>
|
||||||
{item.trial_days != null ? (
|
{item.trial_days != null ? (
|
||||||
<Badge variant="default" className="mt-2">
|
<Badge variant="default" className="mt-2">
|
||||||
Trial {item.trial_days} روز
|
{item.trial_days} روز آزمایش رایگان
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState title="قیمتگذاری خالی است" description={pricingRes?.message} />
|
<CommercialEmptyState title="قیمت این بسته هنوز اعلام نشده" description="برای دریافت قیمت با فروش تماس بگیرید." />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={() => setStep("bundles")}>
|
<Button variant="outline" onClick={() => setStep("bundles")}>
|
||||||
بازگشت
|
بازگشت
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("trial_summary")}>خلاصه Trial</Button>
|
<Button onClick={() => setStep("trial_summary")}>خلاصه دوره آزمایشی</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@ -556,25 +558,24 @@ export function CommercialDiscoverExperience() {
|
|||||||
{step === "trial_summary" ? (
|
{step === "trial_summary" ? (
|
||||||
<Container className="py-10">
|
<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">
|
<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">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
پس از ثبتنام و ساخت workspace، trial از Billing / commercial subscriptions فعال
|
پس از ثبتنام، فضای کاری و زیردامنه رایگان شما ساخته میشود و میتوانید امکانات انتخابی را امتحان کنید.
|
||||||
میشود — بدون اعداد ساختگی در این صفحه.
|
|
||||||
</p>
|
</p>
|
||||||
{selectedBundle ? (
|
{selectedBundle ? (
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
باندل: <span className="font-semibold">{selectedBundle}</span>
|
بسته: <span className="font-semibold">{selectedBundleObj?.display_name || "بسته پیشنهادی"}</span>
|
||||||
</p>
|
</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">
|
<ul className="space-y-1 text-xs text-gray-500">
|
||||||
<li>✓ ساخت tenant و زیردامنه</li>
|
<li>✓ ساخت فضای کاری و زیردامنه</li>
|
||||||
<li>✓ داشبورد تجاری و لانچر اپها</li>
|
<li>✓ داشبورد و دسترسی به محصولات</li>
|
||||||
<li>✓ دامنه اختصاصی پس از entitlement</li>
|
<li>✓ اتصال دامنه اختصاصی پس از فعالسازی اشتراک</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Button className="w-full" size="lg" onClick={goWorkspace}>
|
<Button className="w-full" size="lg" onClick={goWorkspace}>
|
||||||
ساخت Workspace
|
ساخت فضای کاری
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</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">
|
<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>
|
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ثبتنام / ورود</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
انتخابهای ارزیابی در session میماند و پس از ورود به onboarding منتقل میشود.
|
انتخابهای شما ذخیره میشود و پس از ورود، راهاندازی را از همینجا ادامه میدهید.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { evaluateDomainPolicy, loadWorkspaceSnapshot } from "../adapters";
|
|||||||
import type { AdapterResult, CommercialWorkspaceSnapshot, DomainPolicyResult } from "../types";
|
import type { AdapterResult, CommercialWorkspaceSnapshot, DomainPolicyResult } from "../types";
|
||||||
import { DomainPolicyPanel, FeatureLock, PlatformShell } from "../components";
|
import { DomainPolicyPanel, FeatureLock, PlatformShell } from "../components";
|
||||||
import { CommercialEmptyState } from "../components/CommercialEmptyState";
|
import { CommercialEmptyState } from "../components/CommercialEmptyState";
|
||||||
|
import { friendlyMessage } from "../presentation";
|
||||||
|
import { CommercialSkeleton } from "../components/CommercialSkeleton";
|
||||||
|
|
||||||
function DomainsInner() {
|
function DomainsInner() {
|
||||||
const { me, loading: meLoading } = useMe();
|
const { me, loading: meLoading } = useMe();
|
||||||
@ -45,17 +47,17 @@ function DomainsInner() {
|
|||||||
setMsg("");
|
setMsg("");
|
||||||
try {
|
try {
|
||||||
if (!unlocked) {
|
if (!unlocked) {
|
||||||
setError(policy?.reason || "دامنه اختصاصی بر اساس سیاست قفل است.");
|
setError(friendlyMessage(policy?.reason, "برای اتصال دامنه اختصاصی، ابتدا اشتراک خود را فعال کنید."));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await api.onboarding.updateDomain(snap.data.tenant_id, {
|
await api.onboarding.updateDomain(snap.data.tenant_id, {
|
||||||
custom_domain: customDomain.trim(),
|
custom_domain: customDomain.trim(),
|
||||||
});
|
});
|
||||||
setMsg("دامنه ثبت شد — تأیید DNS را از وضعیت زیرگیری کنید.");
|
setMsg("دامنه ثبت شد. مراحل تأیید DNS را در همین صفحه دنبال کنید.");
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "ثبت دامنه ناموفق بود");
|
setError(friendlyMessage(err instanceof ApiError ? err.message : null, "ثبت دامنه انجام نشد. آدرس را بررسی و دوباره تلاش کنید."));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -64,7 +66,7 @@ function DomainsInner() {
|
|||||||
if (meLoading) {
|
if (meLoading) {
|
||||||
return (
|
return (
|
||||||
<Container className="py-16 text-center">
|
<Container className="py-16 text-center">
|
||||||
<p className="text-sm text-gray-500">در حال بارگذاری…</p>
|
<CommercialSkeleton rows={4} className="mx-auto max-w-3xl" />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -72,13 +74,13 @@ function DomainsInner() {
|
|||||||
const ws = snap?.data;
|
const ws = snap?.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformShell title="دامنه و SSL" subtitle="قبل از اشتراک فقط زیردامنه؛ بعد از entitlement دامنه اختصاصی">
|
<PlatformShell title="دامنه و امنیت" subtitle="در دوره آزمایشی زیردامنه رایگان دارید؛ دامنه اختصاصی با اشتراک فعال قابل اتصال است.">
|
||||||
{!ws ? (
|
{!ws ? (
|
||||||
<CommercialEmptyState title="Workspace نیست" description={snap?.message} actionHref="/onboarding" actionLabel="راهاندازی" />
|
<CommercialEmptyState title="فضای کاری شما هنوز ساخته نشده" description="ابتدا راهاندازی اولیه را کامل کنید تا زیردامنه رایگان شما فعال شود." actionHref="/onboarding" actionLabel="شروع راهاندازی" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<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">
|
<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">
|
<p className="mt-1 font-mono text-lg text-secondary dark:text-gray-100" dir="ltr">
|
||||||
{ws.slug}.torbatyar.ir
|
{ws.slug}.torbatyar.ir
|
||||||
</p>
|
</p>
|
||||||
@ -101,9 +103,9 @@ function DomainsInner() {
|
|||||||
featureKey="custom_domain"
|
featureKey="custom_domain"
|
||||||
fallback={
|
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">
|
<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">
|
<Link href="/billing" className="font-semibold text-primary">
|
||||||
ارتقا در Billing
|
مشاهده پلنها
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -126,7 +128,7 @@ function DomainsInner() {
|
|||||||
</FeatureLock>
|
</FeatureLock>
|
||||||
|
|
||||||
<section>
|
<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 ? (
|
{ws.domains.length ? (
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
{ws.domains.map((d) => (
|
{ws.domains.map((d) => (
|
||||||
@ -137,19 +139,21 @@ function DomainsInner() {
|
|||||||
<span className="font-mono" dir="ltr">
|
<span className="font-mono" dir="ltr">
|
||||||
{d.domain}
|
{d.domain}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{d.domain_type} · {d.is_verified ? "verified" : "pending"}
|
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-gray-600">{d.domain_type === "custom" ? "دامنه اختصاصی" : "زیردامنه"}</span>
|
||||||
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="دامنهای ثبت نشده"
|
title="دامنهای ثبت نشده"
|
||||||
description="زیردامنه workspace بهصورت پیشفرض فعال است. دامنه اختصاصی پس از entitlement اضافه میشود."
|
description="زیردامنه فضای کاری بهصورت خودکار فعال است. با اشتراک فعال میتوانید دامنه اختصاصی اضافه کنید."
|
||||||
hint="وضعیت تأیید DNS و SSL پس از ثبت دامنه اینجا نمایش داده میشود."
|
hint="وضعیت تأیید DNS و SSL پس از ثبت دامنه اینجا نمایش داده میشود."
|
||||||
actionHref="/billing"
|
actionHref="/billing"
|
||||||
actionLabel="بررسی entitlement"
|
actionLabel="مشاهده پلنها"
|
||||||
secondaryHref="/help"
|
secondaryHref="/help"
|
||||||
secondaryLabel="راهنمای دامنه"
|
secondaryLabel="راهنمای دامنه"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,32 +12,33 @@ import {
|
|||||||
} from "../adapters";
|
} from "../adapters";
|
||||||
import { commercialGet, extractItems } from "../adapters/http";
|
import { commercialGet, extractItems } from "../adapters/http";
|
||||||
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
||||||
|
import { enumLabel, formatMoney } from "../presentation";
|
||||||
|
|
||||||
const JOURNEY = [
|
const JOURNEY = [
|
||||||
"کشف نیاز",
|
"کشف نیاز",
|
||||||
"پیشنهاد هوشمند",
|
"پیشنهاد هوشمند",
|
||||||
"باندل و قیمت",
|
"بسته و قیمت",
|
||||||
"Trial رایگان",
|
"آزمایش رایگان",
|
||||||
"Workspace",
|
"فضای کاری",
|
||||||
"تولید",
|
"شروع کار",
|
||||||
];
|
];
|
||||||
|
|
||||||
const FAQ = [
|
const FAQ = [
|
||||||
{
|
{
|
||||||
q: "آیا میتوانم بدون کارت بانکی شروع کنم؟",
|
q: "آیا میتوانم بدون کارت بانکی شروع کنم؟",
|
||||||
a: "بله. پس از ساخت workspace میتوانید دوره آزمایشی را از Billing فعال کنید — جزئیات trial از رجیستری قیمتگذاری میآید.",
|
a: "بله. پس از ساخت فضای کاری میتوانید دوره آزمایشی را بدون کارت بانکی فعال کنید.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "محصولات جدید چطور اضافه میشوند؟",
|
q: "بعداً میتوانم محصول جدید اضافه کنم؟",
|
||||||
a: "با ثبت در رجیستری تجاری Admin. بدون تغییر frontend در کاتالوگ و لانچر ظاهر میشوند.",
|
a: "بله. هر زمان نیازتان بیشتر شد، محصولات و امکانات تازه را از داشبورد اضافه کنید.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "دامنه اختصاصی کی باز میشود؟",
|
q: "دامنه اختصاصی کی باز میشود؟",
|
||||||
a: "بر اساس سیاست دامنه پس از entitlement / اشتراک فعال — تا آن زمان فقط زیردامنه پلتفرم.",
|
a: "پس از فعالشدن اشتراک میتوانید دامنه اختصاصی متصل کنید؛ در دوره آزمایشی زیردامنه رایگان دارید.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "آیا باندل قیمت ثابت دارد؟",
|
q: "آیا قیمتها شفاف هستند؟",
|
||||||
a: "خیر. باندل فقط به pricing_refs ارجاع میدهد؛ مبالغ از کاتالوگ قیمت runtime خوانده میشود.",
|
a: "بله. هزینه ماهانه و سالانه هر پلن پیش از انتخاب بهصورت شفاف نمایش داده میشود.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -98,12 +99,12 @@ export function CommercialMarketingLanding() {
|
|||||||
سیستمعامل کسبوکار شما روی ابر
|
سیستمعامل کسبوکار شما روی ابر
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-xl text-base leading-8 text-sky-100/85 sm:text-lg">
|
<p className="mt-5 max-w-xl text-base leading-8 text-sky-100/85 sm:text-lg">
|
||||||
از کشف نیاز تا داشبورد تولید — محصولات، باندل و قیمت از رجیستری زنده؛ بدون کاتالوگ ثابت.
|
از شناخت نیاز تا شروع کار؛ محصولات، بستهها و قیمتها همیشه شفاف و بهروز هستند.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-9 flex flex-wrap gap-3">
|
<div className="mt-9 flex flex-wrap gap-3">
|
||||||
<Link href="/discover">
|
<Link href="/discover">
|
||||||
<Button size="lg" className="bg-white text-secondary hover:bg-sky-50">
|
<Button size="lg" className="bg-white text-secondary hover:bg-sky-50">
|
||||||
شروع Trial رایگان
|
شروع آزمایش رایگان
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
@ -141,8 +142,7 @@ export function CommercialMarketingLanding() {
|
|||||||
<Container>
|
<Container>
|
||||||
<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="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
|
||||||
نوع کسبوکار از رجیستری میآید؛ ارزیابی چندمرحلهای نیازها را به پیشنهاد محصول و باندل وصل
|
چند سؤال ساده پاسخ دهید تا محصولات و بسته مناسب کسبوکار شما پیشنهاد شود.
|
||||||
میکند.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-2">
|
<div className="mt-8 flex flex-wrap gap-2">
|
||||||
{businessTypes.length ? (
|
{businessTypes.length ? (
|
||||||
@ -169,7 +169,7 @@ export function CommercialMarketingLanding() {
|
|||||||
<Container>
|
<Container>
|
||||||
<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="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
کشف پویا از رجیستری — افزودن محصول در Admin بدون تغییر این صفحه.
|
ابزارهای یکپارچه برای فروش، ارتباط با مشتری، مدیریت و رشد کسبوکار.
|
||||||
</p>
|
</p>
|
||||||
{products.length ? (
|
{products.length ? (
|
||||||
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@ -185,7 +185,7 @@ export function CommercialMarketingLanding() {
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-6 text-sm text-gray-500">
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
کاتالوگ محصول هنوز از API پر نشده — داده جعلی نمایش داده نمیشود.
|
محصولات قابل ارائه بهزودی اینجا نمایش داده میشوند.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
@ -197,23 +197,22 @@ export function CommercialMarketingLanding() {
|
|||||||
<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>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 max-w-2xl text-sm text-gray-600 dark:text-gray-400">
|
||||||
ارزیابی metadata-driven، پیشنهاد runtime، قفل قابلیت، دامنه بر اساس سیاست، Billing و
|
پیشنهاد هوشمند، مدیریت اشتراک، دامنه اختصاصی، گزارش مصرف و دسترسی یکپارچه به محصولات.
|
||||||
لانچر اپها — همه از Core Commercial.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
صنایع نامحدود از business-types registry — نه لیست ثابت frontend.
|
راهکارهای متناسب با مدل کاری و اندازه کسبوکار شما.
|
||||||
</p>
|
</p>
|
||||||
{!businessTypes.length ? (
|
{!businessTypes.length ? (
|
||||||
<p className="mt-4 text-sm text-gray-500">انواع کسبوکار از API در دسترس نیست.</p>
|
<p className="mt-4 text-sm text-gray-500">فهرست صنایع در حال بهروزرسانی است.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 ? (
|
{bundles.length ? (
|
||||||
<ul className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{bundles.slice(0, 6).map((b) => (
|
{bundles.slice(0, 6).map((b) => (
|
||||||
@ -222,7 +221,7 @@ export function CommercialMarketingLanding() {
|
|||||||
{b.display_name}
|
{b.display_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{b.description || "باندل تجاری از رجیستری"}
|
{b.description || "مجموعهای کاربردی برای شروع سریعتر"}
|
||||||
</p>
|
</p>
|
||||||
{b.recommendation_badge ? (
|
{b.recommendation_badge ? (
|
||||||
<Badge variant="primary" className="mt-2">
|
<Badge variant="primary" className="mt-2">
|
||||||
@ -233,10 +232,10 @@ export function CommercialMarketingLanding() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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 href="/discover" className="mt-6 inline-block text-sm font-semibold text-primary">
|
||||||
مقایسه و انتخاب باندل →
|
مقایسه و انتخاب بسته →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -250,16 +249,13 @@ export function CommercialMarketingLanding() {
|
|||||||
{item.display_name}
|
{item.display_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
{item.pricing_model || "—"} ·{" "}
|
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
||||||
{item.amount_minor != null
|
|
||||||
? `${item.amount_minor} ${item.currency || ""}`
|
|
||||||
: "مبلغ از بکاند"}
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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">
|
<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>
|
<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">
|
<ul className="mt-4 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
<li>چندمستأجری و SSO یکپارچه</li>
|
<li>چندمستأجری و SSO یکپارچه</li>
|
||||||
<li>سیاست دامنه و entitlement قابل ممیزی</li>
|
<li>دامنه اختصاصی و گواهی SSL</li>
|
||||||
<li>رجیستری نسخهدار محصولات و قیمت</li>
|
<li>قیمتگذاری شفاف و قابل ارتقا</li>
|
||||||
<li>بدون کاتالوگ جعلی در frontend</li>
|
<li>پشتیبانی از رشد چندشعبهای</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -309,10 +305,10 @@ export function CommercialMarketingLanding() {
|
|||||||
</Container>
|
</Container>
|
||||||
<Container className="mt-14 text-center">
|
<Container className="mt-14 text-center">
|
||||||
<h2 className="text-3xl font-extrabold text-secondary dark:text-gray-100">
|
<h2 className="text-3xl font-extrabold text-secondary dark:text-gray-100">
|
||||||
آماده ساخت workspace هستید؟
|
آماده ساخت فضای کاری هستید؟
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-3 max-w-lg text-sm text-gray-600 dark:text-gray-400">
|
<p className="mx-auto mt-3 max-w-lg text-sm text-gray-600 dark:text-gray-400">
|
||||||
ارزیابی را شروع کنید، باندل را انتخاب کنید، trial بگیرید و وارد داشبورد تجاری شوید.
|
ارزیابی را شروع کنید، بسته مناسب را انتخاب کنید و رایگان وارد داشبورد شوید.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||||
<Link href="/discover">
|
<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",
|
id: "branding",
|
||||||
label: "بررسی برندینگ workspace",
|
label: "بررسی ظاهر فضای کاری",
|
||||||
href: "/dashboard/settings",
|
href: "/dashboard/settings",
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: "تأیید trial / اشتراک",
|
label: "تأیید دوره آزمایشی یا اشتراک",
|
||||||
href: "/billing",
|
href: "/billing",
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
@ -112,16 +112,16 @@ function SetupWizardInner() {
|
|||||||
return (
|
return (
|
||||||
<PlatformShell
|
<PlatformShell
|
||||||
title={product ? `راهاندازی ${product.display_name}` : "راهاندازی محصول"}
|
title={product ? `راهاندازی ${product.display_name}` : "راهاندازی محصول"}
|
||||||
subtitle="ویزارد اولین اجرا — گامها از checklist / metadata رجیستری"
|
subtitle="مراحل ساده برای آمادهکردن محصول و شروع استفاده"
|
||||||
>
|
>
|
||||||
{loading ? <CommercialSkeleton rows={4} /> : null}
|
{loading ? <CommercialSkeleton rows={4} /> : null}
|
||||||
|
|
||||||
{!loading && !product ? (
|
{!loading && !product ? (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="محصول در رجیستری نیست"
|
title="این محصول هنوز آماده راهاندازی نیست"
|
||||||
description={
|
description={
|
||||||
productsAvail === "unavailable"
|
productsAvail === "unavailable"
|
||||||
? "رجیستری محصول در دسترس نیست."
|
? "اطلاعات این محصول فعلاً در دسترس نیست."
|
||||||
: `کد ${productCode} پیدا نشد.`
|
: `کد ${productCode} پیدا نشد.`
|
||||||
}
|
}
|
||||||
actionHref="/apps"
|
actionHref="/apps"
|
||||||
@ -174,7 +174,7 @@ function SetupWizardInner() {
|
|||||||
) : (
|
) : (
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title="گام راهاندازی تعریف نشده"
|
title="گام راهاندازی تعریف نشده"
|
||||||
description="Admin میتواند setup_steps را در metadata محصول ثبت کند."
|
description="مراحل راهاندازی این محصول هنوز آماده نشده است."
|
||||||
actionHref={launch || "/apps"}
|
actionHref={launch || "/apps"}
|
||||||
actionLabel="باز کردن محصول"
|
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