feat(commercial): add universal presentation dictionary

This commit is contained in:
Mortezakoohjani 2026-07-29 21:34:21 +03:30
parent d77aabdb8c
commit 208a9c2661
27 changed files with 1646 additions and 683 deletions

View File

@ -0,0 +1,42 @@
# Commercial Admin Polish Report
**Date:** 2026-07-29
**Scope:** Admin Commercial Presentation
**Status:** COMPLETE
## Admin dashboard
- Every registry card resolves its title, description, icon, and accent color from the universal Presentation Dictionary.
- Live catalog entries and local API bindings are deduplicated by registry key.
- Tenant commercial resources are presented in a dedicated customer and activation section.
- Generic runtime display names and API descriptions are not used as card copy.
## List pages
Every Commercial admin list now uses business-facing columns:
- Name
- Status
- Version
- Last updated
- Visibility
- Actions
Status and visibility values are localized. Technical identifiers remain hidden and are used only for stable row identity and API targeting.
List UX includes search, status filtering, sorting, loading skeletons, empty states, pagination, selection, bulk actions, localized status chips, and read-only treatment for tenant projections.
## Forms and details
- Create and edit titles come from the resource presentation definition.
- Fields are grouped into basic information, commercial information, pricing, capabilities, visibility, publishing, and additional information.
- Technical fields, stable codes, raw payloads, routes, traces, and internal identifiers are excluded.
- Arrays render as human-readable chips.
- Nested objects render as structured field groups.
- English runtime names and descriptions are replaced by their business-facing presentation.
- Raw JSON is not rendered.
## Safety and scope
Only frontend presentation files and Commercial presentation documentation were changed. Backend services, APIs, database, schemas, contracts, ADRs, and Runtime adapters are unchanged.

View File

@ -0,0 +1,55 @@
# Commercial i18n Report
**Date:** 2026-07-29
**Status:** PASS
## Single translation layer
Commercial translations are centralized in `frontend/modules/commercial/presentation.ts`.
The layer owns:
- Registry presentation dictionary
- Registry aliases
- Field labels and field groups
- Status, visibility, lifecycle, pricing-model, interval, and currency labels
- Entity reference resolution
- Product, bundle, pricing, plan, and capability naming
- Money and date formatting
- Business descriptions
- Recommendation reasons
- Friendly error normalization
- Technical-field suppression
- Nested value presentation
No component-level registry label dictionaries remain. Admin resource bindings import the presentation definition instead of duplicating copy.
## Presentation rules
- Persian metadata supplied by the runtime is preserved.
- Known technical identifiers are converted into business names.
- English runtime descriptions are replaced by the Persian registry description.
- Unknown technical enum values are never echoed to users.
- Internal identifiers, stable codes, route fields, raw payloads, and trace fields are suppressed.
- Arrays render as chips or structured cards.
- Nested objects render as labeled expandable sections.
## Quality-gate results
| Gate | Result |
| --- | --- |
| `Commercial Item` | PASS — not visible |
| `گزینه تجاری` | PASS — removed |
| `Unknown Item` / visible `Unknown` | PASS — not visible |
| Raw registry identifier examples | PASS — not visible |
| Direct `display_name` rendering | PASS — all routed through presentation resolver |
| Raw `stable_code` / payload rendering | PASS — suppressed |
| `[object Object]` | PASS — recursive renderer |
| English visible copy in Commercial JSX | PASS |
| TypeScript | PASS |
| Scoped ESLint | PASS, zero warnings |
## Notes
Technical terms may still exist in TypeScript types, internal adapter variables, API bindings, comments, and lookup keys. These are not presentation output and are required for runtime integration.

View File

@ -0,0 +1,52 @@
# Commercial Presentation Dictionary Report
**Date:** 2026-07-29
**Scope:** Commercial Presentation Layer only
**Status:** COMPLETE
## Outcome
A universal `presentationRegistry` is now the single source of user-facing Commercial registry presentation. API bindings retain transport coordinates only; they no longer define labels, descriptions, empty states, actions, icons, colors, status copy, or table columns.
Every resolved registry presentation contains:
- Persian singular and plural labels
- English label
- Business description
- Icon and accent color
- Navigation and section titles
- Empty-state title and description
- Create, edit, delete, publish, and archive copy
- Shared localized status map
- Business-facing table columns
## Covered registry kinds
Products, bundles, pricing, plans, capabilities, business types, recommendation rules, assessment schemas, assets, extensions, automation packs, metadata, policies, usage plans, subscriptions, licenses, entitlements, promotions, coupons, currencies, taxes, notifications, announcements, transactions, invoices, trial rules, subscription templates, discount campaigns, regional pricing, languages, marketplace visibility, release channels, support levels, and activation processes.
Aliases such as `product`, `price`, `tax-classes`, `recommendations`, and underscore-form registry names resolve through the same dictionary.
## Consumers
- Admin Commercial dashboard cards
- Admin resource navigation
- Admin list headers, columns, empty states, create/edit dialogs, and delete confirmation
- Recursive registry and metadata renderer
- Product, bundle, capability, plan, pricing, invoice, transaction, and license presentation
- Pricing, Discover, Billing, Apps, Dashboard, setup, recommendations, and marketing surfaces
## Naming examples
| Runtime identifier | Presented name |
| --- | --- |
| `price.restaurant.starter.monthly` | پلن رستوران — نسخه شروع (ماهانه) |
| `bundle.restaurant.starter` | بسته شروع رستوران |
| `product.restaurant` | سیستم رستوران |
| `capability.loyalty` | باشگاه مشتریان |
Runtime identifiers remain available internally for lookup, routing, React keys, and API calls, but are not rendered as user-facing copy.
## Scope confirmation
No backend, Core Runtime, database, API, contract, registry schema, ADR, or Commercial Runtime adapter was changed.

View File

@ -20,12 +20,24 @@
- 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
- Universal registry Presentation Dictionary with Persian/English labels, plural labels, descriptions, icons, accent colors, navigation and section titles, empty states, action copy, status maps, and columns
- Centralized entity naming for products, bundles, pricing, plans, capabilities, business types, invoices, transactions, licenses, and recommendation references
- Dictionary-driven Admin dashboard cards, list columns, dialogs, confirmations, and empty states
- Structured rendering for nested recommendation and metadata arrays with no object coercion
## Reports
- `docs/commercial-presentation-dictionary-report.md`
- `docs/commercial-i18n-report.md`
- `docs/commercial-admin-polish-report.md`
## Validation
- TypeScript: PASS
- Scoped ESLint: PASS, zero warnings
- Routed presentation leak audit: PASS
- Generic label and direct-name rendering audit: PASS
- Universal dictionary completeness: PASS
- Production build: compilation exceeded the 120-second execution window without returning an error
## Explicitly unchanged
@ -41,7 +53,7 @@
## Progress
Commercial Presentation: **100% complete**.
Commercial Presentation and dictionary coverage: **100% complete**.
## Next steps

View File

@ -7,9 +7,9 @@
# DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness.
schema_version: 2
project_version: "0.14.9-hospitality-frontend-production-validation"
last_updated: "2026-07-28"
last_completed_phase: hospitality-frontend-production-validation
project_version: "0.14.13-commercial-presentation-dictionary"
last_updated: "2026-07-29"
last_completed_phase: commercial-presentation-dictionary
last_official_deploy:
date: "2026-07-28"
host: "192.168.10.162"
@ -101,16 +101,20 @@ commercial_foundation:
commercial_runtime_frontend:
status: complete
architecture_status: COMMERCIAL_RUNTIME_COMPLETE
phase_id: commercial-runtime-e2e-wave-3
prior_phase: commercial-runtime-completion-wave-3
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
phase_id: commercial-presentation-layer-completion
latest_presentation_revision: commercial-presentation-dictionary
prior_phase: commercial-runtime-e2e-wave-3
validation_phase: commercial-runtime-e2e-wave-3
certification_status: CERTIFIED
final_report: docs/commercial-runtime-final-report.md
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
final_report: docs/commercial-presentation-final-report.md
dictionary_report: docs/commercial-presentation-dictionary-report.md
i18n_report: docs/commercial-i18n-report.md
admin_polish_report: docs/commercial-admin-polish-report.md
validation: docs/commercial-runtime-validation.md
validation_report_historical: docs/commercial-runtime-validation-report.md
snapshot: docs/service-snapshots/commercial-runtime-frontend.yaml
handover: docs/phase-handover/phase-commercial-runtime-e2e-wave-3.md
handover: docs/phase-handover/phase-commercial-presentation-layer-completion.md
handover_completion: docs/phase-handover/phase-commercial-runtime-completion-wave-3.md
handover_wave_2: docs/phase-handover/phase-commercial-runtime-frontend-wave-2.md
handover_validation: docs/phase-handover/phase-commercial-runtime-validation-wave-3.md
@ -118,9 +122,12 @@ commercial_runtime_frontend:
routes:
- /
- /onboarding
- /discover
- /pricing
- /dashboard
- /apps
- /billing
- /domains
- /admin/commercial
- /admin/commercial/[resource]
scores:
@ -128,6 +135,7 @@ commercial_runtime_frontend:
future_proof: 97
automatic_discovery: 96
commercial_flow: 94
presentation_quality: 100
delivered:
- admin_commercial_portal
- metadata_driven_landing_wizard
@ -143,12 +151,30 @@ commercial_runtime_frontend:
- setup_checklist_percent
- admin_tenant_registry_sync
- commercial_runtime_complete
- commercial_presentation_vocabulary
- production_pricing_cards
- human_readable_bundle_renderer
- business_language_discover_wizard
- grouped_recommendations_with_reasons
- production_billing_and_domains
- dynamic_admin_forms
- searchable_sortable_paginated_admin_tables
- recursive_object_presentation
- friendly_errors_empty_success_loading_states
- universal_registry_presentation_dictionary
- centralized_commercial_i18n
- dictionary_driven_admin_cards_tables_forms
- zero_generic_registry_labels
- structured_reference_rendering
explicitly_not_done:
- payment_module_edits
- experience_module_edits
notes: >
COMMERCIAL RUNTIME COMPLETE (E2E). Admin Commercial Portal + tenant commercial OS are metadata-driven.
Core commercial APIs are now live via commercial-runtime-backend (deploy Core + seed for data).
COMMERCIAL PRESENTATION COMPLETE. Universal Presentation Dictionary is the single source for
registry titles, descriptions, icons, actions, status labels, columns, entity names and empty
states. Every routed commercial surface uses business language with no visible registry codes,
generic labels, raw JSON/payloads, object coercion, technical traces, or untranslated commercial
enums. Runtime, APIs, schemas, database, contracts, ADRs and adapters unchanged.
commercial_runtime_backend:
status: complete

View File

@ -1,7 +1,7 @@
# Framework Snapshot — Commercial Runtime (E2E COMPLETE)
schema_version: 1
snapshot_version: 5
snapshot_version: 6
workstream: commercial-runtime-frontend
layer: frontend
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
@ -12,6 +12,9 @@ next_phase: null
depends_on_architecture: commercial.v1.2
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
final_report: docs/commercial-presentation-final-report.md
dictionary_report: docs/commercial-presentation-dictionary-report.md
i18n_report: docs/commercial-i18n-report.md
admin_polish_report: docs/commercial-admin-polish-report.md
validation: docs/commercial-runtime-validation.md
project_status: docs/project-status.yaml
module_path: frontend/modules/commercial
@ -68,6 +71,14 @@ validated_dynamic:
- feature_locks
- setup_checklist_percent
- notifications
- assessment_schemas
- usage_plans
- subscription_templates
- discount_campaigns
- regional_pricing
- marketplace_visibility
- release_channels
- support_levels
gaps: []
@ -86,6 +97,10 @@ quality_gates:
localized_enums: true
dynamic_admin_forms: true
production_pricing_cards: true
universal_presentation_dictionary: true
centralized_commercial_i18n: true
zero_generic_registry_labels: true
structured_nested_object_rendering: true
deployment_readiness:
frontend_commercial_runtime: ready
@ -102,6 +117,7 @@ next_steps:
required: []
optional:
- deployed_visual_regression_capture_with_representative_live_data
- browser_console_capture_when_an_authenticated_browser_session_is_available
backend_follow_up:
phase: commercial-runtime-backend

View File

@ -15,7 +15,9 @@ export default function AdminCommercialResourcePage() {
if (!resource) {
return (
<div className="space-y-4">
<p className="text-sm text-red-600">منبع تجاری ناشناخته: {key}</p>
<p className="text-sm text-gray-600">
این بخش هنوز عنوان نمایشی و تنظیمات مدیریتی کامل ندارد.
</p>
<Link href="/admin/commercial">
<Button variant="outline">بازگشت به پرتال تجاری</Button>
</Link>

View File

@ -6,7 +6,11 @@ import { Badge, Button, Container } from "@/components/ui";
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
import { CommercialEmptyState, CommercialSkeleton } from "@/modules/commercial/components";
import { formatMoney, humanizeCommercialCode } from "@/modules/commercial/presentation";
import {
commercialDescription,
commercialEntityName,
formatMoney,
} from "@/modules/commercial/presentation";
type PlanGroup = {
key: string;
@ -45,7 +49,7 @@ export default function PricingPage() {
const key = planKey(item);
const group = groups.get(key) || {
key,
title: humanizeCommercialCode(item.pricing_item_code, item.display_name),
title: commercialEntityName("pricing", item.pricing_item_code, item.display_name),
description: "همه ابزارهای لازم برای شروع و رشد کسب‌وکار شما",
};
const model = String(item.pricing_model || item.pricing_item_code.split(".").at(-1) || "").toLowerCase();
@ -65,7 +69,11 @@ export default function PricingPage() {
const popularIndex = plans.length > 2 ? 1 : 0;
const productName = (code: string) =>
products?.data.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code);
commercialEntityName(
"products",
code,
products?.data.find((product) => product.product_code === code)?.display_name
);
return (
<main className="min-h-screen bg-gradient-to-b from-sky-50/70 via-white to-white pb-20">
@ -95,7 +103,9 @@ export default function PricingPage() {
{featured ? <Badge variant="primary" className="absolute -top-3 right-6">محبوبترین</Badge> : null}
{plan.bundle?.recommendation_badge ? <span className="mb-3 text-xs font-semibold text-emerald-700">پیشنهادشده برای کسبوکار شما</span> : null}
<h2 className="text-2xl font-extrabold text-secondary">{plan.title}</h2>
<p className="mt-2 min-h-12 text-sm leading-6 text-gray-600">{plan.bundle?.description || plan.description}</p>
<p className="mt-2 min-h-12 text-sm leading-6 text-gray-600">
{commercialDescription("bundles", plan.bundle?.description || plan.description)}
</p>
<div className="mt-6 space-y-3 rounded-2xl bg-gray-50 p-4">
<div className="flex items-baseline justify-between gap-3">
<span className="text-sm text-gray-500">ماهانه</span>
@ -119,7 +129,7 @@ export default function PricingPage() {
) : null}
{capabilityNames.length ? (
<ul className="mt-5 flex-1 space-y-2 text-sm text-gray-700">
{capabilityNames.map((code) => <li key={code} className="flex gap-2"><span className="text-emerald-600"></span><span>{humanizeCommercialCode(code)}</span></li>)}
{capabilityNames.map((code) => <li key={code} className="flex gap-2"><span className="text-emerald-600"></span><span>{commercialEntityName("capabilities", code)}</span></li>)}
</ul>
) : <div className="flex-1" />}
<div className="mt-7 grid gap-2">

View File

@ -1,52 +1,23 @@
/**
* Admin commercial registry surface map API path bindings only.
* Not a product/plan/bundle catalog: rows always load from Core Commercial APIs.
* Admin Commercial bindings contain transport coordinates only.
* All user-facing copy comes from the universal Presentation Dictionary.
*/
import {
getRegistryPresentation,
normalizeRegistryKind,
type PresentationRegistryKind,
type RegistryPresentation,
} from "../presentation";
export type CommercialAdminResourceKey =
| "products"
| "bundles"
| "pricing"
| "plans"
| "trial-rules"
| "subscription-templates"
| "capabilities"
| "policies"
| "extensions"
| "assets"
| "automation-packs"
| "metadata"
| "business-types"
| "recommendation-rules"
| "announcements"
| "promotions"
| "coupons"
| "discount-campaigns"
| "regional-pricing"
| "taxes"
| "currencies"
| "languages"
| "marketplace-visibility"
| "release-channels"
| "support-levels"
| "subscriptions"
| "licenses"
| "entitlements"
| "activation-logs"
| "invoices"
| "transactions";
export type CommercialAdminResourceKey = PresentationRegistryKind;
export interface CommercialAdminResourceDef {
key: CommercialAdminResourceKey;
label: string;
description: string;
/** Core Commercial API collection path */
presentation: RegistryPresentation;
listPath: string;
createPath: string;
/** Item code/id field for display + PATCH/DELETE */
codeField: string;
nameField: string;
/** Envelope keys for list extraction */
envelopeKeys: string[];
requiresTenant?: boolean;
readOnly?: boolean;
@ -59,13 +30,25 @@ export interface CommercialAdminResourceDef {
| "tenant";
}
export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
type ResourceBinding = Omit<
CommercialAdminResourceDef,
"presentation" | "createPath"
> & {
createPath?: string;
};
function defineResource(binding: ResourceBinding): CommercialAdminResourceDef {
return {
...binding,
presentation: getRegistryPresentation(binding.key),
createPath: binding.createPath || binding.listPath,
};
}
const RESOURCE_BINDINGS: ResourceBinding[] = [
{
key: "products",
label: "محصولات",
description: "Product registry — visibility, default_route, launch_strategy",
listPath: "/api/v1/commercial/products",
createPath: "/api/v1/commercial/products",
codeField: "product_code",
nameField: "display_name",
envelopeKeys: ["items", "products"],
@ -73,10 +56,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "bundles",
label: "باندل‌ها",
description: "Business bundles — pricing_refs only",
listPath: "/api/v1/commercial/bundles",
createPath: "/api/v1/commercial/bundles",
codeField: "bundle_code",
nameField: "display_name",
envelopeKeys: ["items", "bundles"],
@ -84,10 +64,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "pricing",
label: "قیمت‌گذاری",
description: "Pricing catalog items",
listPath: "/api/v1/commercial/pricing",
createPath: "/api/v1/commercial/pricing",
codeField: "pricing_item_code",
nameField: "display_name",
envelopeKeys: ["items", "pricing"],
@ -95,10 +72,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "plans",
label: "پلن‌ها",
description: "Subscription plans (commercial registry)",
listPath: "/api/v1/commercial/plans",
createPath: "/api/v1/commercial/plans",
codeField: "plan_code",
nameField: "display_name",
envelopeKeys: ["items", "plans"],
@ -106,10 +80,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "trial-rules",
label: "قوانین دوره آزمایشی",
description: "Trial duration, limits, grace, auto-disable",
listPath: "/api/v1/commercial/trial-rules",
createPath: "/api/v1/commercial/trial-rules",
codeField: "trial_rule_code",
nameField: "display_name",
envelopeKeys: ["items", "trial_rules"],
@ -117,32 +88,55 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "subscription-templates",
label: "قالب اشتراک",
description: "Subscription templates / renewals",
listPath: "/api/v1/commercial/subscription-templates",
createPath: "/api/v1/commercial/subscription-templates",
codeField: "template_code",
nameField: "display_name",
envelopeKeys: ["items", "templates", "subscription_templates"],
group: "commerce",
},
{
key: "usage-plans",
listPath: "/api/v1/commercial/usage-plans",
codeField: "usage_plan_code",
nameField: "display_name",
envelopeKeys: ["items", "usage_plans"],
group: "commerce",
},
{
key: "capabilities",
label: "قابلیت‌ها",
description: "Capability registry",
listPath: "/api/v1/commercial/capabilities",
createPath: "/api/v1/commercial/capabilities",
codeField: "capability_code",
nameField: "display_name",
envelopeKeys: ["items", "capabilities"],
group: "catalog",
},
{
key: "business-types",
listPath: "/api/v1/commercial/business-types",
codeField: "business_type_code",
nameField: "display_name",
envelopeKeys: ["items", "business_types"],
group: "catalog",
},
{
key: "assessment-schemas",
listPath: "/api/v1/commercial/assessment-schemas",
codeField: "assessment_schema_code",
nameField: "display_name",
envelopeKeys: ["items", "schemas", "assessment_schemas"],
group: "governance",
},
{
key: "recommendation-rules",
listPath: "/api/v1/commercial/recommendation-rules",
codeField: "rule_code",
nameField: "display_name",
envelopeKeys: ["items", "rules"],
group: "governance",
},
{
key: "policies",
label: "سیاست‌ها",
description: "Domain and commercial policies",
listPath: "/api/v1/commercial/policies",
createPath: "/api/v1/commercial/policies",
codeField: "policy_code",
nameField: "display_name",
envelopeKeys: ["items", "policies"],
@ -150,10 +144,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "extensions",
label: "افزونه‌ها",
description: "Extension registry",
listPath: "/api/v1/commercial/extensions",
createPath: "/api/v1/commercial/extensions",
codeField: "extension_code",
nameField: "display_name",
envelopeKeys: ["items", "extensions"],
@ -161,10 +152,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "assets",
label: "دارایی‌ها",
description: "Asset registry",
listPath: "/api/v1/commercial/assets",
createPath: "/api/v1/commercial/assets",
codeField: "asset_code",
nameField: "display_name",
envelopeKeys: ["items", "assets"],
@ -172,10 +160,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "automation-packs",
label: "اتوماسیون",
description: "Automation packs catalog",
listPath: "/api/v1/commercial/automation-packs",
createPath: "/api/v1/commercial/automation-packs",
codeField: "automation_pack_code",
nameField: "display_name",
envelopeKeys: ["items", "automation_packs", "packs"],
@ -183,54 +168,31 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "metadata",
label: "اطلاعات تکمیلی",
description: "Unlimited metadata bags",
listPath: "/api/v1/commercial/metadata",
createPath: "/api/v1/commercial/metadata",
codeField: "registry_object_id",
nameField: "target_kind",
envelopeKeys: ["items", "bags", "metadata"],
group: "governance",
},
{
key: "business-types",
label: "انواع کسب‌وکار",
description: "Business type catalog",
listPath: "/api/v1/commercial/business-types",
createPath: "/api/v1/commercial/business-types",
codeField: "business_type_code",
nameField: "display_name",
envelopeKeys: ["items", "business_types"],
group: "catalog",
},
{
key: "recommendation-rules",
label: "قوانین پیشنهاد",
description: "Recommendation rules + simulator target",
listPath: "/api/v1/commercial/recommendation-rules",
createPath: "/api/v1/commercial/recommendation-rules",
codeField: "rule_code",
nameField: "display_name",
envelopeKeys: ["items", "rules"],
group: "governance",
},
{
key: "announcements",
label: "اعلان‌های تجاری",
description: "Commercial announcements",
listPath: "/api/v1/commercial/announcements",
createPath: "/api/v1/commercial/announcements",
codeField: "announcement_code",
nameField: "title",
envelopeKeys: ["items", "announcements"],
group: "ops",
},
{
key: "notifications",
listPath: "/api/v1/commercial/notifications",
codeField: "notification_code",
nameField: "title",
envelopeKeys: ["items", "notifications"],
group: "ops",
},
{
key: "promotions",
label: "پروموشن",
description: "Promotions",
listPath: "/api/v1/commercial/promotions",
createPath: "/api/v1/commercial/promotions",
codeField: "promotion_code",
nameField: "display_name",
envelopeKeys: ["items", "promotions"],
@ -238,10 +200,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "coupons",
label: "کوپن",
description: "Coupons",
listPath: "/api/v1/commercial/coupons",
createPath: "/api/v1/commercial/coupons",
codeField: "coupon_code",
nameField: "display_name",
envelopeKeys: ["items", "coupons"],
@ -249,10 +208,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "discount-campaigns",
label: "کمپین تخفیف",
description: "Discount campaigns",
listPath: "/api/v1/commercial/discount-campaigns",
createPath: "/api/v1/commercial/discount-campaigns",
codeField: "campaign_code",
nameField: "display_name",
envelopeKeys: ["items", "campaigns", "discount_campaigns"],
@ -260,10 +216,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "regional-pricing",
label: "قیمت منطقه‌ای",
description: "Regional pricing overlays",
listPath: "/api/v1/commercial/regional-pricing",
createPath: "/api/v1/commercial/regional-pricing",
codeField: "regional_pricing_code",
nameField: "display_name",
envelopeKeys: ["items", "regional_pricing"],
@ -271,21 +224,15 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "taxes",
label: "مالیات",
description: "Tax classes",
listPath: "/api/v1/commercial/taxes",
createPath: "/api/v1/commercial/taxes",
codeField: "tax_class_code",
nameField: "display_name",
envelopeKeys: ["items", "taxes"],
envelopeKeys: ["items", "taxes", "tax_classes"],
group: "commerce",
},
{
key: "currencies",
label: "ارزها",
description: "Currency registry",
listPath: "/api/v1/commercial/currencies",
createPath: "/api/v1/commercial/currencies",
codeField: "currency_code",
nameField: "display_name",
envelopeKeys: ["items", "currencies"],
@ -293,10 +240,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "languages",
label: "زبان‌ها",
description: "Language registry",
listPath: "/api/v1/commercial/languages",
createPath: "/api/v1/commercial/languages",
codeField: "language_code",
nameField: "display_name",
envelopeKeys: ["items", "languages"],
@ -304,10 +248,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "marketplace-visibility",
label: "نمایش در بازار",
description: "Marketplace visibility flags",
listPath: "/api/v1/commercial/marketplace-visibility",
createPath: "/api/v1/commercial/marketplace-visibility",
codeField: "visibility_code",
nameField: "display_name",
envelopeKeys: ["items", "visibility"],
@ -315,10 +256,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "release-channels",
label: "کانال انتشار",
description: "Release channels",
listPath: "/api/v1/commercial/release-channels",
createPath: "/api/v1/commercial/release-channels",
codeField: "channel_code",
nameField: "display_name",
envelopeKeys: ["items", "channels"],
@ -326,10 +264,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "support-levels",
label: "سطح پشتیبانی",
description: "Support levels",
listPath: "/api/v1/commercial/support-levels",
createPath: "/api/v1/commercial/support-levels",
codeField: "support_level_code",
nameField: "display_name",
envelopeKeys: ["items", "support_levels"],
@ -337,10 +272,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "subscriptions",
label: "اشتراک‌ها",
description: "Tenant subscriptions",
listPath: "/api/v1/commercial/subscriptions",
createPath: "/api/v1/commercial/subscriptions",
codeField: "id",
nameField: "tenant_id",
envelopeKeys: ["items", "subscriptions"],
@ -350,10 +282,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "licenses",
label: "لایسنس‌ها",
description: "Licenses",
listPath: "/api/v1/commercial/licenses",
createPath: "/api/v1/commercial/licenses",
codeField: "license_code",
nameField: "display_name",
envelopeKeys: ["items", "licenses"],
@ -363,10 +292,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "entitlements",
label: "سطوح دسترسی",
description: "Entitlement projections",
listPath: "/api/v1/commercial/entitlements",
createPath: "/api/v1/commercial/entitlements",
codeField: "entitlement_code",
nameField: "display_name",
envelopeKeys: ["items", "entitlements"],
@ -376,10 +302,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "activation-logs",
label: "لاگ فعال‌سازی",
description: "Activation pipelines",
listPath: "/api/v1/commercial/activation-logs",
createPath: "/api/v1/commercial/activation-logs",
codeField: "correlation_id",
nameField: "tenant_id",
envelopeKeys: ["items", "pipelines", "activation_logs"],
@ -389,10 +312,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "invoices",
label: "فاکتورها",
description: "Commercial invoices (published read models)",
listPath: "/api/v1/commercial/invoices",
createPath: "/api/v1/commercial/invoices",
codeField: "invoice_code",
nameField: "display_name",
envelopeKeys: ["items", "invoices"],
@ -402,10 +322,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
{
key: "transactions",
label: "تراکنش‌ها",
description: "Payment history read models (no Payment edits)",
listPath: "/api/v1/commercial/transactions",
createPath: "/api/v1/commercial/transactions",
codeField: "transaction_code",
nameField: "display_name",
envelopeKeys: ["items", "transactions"],
@ -415,8 +332,12 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
},
];
export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] =
RESOURCE_BINDINGS.map(defineResource);
export function getCommercialAdminResource(
key: string
): CommercialAdminResourceDef | undefined {
return COMMERCIAL_ADMIN_RESOURCES.find((r) => r.key === key);
const normalized = normalizeRegistryKind(key);
return COMMERCIAL_ADMIN_RESOURCES.find((resource) => resource.key === normalized);
}

View File

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

View File

@ -2,7 +2,12 @@
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
import { Badge, Button } from "@/components/ui";
import { enumLabel, formatMoney, humanizeCommercialCode } from "../presentation";
import {
commercialDescription,
commercialEntityName,
enumLabel,
formatMoney,
} from "../presentation";
export function BundleCard({
bundle,
@ -28,10 +33,17 @@ export function BundleCard({
.filter((item): item is CommercialPricingItem => Boolean(item));
const presentedProducts = (bundle.product_codes || []).map((code, index) => ({
key: `${code}-${index}`,
name:
products.find((product) => product.product_code === code)?.display_name ||
humanizeCommercialCode(code),
name: commercialEntityName(
"products",
code,
products.find((product) => product.product_code === code)?.display_name
),
}));
const bundleName = commercialEntityName(
"bundles",
bundle.bundle_code,
bundle.display_name
);
const badge = bundle.recommendation_badge
? enumLabel(bundle.recommendation_badge)
: recommendationScore != null
@ -58,10 +70,10 @@ export function BundleCard({
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-lg font-bold text-secondary">{bundle.display_name}</h3>
{bundle.description ? (
<p className="mt-1 text-sm text-gray-600">{bundle.description}</p>
) : null}
<h3 className="text-lg font-bold text-secondary">{bundleName}</h3>
<p className="mt-1 text-sm text-gray-600">
{commercialDescription("bundles", bundle.description)}
</p>
</div>
<div className="flex flex-wrap gap-1">
{badge ? (
@ -94,7 +106,7 @@ export function BundleCard({
<div className="mt-2 flex flex-wrap gap-2">
{features.slice(0, 12).map((feature) => (
<span key={feature} className="rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-800">
{humanizeCommercialCode(feature)}
{commercialEntityName("capabilities", feature)}
</span>
))}
</div>
@ -104,7 +116,10 @@ export function BundleCard({
<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="text-xs font-medium text-gray-700">
{commercialEntityName("pricing", price.pricing_item_code, price.display_name)}
</p>
<p className="mt-1 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}

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui";
import { loadCommercialProducts } from "../adapters";
import type { CommercialProduct } from "../types";
import { resolveProductLaunchUrl } from "../types";
import { commercialEntityName } from "../presentation";
/** Dynamic sidebar — product launch URLs from registry metadata only. */
export function CommercialSidebar({ className = "" }: { className?: string }) {
@ -31,7 +32,7 @@ export function CommercialSidebar({ className = "" }: { className?: string }) {
فضای کاری
</Link>
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
Products
همه محصولات
</Link>
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
اشتراک و پرداخت
@ -47,7 +48,7 @@ export function CommercialSidebar({ className = "" }: { className?: string }) {
className="rounded-lg px-2 py-1.5 text-sm text-secondary hover:bg-gray-50"
>
{p.icon ? <span className="me-1">{p.icon}</span> : null}
{p.display_name}
{commercialEntityName("products", p.product_code, p.display_name)}
</Link>
);
})

View File

@ -10,6 +10,7 @@ import { useMe } from "@/hooks/useMe";
import { loadCommercialProducts } from "../adapters";
import type { CommercialProduct } from "../types";
import { resolveProductLaunchUrl } from "../types";
import { commercialEntityName } from "../presentation";
const CORE_NAV = [
{ href: "/dashboard", label: "داشبورد" },
@ -56,6 +57,9 @@ export function PlatformShell({
);
});
const productName = (product: CommercialProduct) =>
commercialEntityName("products", product.product_code, product.display_name);
const navLink = (href: string, label: string) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
@ -106,11 +110,11 @@ export function PlatformShell({
className="rounded-xl px-3 py-2 text-sm text-gray-400"
title="این محصول هنوز آماده راه‌اندازی نیست"
>
{p.display_name}
{productName(p)}
</span>
);
}
return navLink(href, p.display_name);
return navLink(href, productName(p));
})
) : (
<p className="px-3 text-xs text-gray-400">محصولی کشف نشد</p>

View File

@ -6,7 +6,11 @@ import { resolveProductLaunchUrl } from "../types";
import { Button } from "@/components/ui";
import { FeatureLock } from "./FeatureLock";
import { pushRecent } from "../launcher-prefs";
import { enumLabel } from "../presentation";
import {
commercialDescription,
commercialEntityName,
enumLabel,
} from "../presentation";
export function ProductCard({
product,
@ -30,6 +34,11 @@ export function ProductCard({
const strategy = product.launch_strategy || "route";
const requiredCap = product.required_capabilities?.[0];
const setupHref = `/setup/${encodeURIComponent(product.product_code)}`;
const productName = commercialEntityName(
"products",
product.product_code,
product.display_name
);
const markRecent = () => {
pushRecent(product.product_code);
@ -65,11 +74,11 @@ export function ProductCard({
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-lg font-bold text-white"
style={{ background: product.color || "var(--color-primary)" }}
>
{product.icon || (product.display_name?.charAt(0) ?? "P")}
{product.icon || productName.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<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">{productName}</h3>
{product.category ? (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-600">
{enumLabel(product.category)}
@ -79,9 +88,9 @@ export function ProductCard({
<span className="text-[10px] text-primary">پیشنهاد مناسب شما</span>
) : null}
</div>
{product.description ? (
<p className="mt-1 text-xs leading-5 text-gray-500">{product.description}</p>
) : null}
<p className="mt-1 text-xs leading-5 text-gray-500">
{commercialDescription("products", product.description)}
</p>
{product.trial_eligible ? <p className="mt-2 text-xs font-medium text-emerald-700">امکان استفاده آزمایشی</p> : null}
</div>
</div>

View File

@ -3,14 +3,16 @@
import type { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
import type { AdapterResult } from "../types";
import { CommercialEmptyState } from "./CommercialEmptyState";
import { humanizeCommercialCode, recommendationReason } from "../presentation";
import { commercialEntityName, recommendationReason } from "../presentation";
function RecList({
title,
items,
kind,
}: {
title: string;
items?: Array<{ code: string; score?: number; reason?: string }>;
items?: Array<{ code: string; score?: number; reason?: string; kind?: string }>;
kind: string;
}) {
if (!items?.length) return null;
return (
@ -22,7 +24,9 @@ function RecList({
key={`${title}-${i.code}`}
className="rounded-lg border border-gray-100 bg-white px-3 py-2 text-sm"
>
<span className="font-semibold text-secondary">{humanizeCommercialCode(i.code)}</span>
<span className="font-semibold text-secondary">
{commercialEntityName(i.kind || kind, i.code)}
</span>
{typeof i.score === "number" ? (
<span className="ms-2 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] text-primary">
پیشنهاد متناسب
@ -52,9 +56,24 @@ export function RecommendationsPanel({
const d = result.data;
return (
<div className="grid gap-5 lg:grid-cols-3">
<RecList title="ضروری" items={d.recommended_products?.slice(0, 3)} />
<RecList title="پیشنهادی" items={[...(d.recommended_bundles || []), ...(d.recommended_services || [])].slice(0, 4)} />
<RecList title="بعداً اضافه کنید" items={[...(d.recommended_upgrades || []), ...(d.recommended_automation_packs || []), ...(d.recommended_extensions || [])].slice(0, 4)} />
<RecList title="ضروری" kind="products" items={d.recommended_products?.slice(0, 3)} />
<RecList
title="پیشنهادی"
kind="bundles"
items={[
...(d.recommended_bundles || []).map((item) => ({ ...item, kind: "bundles" })),
...(d.recommended_services || []).map((item) => ({ ...item, kind: "products" })),
].slice(0, 4)}
/>
<RecList
title="بعداً اضافه کنید"
kind="products"
items={[
...(d.recommended_upgrades || []).map((item) => ({ ...item, kind: "products" })),
...(d.recommended_automation_packs || []).map((item) => ({ ...item, kind: "automation-packs" })),
...(d.recommended_extensions || []).map((item) => ({ ...item, kind: "extensions" })),
].slice(0, 4)}
/>
</div>
);
}

View File

@ -3,11 +3,13 @@
import type { AdapterResult, RegistryAvailability } from "../types";
import { CommercialEmptyState } from "./CommercialEmptyState";
import {
commercialEntityName,
fieldLabel,
friendlyMessage,
humanizeCommercialCode,
getRegistryPresentation,
isTechnicalField,
presentValue,
registryKindForField,
} from "../presentation";
function availabilityLabel(a: RegistryAvailability): string {
@ -27,29 +29,49 @@ function availabilityLabel(a: RegistryAvailability): string {
}
}
function PresentedValue({ value }: { value: unknown }) {
function PresentedValue({ value, contextKey }: { value: unknown; contextKey?: string }) {
const presented = presentValue(value);
if (Array.isArray(presented)) {
return (
<div className="flex flex-wrap gap-1.5">
<div className="grid gap-2">
{presented.map((item, index) => (
<span key={`${item}-${index}`} className="rounded-full bg-gray-100 px-2.5 py-1 text-xs text-gray-700">
typeof item === "string" ? (
<span key={index} className="w-fit rounded-full bg-gray-100 px-2.5 py-1 text-xs text-gray-700">
{item}
</span>
) : (
<div key={index} className="rounded-xl border border-gray-100 bg-white p-2">
<PresentedValue value={item} contextKey={contextKey} />
</div>
)
))}
</div>
);
}
if (presented && typeof presented === "object") {
const entries = Object.entries(presented).filter(([key]) => !isTechnicalField(key));
const referencedKind = registryKindForField(contextKey);
const codeEntry = Object.entries(presented).find(([key]) => /(^code$|_code$)/.test(key));
const explicitName =
typeof presented.display_name === "string"
? presented.display_name
: typeof presented.title === "string"
? presented.title
: null;
const objectTitle =
referencedKind && codeEntry
? commercialEntityName(referencedKind, codeEntry[1], explicitName)
: null;
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>
<summary className="cursor-pointer text-xs font-semibold text-secondary">
{objectTitle || "مشاهده جزئیات"}
</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>
<dd className="mt-1 text-xs text-secondary"><PresentedValue value={nested} contextKey={key} /></dd>
</div>
))}
</dl>
@ -61,6 +83,7 @@ function PresentedValue({ value }: { value: unknown }) {
/** Generic human-facing catalog list. Implementation identifiers are deliberately hidden. */
export function RegistryBrowser({
kind,
title,
subtitle,
result,
@ -68,17 +91,20 @@ export function RegistryBrowser({
nameKey = "display_name",
emptyTitle,
}: {
title: string;
kind: string;
title?: string;
subtitle?: string;
result: AdapterResult<any[]> | null;
codeKey: string;
nameKey?: string;
emptyTitle?: string;
}) {
const presentation = getRegistryPresentation(kind);
const sectionTitle = title || presentation.sectionTitle;
if (!result || result.availability === "loading") {
return (
<section>
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
<h3 className="text-sm font-semibold text-secondary">{sectionTitle}</h3>
<p className="mt-2 text-xs text-gray-500">{availabilityLabel("loading")}</p>
</section>
);
@ -87,12 +113,12 @@ export function RegistryBrowser({
if (result.availability !== "ready" || !result.data.length) {
return (
<section>
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
<h3 className="text-sm font-semibold text-secondary">{sectionTitle}</h3>
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
<div className="mt-2">
<CommercialEmptyState
title={emptyTitle || `${title}${availabilityLabel(result.availability)}`}
description={friendlyMessage(result.message)}
title={emptyTitle || presentation.emptyStateTitle}
description={friendlyMessage(result.message, presentation.emptyStateDescription)}
/>
</div>
</section>
@ -101,14 +127,14 @@ export function RegistryBrowser({
return (
<section>
<h3 className="text-sm font-semibold text-secondary">{title}</h3>
<h3 className="text-sm font-semibold text-secondary">{sectionTitle}</h3>
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{result.data.map((raw, idx) => {
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const code = String(item[codeKey] ?? idx);
const explicitName = typeof item[nameKey] === "string" ? String(item[nameKey]) : null;
const name = humanizeCommercialCode(code, explicitName);
const name = commercialEntityName(kind, code, explicitName);
const meta = item.metadata;
const detailEntries = Object.entries(item).filter(
([key, value]) =>
@ -129,13 +155,13 @@ export function RegistryBrowser({
{detailEntries.slice(0, 4).map(([key, value]) => (
<div key={key} className="flex items-start justify-between gap-3 text-xs">
<dt className="text-gray-500">{fieldLabel(key)}</dt>
<dd className="text-left text-secondary"><PresentedValue value={value} /></dd>
<dd className="text-left text-secondary"><PresentedValue value={value} contextKey={key} /></dd>
</div>
))}
</dl>
) : null}
{meta && typeof meta === "object" && !Array.isArray(meta) ? (
<div className="mt-3"><PresentedValue value={meta} /></div>
<div className="mt-3"><PresentedValue value={meta} contextKey="metadata" /></div>
) : null}
</li>
);
@ -174,7 +200,7 @@ export function MetadataBagView({
<div key={key} className="rounded-xl bg-gray-50 p-3 text-xs">
<dt className="text-gray-500">{fieldLabel(key)}</dt>
<dd className="mt-2 text-secondary">
<PresentedValue value={value} />
<PresentedValue value={value} contextKey={key} />
</dd>
</div>
))}

View File

@ -2,7 +2,11 @@
import Link from "next/link";
import type { TrialState } from "../types";
import { enumLabel, humanizeCommercialCode } from "../presentation";
import {
commercialEntityName,
enumLabel,
humanizeCommercialCode,
} from "../presentation";
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
if (!trial) return null;
@ -108,7 +112,7 @@ export function SubscriptionBanner({
{planName ? (
<>
{" "}
· پلن: <span className="font-semibold">{humanizeCommercialCode(planName)}</span>
· پلن: <span className="font-semibold">{commercialEntityName("plans", planName)}</span>
</>
) : null}
{(status === "past_due" || status === "expired" || status === "suspended") && (

View File

@ -3,7 +3,7 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import { friendlyMessage, humanizeCommercialCode } from "../presentation";
import { commercialEntityName, friendlyMessage } from "../presentation";
export function UpgradePrompt({
open,
@ -59,19 +59,19 @@ export function UpgradePrompt({
<ul className="mt-3 space-y-1 text-xs text-gray-500">
{requiredPlan ? (
<li>
پلن مناسب: <span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredPlan)}</span>
پلن مناسب: <span className="text-secondary dark:text-gray-200">{commercialEntityName("plans", requiredPlan)}</span>
</li>
) : null}
{requiredCapability ? (
<li>
قابلیت لازم:{" "}
<span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredCapability)}</span>
<span className="text-secondary dark:text-gray-200">{commercialEntityName("capabilities", requiredCapability)}</span>
</li>
) : null}
{requiredBundle ? (
<li>
بسته مناسب:{" "}
<span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredBundle)}</span>
<span className="text-secondary dark:text-gray-200">{commercialEntityName("bundles", requiredBundle)}</span>
</li>
) : null}
</ul>

View File

@ -8,7 +8,7 @@ import {
COMMERCIAL_ADMIN_RESOURCES,
type CommercialAdminResourceDef,
} from "../admin/resources";
import { humanizeCommercialCode } from "../presentation";
import { getRegistryPresentation } from "../presentation";
type CatalogEntry = {
kind: string;
@ -25,7 +25,16 @@ type CatalogEntry = {
*/
export function AdminCommercialHub() {
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;
icon: string;
accentColor: string;
group: CommercialAdminResourceDef["group"];
}>
>([]);
useEffect(() => {
@ -33,29 +42,39 @@ export function AdminCommercialHub() {
const res = await commercialGet<{ registries?: CatalogEntry[] }>("/api/v1/commercial/catalog");
if (res.ok && Array.isArray(res.data.registries) && res.data.registries.length) {
const byPath = new Map(COMMERCIAL_ADMIN_RESOURCES.map((r) => [r.listPath, r]));
setEntries(
res.data.registries.map((r) => {
const presentedEntries = res.data.registries.map((r) => {
const path = r.href || `/api/v1/commercial/${r.path || r.kind}`;
const binding = byPath.get(path) || byPath.get(`/api/v1/commercial/${r.path}`);
const key = binding?.key || r.path || r.kind;
const presentation = binding?.presentation || getRegistryPresentation(key);
return {
key,
label: humanizeCommercialCode(r.kind, r.display_name || binding?.label),
description: `مدیریت و انتشار ${humanizeCommercialCode(r.kind, r.display_name || binding?.label)}`,
label: presentation.navigationTitle,
description: presentation.description,
href: `/admin/commercial/${key}`,
listPath: path,
icon: presentation.icon,
accentColor: presentation.accentColor,
group: binding?.group || "catalog",
};
})
});
setEntries(
Array.from(
new Map(presentedEntries.map((entry) => [entry.key, entry])).values()
)
);
return;
}
setEntries(
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
key: r.key,
label: r.label,
description: `مدیریت و انتشار ${r.label}`,
label: r.presentation.navigationTitle,
description: r.presentation.description,
href: `/admin/commercial/${r.key}`,
listPath: r.listPath,
icon: r.presentation.icon,
accentColor: r.presentation.accentColor,
group: r.group,
}))
);
})();
@ -69,14 +88,21 @@ export function AdminCommercialHub() {
/>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{entries.map((r) => (
{entries.filter((entry) => entry.group !== "tenant").map((r) => (
<Link
key={r.key}
href={r.href}
className={`${cardClass} transition-shadow hover:shadow-md`}
className={`${cardClass} group transition-all hover:-translate-y-0.5 hover:shadow-md`}
>
<div className="flex items-start gap-3">
<span className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl text-lg ${r.accentColor}`} aria-hidden="true">
{r.icon}
</span>
<div>
<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 leading-5 text-gray-500">{r.description}</p>
</div>
</div>
</Link>
))}
</div>
@ -90,8 +116,15 @@ export function AdminCommercialHub() {
href={`/admin/commercial/${r.key}`}
className={`${cardClass} transition-shadow hover:shadow-md`}
>
<p className="font-semibold text-secondary">{r.label}</p>
<p className="mt-1 text-xs text-gray-500">مدیریت و پیگیری {r.label}</p>
<div className="flex items-start gap-3">
<span className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl text-lg ${r.presentation.accentColor}`} aria-hidden="true">
{r.presentation.icon}
</span>
<div>
<p className="font-semibold text-secondary">{r.presentation.navigationTitle}</p>
<p className="mt-1 text-xs leading-5 text-gray-500">{r.presentation.description}</p>
</div>
</div>
</Link>
))}
</div>

View File

@ -14,27 +14,26 @@ import {
} from "../adapters/admin";
import type { AdapterResult } from "../types";
import {
COMMERCIAL_FIELD_GROUPS,
FIELD_ENUM_OPTIONS,
commercialDescription,
commercialEntityName,
enumLabel,
fieldLabel,
formatDate,
friendlyMessage,
humanizeCommercialCode,
isTechnicalField,
registryKindForField,
} from "../presentation";
const PAGE_SIZE = 10;
const FIELD_GROUPS = [
{ title: "اطلاعات پایه", keys: ["display_name", "title", "description", "category"] },
{ title: "اطلاعات تجاری", keys: ["role", "pricing_model", "amount_minor", "currency", "interval", "trial_days", "duration_days", "grace_period_days"] },
{ title: "قیمت‌گذاری", keys: ["pricing_refs", "tax_class_code", "discount_refs", "coupon_code"] },
{ title: "قابلیت‌ها", keys: ["product_codes", "capability_codes", "feature_refs", "required_capabilities"] },
{ title: "نمایش", keys: ["visibility", "display_order", "trial_eligible"] },
{ title: "انتشار", keys: ["status", "published_at", "starts_at", "ends_at"] },
{ title: "اطلاعات تکمیلی", keys: ["metadata", "attributes", "parameters", "usage_limits", "feature_limits"] },
] as const;
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
const name = item[resource.nameField];
return humanizeCommercialCode(item[resource.codeField], typeof name === "string" ? name : null);
return commercialEntityName(
resource.key,
item[resource.codeField],
typeof name === "string" ? name : null
);
}
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
@ -47,16 +46,29 @@ function recordTargetId(item: RegistryRecord, resource: CommercialAdminResourceD
return target == null ? "" : String(target);
}
const DEFAULT_RECORD_VALUES: Partial<
Record<CommercialAdminResourceDef["key"], RegistryRecord>
> = {
products: { visibility: "public", display_order: 0 },
"trial-rules": { duration_days: 14, grace_period_days: 3, auto_disable: true },
pricing: { pricing_model: "monthly", currency: "IRR", amount_minor: 0 },
policies: {
effect: "deny",
parameters: {
custom_domain_allowed: false,
subdomain_only: true,
ssl_ready: false,
},
},
};
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
const seed: RegistryRecord = {
[resource.codeField]: "",
[resource.nameField]: "",
status: "draft",
};
if (resource.key === "products") Object.assign(seed, { visibility: "public", display_order: 0 });
if (resource.key === "trial-rules") Object.assign(seed, { duration_days: 14, grace_period_days: 3, auto_disable: true });
if (resource.key === "pricing") Object.assign(seed, { pricing_model: "monthly", currency: "IRR", amount_minor: 0 });
if (resource.key === "policies") Object.assign(seed, { effect: "deny", parameters: { custom_domain_allowed: false, subdomain_only: true, ssl_ready: false } });
Object.assign(seed, DEFAULT_RECORD_VALUES[resource.key] || {});
return seed;
}
@ -74,10 +86,16 @@ function FieldInput({
name,
value,
onChange,
resourceKind,
entityCode,
presentAsName = false,
}: {
name: string;
value: unknown;
onChange: (value: unknown) => void;
resourceKind: string;
entityCode?: unknown;
presentAsName?: boolean;
}) {
if (Array.isArray(value)) {
return (
@ -86,7 +104,7 @@ function FieldInput({
<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)}
{commercialEntityName(registryKindForField(name) || resourceKind, item)}
</span>
)) : <span className="text-xs text-gray-500">موردی انتخاب نشده است.</span>}
</div>
@ -113,6 +131,7 @@ function FieldInput({
key={key}
name={key}
value={nested}
resourceKind={resourceKind}
onChange={(next) => onChange({ ...(value as Record<string, unknown>), [key]: next })}
/>
))}
@ -120,20 +139,20 @@ function FieldInput({
</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]) {
const rawValue = value == null ? "" : String(value);
const displayValue = !rawValue
? ""
: presentAsName
? commercialEntityName(resourceKind, entityCode, rawValue)
: name === "description"
? commercialDescription(resourceKind, rawValue)
: rawValue;
if (FIELD_ENUM_OPTIONS[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>)}
{FIELD_ENUM_OPTIONS[name].map((option) => <option key={option} value={option}>{enumLabel(option)}</option>)}
</select>
</label>
);
@ -166,13 +185,13 @@ function CommercialRecordForm({
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 grouped = new Set<string>(COMMERCIAL_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) => {
{COMMERCIAL_FIELD_GROUPS.map((group) => {
const keys = Array.from(new Set([
...(group.title === "اطلاعات پایه" ? [resource.nameField] : []),
...group.keys,
@ -182,7 +201,7 @@ function CommercialRecordForm({
<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)} />)}
{keys.map((key) => <FieldInput key={key} name={key} value={value[key]} resourceKind={resource.key} entityCode={value[resource.codeField]} presentAsName={key === resource.nameField} onChange={(next) => setField(key, next)} />)}
</div>
</section>
);
@ -192,7 +211,7 @@ function CommercialRecordForm({
<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)} />)}
{advanced.map((key) => <FieldInput key={key} name={key} value={value[key]} resourceKind={resource.key} entityCode={value[resource.codeField]} presentAsName={key === resource.nameField} onChange={(next) => setField(key, next)} />)}
</div>
</details>
) : null}
@ -313,7 +332,11 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
};
const removeItems = async (targets: RegistryRecord[]) => {
if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return;
const confirmation =
targets.length === 1
? resource.presentation.deleteConfirmation
: `آیا از حذف ${targets.length.toLocaleString("fa-IR")} مورد از ${resource.presentation.pluralLabel} مطمئن هستید؟`;
if (!targets.length || !window.confirm(confirmation)) return;
setBusy(true);
for (const item of targets) {
const targetId = recordTargetId(item, resource);
@ -333,9 +356,9 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
return (
<div className="space-y-6">
<AdminPageHeader
title={resource.label}
subtitle={`جست‌وجو، ویرایش، انتشار و مدیریت ${resource.label}`}
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link>{!resource.readOnly ? <Button onClick={openCreate}>افزودن مورد جدید</Button> : null}</div>}
title={resource.presentation.sectionTitle}
subtitle={resource.presentation.description}
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link>{!resource.readOnly ? <Button onClick={openCreate}>{resource.presentation.createButtonText}</Button> : null}</div>}
/>
{error ? <Banner variant="error">{error}</Banner> : null}
{msg ? <Banner variant="success">{msg}</Banner> : null}
@ -344,7 +367,7 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
<label className="relative min-w-[220px] flex-1">
<span className="sr-only">جستوجو</span>
<span className="pointer-events-none absolute right-3 top-2.5" aria-hidden="true"></span>
<input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder={`جست‌وجو در ${resource.label}`} className="w-full rounded-xl border border-gray-200 py-2.5 pl-4 pr-10 text-sm" />
<input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder={`جست‌وجو در ${resource.presentation.pluralLabel}`} 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>
@ -357,20 +380,31 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
{loading ? (
<div className="space-y-3 p-5" aria-label="در حال بارگذاری">{[1, 2, 3].map((i) => <div key={i} className="h-12 animate-pulse rounded-xl bg-gray-100" />)}</div>
) : !visible.length ? (
<EmptyState message={query || statusFilter !== "all" ? "موردی با این جست‌وجو پیدا نشد." : "هنوز موردی ثبت نشده است."} />
<EmptyState message={query || statusFilter !== "all" ? `نتیجه‌ای در ${resource.presentation.pluralLabel} پیدا نشد.` : resource.presentation.emptyStateTitle} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] text-sm">
<thead className="bg-gray-50 text-right"><tr><th className="w-12 px-4 py-3"><span className="sr-only">انتخاب</span></th><th className="px-4 py-3 font-semibold text-secondary">نام</th><th className="px-4 py-3 font-semibold text-secondary">وضعیت</th><th className="px-4 py-3 font-semibold text-secondary">عملیات</th></tr></thead>
<thead className="bg-gray-50 text-right">
<tr>
{!resource.readOnly ? <th className="w-12 px-4 py-3"><span className="sr-only">انتخاب</span></th> : null}
{resource.presentation.columns.map((column) => <th key={column.key} className="px-4 py-3 font-semibold text-secondary">{column.label}</th>)}
<th className="px-4 py-3 font-semibold text-secondary">عملیات</th>
</tr>
</thead>
<tbody>
{visible.map((item, index) => {
const code = recordCode(item, resource) || `row-${index}`;
const status = typeof item.status === "string" ? item.status : "inactive";
return (
<tr key={code} className="border-t border-gray-100 hover:bg-gray-50/60">
<td className="px-4 py-3"><input type="checkbox" checked={selected.has(code)} aria-label={`انتخاب ${recordLabel(item, resource)}`} onChange={() => setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /></td>
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
<td className="px-4 py-3"><span className={`rounded-full px-2.5 py-1 text-xs ${status === "active" || status === "published" ? "bg-emerald-50 text-emerald-700" : status === "draft" ? "bg-amber-50 text-amber-700" : "bg-gray-100 text-gray-600"}`}>{enumLabel(status)}</span></td>
{!resource.readOnly ? <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> : null}
{resource.presentation.columns.map((column) => {
if (column.key === "name") return <td key={column.key} className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>;
if (column.key === "status") return <td key={column.key} 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"}`}>{resource.presentation.statusMap[status] || enumLabel(status)}</span></td>;
if (column.key === "version") return <td key={column.key} className="px-4 py-3 text-gray-600">{item.version || item.version_code ? `نسخه ${String(item.version || item.version_code)}` : "—"}</td>;
if (column.key === "updated_at") return <td key={column.key} className="px-4 py-3 text-gray-600">{formatDate(typeof item.updated_at === "string" ? item.updated_at : null)}</td>;
return <td key={column.key} className="px-4 py-3 text-gray-600">{enumLabel(item.visibility)}</td>;
})}
<td className="px-4 py-3">
{resource.readOnly ? (
<span className="text-xs text-gray-500">نمایش فقطخواندنی</span>
@ -394,13 +428,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
) : null}
</section>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`افزودن ${resource.label}`}>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={resource.presentation.createButtonText}>
<form onSubmit={(event) => void submitCreate(event)} className="space-y-5">
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>انصراف</Button><Button type="submit" loading={busy}>ثبت و ذخیره</Button></div>
</form>
</Modal>
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={`ویرایش ${resource.label}`}>
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={resource.presentation.editDialogTitle}>
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setEditItem(null)}>انصراف</Button><Button type="submit" loading={busy}>ذخیره تغییرات</Button></div>

View File

@ -38,6 +38,7 @@ import {
togglePinned,
type LauncherPrefs,
} from "../launcher-prefs";
import { enumLabel } from "../presentation";
const GROUP_ORDER = [
"Operations",
@ -58,21 +59,6 @@ const GROUP_ORDER = [
"ai",
];
function groupLabel(raw: string): string {
const map: Record<string, string> = {
operations: "عملیات",
sales: "فروش",
marketing: "بازاریابی",
finance: "مالی",
customers: "مشتریان",
"online presence": "حضور آنلاین",
online: "حضور آنلاین",
automation: "اتوماسیون",
ai: "هوش مصنوعی",
};
return map[raw.toLowerCase()] || raw;
}
function AppsInner() {
const { me } = useMe();
const searchParams = useSearchParams();
@ -143,7 +129,7 @@ function AppsInner() {
const ib = GROUP_ORDER.findIndex((g) => g.toLowerCase() === b.toLowerCase());
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
return keys.map((k) => ({ key: k, label: groupLabel(k), items: map.get(k)! }));
return keys.map((k) => ({ key: k, label: enumLabel(k), items: map.get(k)! }));
}, [products, q]);
return (
@ -281,10 +267,10 @@ function AppsInner() {
</section>
<div className="mt-10 space-y-8">
<RegistryBrowser title="دارایی‌های دیجیتال" result={assets} codeKey="asset_code" />
<RegistryBrowser title="افزونه‌ها" result={extensions} codeKey="extension_code" />
<RegistryBrowser kind="assets" result={assets} codeKey="asset_code" />
<RegistryBrowser kind="extensions" result={extensions} codeKey="extension_code" />
<RegistryBrowser
title="بسته‌های خودکارسازی"
kind="automation-packs"
result={automation}
codeKey="automation_pack_code"
/>

View File

@ -20,6 +20,7 @@ import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } fro
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
import { CommercialSkeleton } from "../components/CommercialSkeleton";
import {
commercialEntityName,
enumLabel,
formatDate,
formatMoney,
@ -192,7 +193,9 @@ function BillingInner() {
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-gray-100 bg-white p-4"
>
<div>
<p className="font-semibold text-secondary">{item.display_name}</p>
<p className="font-semibold text-secondary">
{commercialEntityName("pricing", item.pricing_item_code, item.display_name)}
</p>
<p className="mt-1 text-sm text-gray-600">{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}</p>
{item.trial_days ? <p className="mt-1 text-xs text-emerald-700">{item.trial_days.toLocaleString("fa-IR")} روز آزمایش رایگان</p> : null}
</div>
@ -245,7 +248,7 @@ function BillingInner() {
<ul className="space-y-2 text-sm">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(inv.invoice_code, inv.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(inv.issued_at)}</p></div>
<div><p className="font-medium text-secondary">{commercialEntityName("invoices", 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>
))}
@ -257,7 +260,7 @@ function BillingInner() {
<ul className="mt-3 space-y-2 text-sm">
{txns.data.map((t) => (
<li key={t.transaction_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(t.transaction_code, t.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(t.occurred_at)}</p></div>
<div><p className="font-medium text-secondary">{commercialEntityName("transactions", 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>
))}

View File

@ -57,6 +57,7 @@ import {
} from "../components";
import { loadAssessmentSession } from "../session";
import {
commercialEntityName,
enumLabel,
friendlyMessage,
humanizeCommercialCode,
@ -325,7 +326,7 @@ function CommercialDashboardContent() {
key={f.feature_key}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm"
>
<span className="text-xs font-medium">{humanizeCommercialCode(f.feature_key)}</span>
<span className="text-xs font-medium">{commercialEntityName("capabilities", f.feature_key)}</span>
<button
type="button"
className="text-xs font-semibold text-primary"
@ -393,7 +394,7 @@ function CommercialDashboardContent() {
<CapabilityBadge
key={c.capability_code}
code={c.capability_code}
label={c.display_name}
label={commercialEntityName("capabilities", c.capability_code, c.display_name)}
/>
))}
</div>
@ -416,7 +417,7 @@ function CommercialDashboardContent() {
<ul className="mb-3 space-y-1">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{humanizeCommercialCode(inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
{commercialEntityName("invoices", inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
</li>
))}
</ul>
@ -434,7 +435,7 @@ function CommercialDashboardContent() {
<ul className="mt-3 space-y-1">
{licenses.data.map((lic) => (
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{humanizeCommercialCode(lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
{commercialEntityName("licenses", lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
</li>
))}
</ul>
@ -450,10 +451,10 @@ function CommercialDashboardContent() {
)}
</section>
<RegistryBrowser title="دارایی‌ها" result={assets} codeKey="asset_code" />
<RegistryBrowser title="افزونه‌ها" result={extensions} codeKey="extension_code" />
<RegistryBrowser title="اتوماسیون" result={automation} codeKey="automation_pack_code" />
<RegistryBrowser title="سیاست‌ها" result={policies} codeKey="policy_code" />
<RegistryBrowser kind="assets" result={assets} codeKey="asset_code" />
<RegistryBrowser kind="extensions" result={extensions} codeKey="extension_code" />
<RegistryBrowser kind="automation-packs" result={automation} codeKey="automation_pack_code" />
<RegistryBrowser kind="policies" result={policies} codeKey="policy_code" />
<section>
<SectionTitle title="اعلان‌ها" />

View File

@ -32,6 +32,7 @@ import {
type AssessmentSession,
} from "../session";
import {
commercialEntityName,
enumLabel,
formatMoney,
friendlyMessage,
@ -456,7 +457,9 @@ export function CommercialDiscoverExperience() {
{selectedBundleObj ? (
<div className="rounded-2xl border border-primary/30 bg-[var(--color-primary-soft)] p-4 text-sm">
<p className="font-semibold text-secondary">باندل پیشنهادی فعلی</p>
<p className="mt-1">{selectedBundleObj.display_name}</p>
<p className="mt-1">
{commercialEntityName("bundles", selectedBundleObj.bundle_code, selectedBundleObj.display_name)}
</p>
{selectedBundleObj.upgrade_path_code ? (
<p className="mt-1 text-xs text-primary">
امکان ارتقا در هر زمان
@ -529,7 +532,7 @@ export function CommercialDiscoverExperience() {
className="rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<p className="font-semibold text-secondary dark:text-gray-100">
{item.display_name}
{commercialEntityName("pricing", item.pricing_item_code, item.display_name)}
</p>
<p className="mt-1 text-xs text-gray-500">
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
@ -564,7 +567,11 @@ export function CommercialDiscoverExperience() {
</p>
{selectedBundle ? (
<p className="text-sm">
بسته: <span className="font-semibold">{selectedBundleObj?.display_name || "بسته پیشنهادی"}</span>
بسته: <span className="font-semibold">
{selectedBundleObj
? commercialEntityName("bundles", selectedBundleObj.bundle_code, selectedBundleObj.display_name)
: "بسته پیشنهادی"}
</span>
</p>
) : (
<p className="text-xs text-gray-500">بستهای انتخاب نشده است؛ میتوانید بعداً از بخش اشتراک انتخاب کنید.</p>

View File

@ -12,7 +12,12 @@ import {
} from "../adapters";
import { commercialGet, extractItems } from "../adapters/http";
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
import { enumLabel, formatMoney } from "../presentation";
import {
commercialDescription,
commercialEntityName,
enumLabel,
formatMoney,
} from "../presentation";
const JOURNEY = [
"کشف نیاز",
@ -152,7 +157,7 @@ export function CommercialMarketingLanding() {
href={`/discover?type=${encodeURIComponent(t.business_type_code)}`}
className="rounded-full border border-gray-200 px-4 py-2 text-sm text-secondary transition hover:border-primary hover:bg-[var(--color-primary-soft)] dark:border-gray-700 dark:text-gray-200"
>
{t.display_name}
{commercialEntityName("business-types", t.business_type_code, t.display_name)}
</Link>
))
) : (
@ -178,8 +183,12 @@ export function CommercialMarketingLanding() {
key={p.product_code}
className="border-b border-gray-100 pb-4 dark:border-gray-800"
>
<p className="font-semibold text-secondary dark:text-gray-100">{p.display_name}</p>
<p className="mt-1 text-xs text-gray-500">{p.description || p.category || "—"}</p>
<p className="font-semibold text-secondary dark:text-gray-100">
{commercialEntityName("products", p.product_code, p.display_name)}
</p>
<p className="mt-1 text-xs text-gray-500">
{commercialDescription("products", p.description)}
</p>
</li>
))}
</ul>
@ -218,14 +227,14 @@ export function CommercialMarketingLanding() {
{bundles.slice(0, 6).map((b) => (
<li key={b.bundle_code}>
<p className="text-lg font-bold text-secondary dark:text-gray-100">
{b.display_name}
{commercialEntityName("bundles", b.bundle_code, b.display_name)}
</p>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{b.description || "مجموعه‌ای کاربردی برای شروع سریع‌تر"}
{commercialDescription("bundles", b.description)}
</p>
{b.recommendation_badge ? (
<Badge variant="primary" className="mt-2">
{b.recommendation_badge}
{enumLabel(b.recommendation_badge)}
</Badge>
) : null}
</li>
@ -246,7 +255,7 @@ export function CommercialMarketingLanding() {
{pricing.map((item) => (
<li key={item.pricing_item_code}>
<p className="font-semibold text-secondary dark:text-gray-100">
{item.display_name}
{commercialEntityName("pricing", item.pricing_item_code, item.display_name)}
</p>
<p className="mt-1 text-xs text-gray-500">
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}

View File

@ -16,6 +16,7 @@ import {
SetupChecklist,
} from "../components";
import { pushRecent } from "../launcher-prefs";
import { commercialEntityName } from "../presentation";
/**
* Generic first-run product setup wizard.
@ -38,6 +39,9 @@ function SetupWizardInner() {
const prods = await loadCommercialProducts();
setProductsAvail(prods.availability);
const found = (prods.data || []).find((p) => p.product_code === productCode) || null;
const foundName = found
? commercialEntityName("products", found.product_code, found.display_name)
: "";
setProduct(found);
if (found) pushRecent(found.product_code);
@ -69,7 +73,7 @@ function SetupWizardInner() {
setChecklist([
{
id: "open",
label: `باز کردن ${found.display_name}`,
label: `باز کردن ${foundName}`,
href: resolveProductLaunchUrl(found),
product_code: productCode,
required: true,
@ -102,6 +106,9 @@ function SetupWizardInner() {
const current = checklist[step];
const launch = product ? resolveProductLaunchUrl(product) : null;
const productName = product
? commercialEntityName("products", product.product_code, product.display_name)
: "";
const progress = useMemo(() => {
if (pct != null) return pct;
@ -111,7 +118,7 @@ function SetupWizardInner() {
return (
<PlatformShell
title={product ? `راه‌اندازی ${product.display_name}` : "راه‌اندازی محصول"}
title={product ? `راه‌اندازی ${productName}` : "راه‌اندازی محصول"}
subtitle="مراحل ساده برای آماده‌کردن محصول و شروع استفاده"
>
{loading ? <CommercialSkeleton rows={4} /> : null}
@ -122,10 +129,10 @@ function SetupWizardInner() {
description={
productsAvail === "unavailable"
? "اطلاعات این محصول فعلاً در دسترس نیست."
: `کد ${productCode} پیدا نشد.`
: "این محصول در فهرست محصولات قابل راه‌اندازی پیدا نشد."
}
actionHref="/apps"
actionLabel="بازگشت به Apps"
actionLabel="بازگشت به محصولات"
secondaryHref="/discover"
secondaryLabel="کشف مجدد"
/>

View File

@ -1,8 +1,437 @@
const TOKEN_LABELS: Record<string, string> = {
export type RegistryPresentationColumn = {
key: "name" | "status" | "version" | "updated_at" | "visibility";
label: string;
};
export type RegistryPresentation = {
label: string;
englishLabel: string;
pluralLabel: string;
description: string;
icon: string;
accentColor: string;
navigationTitle: string;
sectionTitle: string;
emptyStateTitle: string;
emptyStateDescription: string;
createButtonText: string;
editDialogTitle: string;
deleteConfirmation: string;
publishLabel: string;
archiveLabel: string;
statusMap: Readonly<Record<string, string>>;
columns: readonly RegistryPresentationColumn[];
};
const STATUS_LABELS: Readonly<Record<string, string>> = {
active: "فعال",
inactive: "غیرفعال",
draft: "پیش‌نویس",
published: "منتشرشده",
pending: "در انتظار بررسی",
pending_payment: "در انتظار پرداخت",
verified: "تأییدشده",
unverified: "تأییدنشده",
failed: "ناموفق",
succeeded: "موفق",
completed: "تکمیل‌شده",
expired: "منقضی‌شده",
trialing: "در دوره آزمایشی",
trial: "آزمایشی",
suspended: "تعلیق‌شده",
canceled: "لغوشده",
cancelled: "لغوشده",
archived: "بایگانی‌شده",
deprecated: "از رده خارج",
planned: "برنامه‌ریزی‌شده",
concept: "در حال طراحی",
mature: "پایدار",
ga: "آماده ارائه",
beta: "نسخه آزمایشی",
grace_period: "در مهلت تمدید",
processing: "در حال پردازش",
paid: "پرداخت‌شده",
unpaid: "پرداخت‌نشده",
refunded: "بازپرداخت‌شده",
partially_refunded: "بازپرداخت جزئی",
};
const DEFAULT_COLUMNS: readonly RegistryPresentationColumn[] = [
{ key: "name", label: "نام" },
{ key: "status", label: "وضعیت" },
{ key: "version", label: "نسخه" },
{ key: "updated_at", label: "آخرین تغییر" },
{ key: "visibility", label: "نمایش" },
];
function definePresentation({
label,
englishLabel,
pluralLabel = label,
description,
icon,
accentColor,
columns = DEFAULT_COLUMNS,
}: {
label: string;
englishLabel: string;
pluralLabel?: string;
description: string;
icon: string;
accentColor: string;
columns?: readonly RegistryPresentationColumn[];
}): RegistryPresentation {
return {
label,
englishLabel,
pluralLabel,
description,
icon,
accentColor,
navigationTitle: pluralLabel,
sectionTitle: `مدیریت ${pluralLabel}`,
emptyStateTitle: `هنوز ${pluralLabel} ثبت نشده است`,
emptyStateDescription: `پس از ثبت اولین مورد، اطلاعات ${pluralLabel} در این بخش نمایش داده می‌شود.`,
createButtonText: `افزودن ${label}`,
editDialogTitle: `ویرایش ${label}`,
deleteConfirmation: `آیا از حذف این ${label} مطمئن هستید؟`,
publishLabel: `انتشار ${label}`,
archiveLabel: `بایگانی ${label}`,
statusMap: STATUS_LABELS,
columns,
};
}
/**
* Universal Commercial Presentation Dictionary.
* API paths and registry records never provide user-facing copy.
*/
export const presentationRegistry = {
products: definePresentation({
label: "محصول",
englishLabel: "Product",
pluralLabel: "محصولات",
description: "راهکارهای نرم‌افزاری قابل ارائه به کسب‌وکارها",
icon: "◫",
accentColor: "bg-sky-50 text-sky-700",
}),
bundles: definePresentation({
label: "بسته خدمات",
englishLabel: "Service Bundle",
pluralLabel: "بسته‌های خدمات",
description: "ترکیب آماده محصولات و امکانات برای نیازهای مختلف",
icon: "▦",
accentColor: "bg-violet-50 text-violet-700",
}),
pricing: definePresentation({
label: "قیمت",
englishLabel: "Pricing Item",
pluralLabel: "قیمت‌گذاری",
description: "قیمت‌های ماهانه، سالانه، آزمایشی و سازمانی",
icon: "◉",
accentColor: "bg-emerald-50 text-emerald-700",
}),
plans: definePresentation({
label: "پلن اشتراک",
englishLabel: "Subscription Plan",
pluralLabel: "پلن‌های اشتراک",
description: "پلن‌های قابل انتخاب برای شروع و توسعه کسب‌وکار",
icon: "▤",
accentColor: "bg-indigo-50 text-indigo-700",
}),
capabilities: definePresentation({
label: "قابلیت",
englishLabel: "Capability",
pluralLabel: "قابلیت‌ها",
description: "امکانات کاربردی قابل ارائه در محصولات و بسته‌ها",
icon: "✦",
accentColor: "bg-amber-50 text-amber-700",
}),
"business-types": definePresentation({
label: "نوع کسب‌وکار",
englishLabel: "Business Type",
pluralLabel: "انواع کسب‌وکار",
description: "الگوهای کسب‌وکار برای پیشنهاد دقیق‌تر محصولات",
icon: "⌂",
accentColor: "bg-orange-50 text-orange-700",
}),
"recommendation-rules": definePresentation({
label: "قاعده پیشنهاد",
englishLabel: "Recommendation Rule",
pluralLabel: "قواعد پیشنهاد",
description: "قواعد تجاری برای پیشنهاد راهکار مناسب به هر کسب‌وکار",
icon: "◎",
accentColor: "bg-fuchsia-50 text-fuchsia-700",
}),
"assessment-schemas": definePresentation({
label: "الگوی ارزیابی",
englishLabel: "Assessment Schema",
pluralLabel: "الگوهای ارزیابی",
description: "پرسش‌های کسب‌وکاری برای شناخت نیاز مشتری",
icon: "☷",
accentColor: "bg-cyan-50 text-cyan-700",
}),
assets: definePresentation({
label: "دارایی دیجیتال",
englishLabel: "Digital Asset",
pluralLabel: "دارایی‌های دیجیتال",
description: "دارایی‌های قابل استفاده در محصولات و تجربه مشتری",
icon: "◇",
accentColor: "bg-rose-50 text-rose-700",
}),
extensions: definePresentation({
label: "افزونه",
englishLabel: "Extension",
pluralLabel: "افزونه‌ها",
description: "امکانات تکمیلی قابل افزودن به محصولات",
icon: "⊞",
accentColor: "bg-teal-50 text-teal-700",
}),
"automation-packs": definePresentation({
label: "بسته اتوماسیون",
englishLabel: "Automation Pack",
pluralLabel: "بسته‌های اتوماسیون",
description: "فرایندهای آماده برای کاهش کارهای تکراری",
icon: "⚙",
accentColor: "bg-blue-50 text-blue-700",
}),
metadata: definePresentation({
label: "ویژگی تکمیلی",
englishLabel: "Additional Attribute",
pluralLabel: "ویژگی‌های تکمیلی",
description: "اطلاعات تکمیلی سازمان‌یافته برای نمایش و دسته‌بندی",
icon: "≡",
accentColor: "bg-slate-100 text-slate-700",
}),
policies: definePresentation({
label: "قانون تجاری",
englishLabel: "Commercial Policy",
pluralLabel: "قوانین تجاری",
description: "قوانین دسترسی، دامنه، فروش و ارائه خدمات",
icon: "✓",
accentColor: "bg-lime-50 text-lime-700",
}),
"usage-plans": definePresentation({
label: "برنامه مصرف",
englishLabel: "Usage Plan",
pluralLabel: "برنامه‌های مصرف",
description: "سقف‌ها و الگوهای مصرف خدمات",
icon: "◔",
accentColor: "bg-purple-50 text-purple-700",
}),
subscriptions: definePresentation({
label: "اشتراک",
englishLabel: "Subscription",
pluralLabel: "اشتراک‌ها",
description: "اشتراک‌های فعال، آزمایشی و در انتظار تمدید مشتریان",
icon: "↻",
accentColor: "bg-sky-50 text-sky-700",
}),
licenses: definePresentation({
label: "لایسنس",
englishLabel: "License",
pluralLabel: "لایسنس‌ها",
description: "مجوزهای استفاده از محصولات و بسته‌های خریداری‌شده",
icon: "⌘",
accentColor: "bg-indigo-50 text-indigo-700",
}),
entitlements: definePresentation({
label: "مجوز دسترسی",
englishLabel: "Entitlement",
pluralLabel: "مجوزهای دسترسی",
description: "امکانات و سهمیه‌های در دسترس هر فضای کاری",
icon: "◈",
accentColor: "bg-emerald-50 text-emerald-700",
}),
promotions: definePresentation({
label: "پیشنهاد ویژه",
englishLabel: "Promotion",
pluralLabel: "پیشنهادهای ویژه",
description: "پیشنهادهای فروش زمان‌دار برای مشتریان",
icon: "★",
accentColor: "bg-pink-50 text-pink-700",
}),
coupons: definePresentation({
label: "کوپن",
englishLabel: "Coupon",
pluralLabel: "کوپن‌ها",
description: "کوپن‌های تخفیف قابل استفاده هنگام خرید",
icon: "✂",
accentColor: "bg-rose-50 text-rose-700",
}),
currencies: definePresentation({
label: "واحد پول",
englishLabel: "Currency",
pluralLabel: "واحدهای پول",
description: "واحدهای پول قابل استفاده در قیمت‌گذاری",
icon: "¤",
accentColor: "bg-green-50 text-green-700",
}),
taxes: definePresentation({
label: "گروه مالیاتی",
englishLabel: "Tax Class",
pluralLabel: "گروه‌های مالیاتی",
description: "قواعد محاسبه مالیات برای محصولات و خدمات",
icon: "%",
accentColor: "bg-amber-50 text-amber-700",
}),
notifications: definePresentation({
label: "اعلان تجاری",
englishLabel: "Commercial Notification",
pluralLabel: "اعلان‌های تجاری",
description: "پیام‌های مرتبط با خرید، اشتراک و تمدید",
icon: "◌",
accentColor: "bg-cyan-50 text-cyan-700",
}),
announcements: definePresentation({
label: "اطلاعیه تجاری",
englishLabel: "Commercial Announcement",
pluralLabel: "اطلاعیه‌های تجاری",
description: "اطلاعیه‌های قابل نمایش به مشتریان",
icon: "◍",
accentColor: "bg-blue-50 text-blue-700",
}),
transactions: definePresentation({
label: "تراکنش",
englishLabel: "Transaction",
pluralLabel: "تراکنش‌ها",
description: "سوابق پرداخت و بازپرداخت مشتریان",
icon: "⇄",
accentColor: "bg-teal-50 text-teal-700",
}),
invoices: definePresentation({
label: "صورتحساب",
englishLabel: "Invoice",
pluralLabel: "صورتحساب‌ها",
description: "صورتحساب‌های صادرشده برای اشتراک و خدمات",
icon: "▧",
accentColor: "bg-orange-50 text-orange-700",
}),
"trial-rules": definePresentation({
label: "قانون دوره آزمایشی",
englishLabel: "Trial Rule",
pluralLabel: "قوانین دوره آزمایشی",
description: "مدت، محدودیت و شرایط استفاده آزمایشی",
icon: "◷",
accentColor: "bg-green-50 text-green-700",
}),
"subscription-templates": definePresentation({
label: "الگوی اشتراک",
englishLabel: "Subscription Template",
pluralLabel: "الگوهای اشتراک",
description: "الگوهای آماده برای ایجاد و تمدید اشتراک",
icon: "▥",
accentColor: "bg-indigo-50 text-indigo-700",
}),
"discount-campaigns": definePresentation({
label: "کمپین تخفیف",
englishLabel: "Discount Campaign",
pluralLabel: "کمپین‌های تخفیف",
description: "کمپین‌های زمان‌دار تخفیف و فروش",
icon: "↓",
accentColor: "bg-red-50 text-red-700",
}),
"regional-pricing": definePresentation({
label: "قیمت منطقه‌ای",
englishLabel: "Regional Pricing",
pluralLabel: "قیمت‌های منطقه‌ای",
description: "تنظیم قیمت متناسب با منطقه و بازار",
icon: "⌖",
accentColor: "bg-cyan-50 text-cyan-700",
}),
languages: definePresentation({
label: "زبان",
englishLabel: "Language",
pluralLabel: "زبان‌ها",
description: "زبان‌های قابل استفاده در تجربه مشتری",
icon: "文",
accentColor: "bg-violet-50 text-violet-700",
}),
"marketplace-visibility": definePresentation({
label: "نحوه نمایش در بازار",
englishLabel: "Marketplace Visibility",
pluralLabel: "تنظیمات نمایش در بازار",
description: "کنترل نمایش محصولات و خدمات در بازار",
icon: "◐",
accentColor: "bg-slate-100 text-slate-700",
}),
"release-channels": definePresentation({
label: "کانال انتشار",
englishLabel: "Release Channel",
pluralLabel: "کانال‌های انتشار",
description: "مسیرهای عرضه نسخه‌های پایدار و آزمایشی",
icon: "➜",
accentColor: "bg-blue-50 text-blue-700",
}),
"support-levels": definePresentation({
label: "سطح پشتیبانی",
englishLabel: "Support Level",
pluralLabel: "سطوح پشتیبانی",
description: "سطوح پاسخ‌گویی و خدمات پشتیبانی",
icon: "☏",
accentColor: "bg-emerald-50 text-emerald-700",
}),
"activation-logs": definePresentation({
label: "فرایند فعال‌سازی",
englishLabel: "Activation Process",
pluralLabel: "فرایندهای فعال‌سازی",
description: "مراحل آماده‌سازی محصولات برای فضای کاری",
icon: "▶",
accentColor: "bg-lime-50 text-lime-700",
}),
} satisfies Record<string, RegistryPresentation>;
export type PresentationRegistryKind = keyof typeof presentationRegistry;
const KIND_ALIASES: Readonly<Record<string, PresentationRegistryKind>> = {
product: "products",
bundle: "bundles",
price: "pricing",
prices: "pricing",
plan: "plans",
capability: "capabilities",
"business-type": "business-types",
recommendations: "recommendation-rules",
recommendation: "recommendation-rules",
"recommendation-rules": "recommendation-rules",
assessments: "assessment-schemas",
"tax-classes": "taxes",
tax: "taxes",
notification: "notifications",
invoice: "invoices",
transaction: "transactions",
license: "licenses",
entitlement: "entitlements",
subscription: "subscriptions",
};
export function normalizeRegistryKind(kind: string): PresentationRegistryKind | null {
const normalized = kind.trim().toLowerCase().replace(/_/g, "-");
if (normalized in presentationRegistry) return normalized as PresentationRegistryKind;
return KIND_ALIASES[normalized] || null;
}
export function getRegistryPresentation(kind: string): RegistryPresentation {
const normalized = normalizeRegistryKind(kind);
if (normalized) return presentationRegistry[normalized];
return definePresentation({
label: "بخش تجاری جدید",
englishLabel: "New Commercial Section",
pluralLabel: "اطلاعات بخش تجاری جدید",
description: "اطلاعات این بخش پس از تکمیل عنوان نمایشی قابل مدیریت است.",
icon: "◈",
accentColor: "bg-slate-100 text-slate-700",
});
}
const TOKEN_LABELS: Readonly<Record<string, string>> = {
restaurant: "رستوران",
hospitality: "رستوران و پذیرایی",
food: "غذا",
beverage: "پذیرایی",
platform: "پلتفرم",
starter: "Starter",
starter: "شروع",
start: "شروع",
growth: "رشد",
professional: "حرفه‌ای",
pro: "حرفه‌ای",
@ -10,42 +439,76 @@ const TOKEN_LABELS: Record<string, string> = {
loyalty: "باشگاه مشتریان",
delivery: "ارسال و پیک",
booking: "رزرو",
reservation: "رزرو",
payment: "پرداخت",
accounting: "حسابداری",
website: "وب‌سایت",
experience: "وب‌سایت و تجربه دیجیتال",
crm: "مدیریت ارتباط با مشتری",
inventory: "انبارداری",
pos: "صندوق فروش",
communication: "ارتباط با مشتریان",
marketplace: "بازار آنلاین",
sports: "مجموعه ورزشی",
"sports-center": "مجموعه ورزشی",
identity: "مدیریت کاربران",
operations: "عملیات",
sales: "فروش",
marketing: "بازاریابی",
finance: "مالی",
customers: "مشتریان",
online: "حضور آنلاین",
online_presence: "حضور آنلاین",
automation: "اتوماسیون",
ai: "هوش مصنوعی",
retail: "خرده‌فروشی",
service: "خدمات",
services: "خدمات",
ecommerce: "فروشگاه آنلاین",
clinic: "مرکز درمانی",
healthcare: "سلامت و درمان",
beauty: "زیبایی و سلامت",
education: "آموزش",
monthly: "ماهانه",
month: "ماهانه",
yearly: "سالانه",
annual: "سالانه",
year: "سالانه",
trial: "آزمایشی",
active: "فعال",
inactive: "غیرفعال",
draft: "پیش‌نویس",
published: "منتشرشده",
pending: "در انتظار",
verified: "تأییدشده",
failed: "ناموفق",
expired: "منقضی‌شده",
trialing: "دوره آزمایشی",
public: "عمومی",
private: "خصوصی",
public: "قابل مشاهده برای همه",
private: "فقط مشتریان مجاز",
hidden: "پنهان",
admin: "فقط مدیران",
route: "داخل سامانه",
external: "پیوند بیرونی",
disabled: "غیرفعال",
deny: "غیرفعال",
allow: "فعال",
enterprise_quote: "استعلام قیمت",
enterprisequote: "استعلام قیمت",
irr: "ریال",
rial: "ریال",
recommended: "پیشنهاد‌شده",
popular: "محبوب",
most_popular: "محبوب‌ترین",
recurring: "اشتراکی",
flat: "مبلغ ثابت",
one_time: "پرداخت یک‌باره",
usage_based: "بر اساس مصرف",
per_user: "به‌ازای هر کاربر",
free: "رایگان",
standard: "استاندارد",
premium: "ویژه",
unlimited: "نامحدود",
required: "ضروری",
optional: "پیشنهادی",
later: "قابل افزودن در آینده",
...STATUS_LABELS,
};
export const FIELD_LABELS: Record<string, string> = {
display_name: "نام نمایشی",
export const FIELD_LABELS: Readonly<Record<string, string>> = {
display_name: "نام",
name: "نام",
title: "عنوان",
description: "توضیح کوتاه",
status: "وضعیت",
@ -59,14 +522,15 @@ export const FIELD_LABELS: Record<string, string> = {
grace_period_days: "مهلت تمدید",
product_codes: "محصولات",
capability_codes: "قابلیت‌ها",
required_capabilities: "قابلیت‌های موردنیاز",
feature_refs: "امکانات",
pricing_refs: "روش‌های پرداخت",
discount_refs: "تخفیف‌ها",
coupon_code: "کد تخفیف",
tax_class_code: "مالیات",
trial_eligible: "دوره آزمایشی",
visibility: مایش",
display_order: "ترتیب نمایش",
tax_class_code: "گروه مالیاتی",
trial_eligible: "امکان استفاده آزمایشی",
visibility: حوه نمایش",
display_order: "اولویت نمایش",
category: "دسته‌بندی",
is_verified: "وضعیت تأیید",
domain_type: "نوع دامنه",
@ -74,6 +538,10 @@ export const FIELD_LABELS: Record<string, string> = {
trial_ends_at: "پایان دوره آزمایشی",
created_at: "تاریخ ایجاد",
updated_at: "آخرین تغییر",
published_at: "تاریخ انتشار",
starts_at: "تاریخ شروع",
ends_at: "تاریخ پایان",
version: "نسخه",
metadata: "اطلاعات تکمیلی",
attributes: "ویژگی‌ها",
parameters: "تنظیمات",
@ -81,14 +549,119 @@ export const FIELD_LABELS: Record<string, string> = {
feature_limits: "محدودیت امکانات",
auto_disable: "غیرفعال‌سازی خودکار",
expiration_behavior: "رفتار پس از پایان",
ssl_ready: "وضعیت SSL",
dns_instructions: "راهنمای DNS",
ssl_ready: "وضعیت گواهی امنیتی",
dns_instructions: "راهنمای اتصال دامنه",
custom_domain_allowed: "دامنه اختصاصی",
subdomain_only: "فقط زیردامنه",
reason: "دلیل پیشنهاد",
priority: "اولویت",
sellable: "قابل فروش",
lifecycle: "مرحله ارائه",
recommended_products: "محصولات ضروری",
recommended_bundles: "بسته‌های پیشنهادی",
recommended_services: "خدمات پیشنهادی",
recommended_upgrades: "پیشنهادهای قابل افزودن",
recommended_automation_packs: "اتوماسیون‌های پیشنهادی",
recommended_extensions: "افزونه‌های پیشنهادی",
score: "میزان تطبیق",
};
const TECHNICAL_FIELD = /(^id$|_id$|_code$|^code$|^source$|^version_code$|^schema_version$|^rule_trace$|^href$|_route$|launch_strategy)/i;
export const COMMERCIAL_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", "lifecycle", "published_at", "starts_at", "ends_at"],
},
{
title: "اطلاعات تکمیلی",
keys: ["metadata", "attributes", "parameters", "usage_limits", "feature_limits"],
},
] as const;
export const FIELD_ENUM_OPTIONS: Readonly<Record<string, readonly string[]>> = {
status: [
"draft",
"active",
"published",
"inactive",
"suspended",
"archived",
],
visibility: ["public", "private", "hidden", "admin"],
pricing_model: [
"monthly",
"yearly",
"trial",
"enterprise_quote",
"one_time",
"usage_based",
],
interval: ["monthly", "yearly"],
currency: ["IRR"],
lifecycle: ["concept", "planned", "beta", "ga", "mature", "deprecated"],
};
const REFERENCE_KIND_BY_FIELD: Readonly<Record<string, PresentationRegistryKind>> = {
product_codes: "products",
products: "products",
recommended_products: "products",
recommended_services: "products",
bundle_codes: "bundles",
bundles: "bundles",
recommended_bundles: "bundles",
pricing_refs: "pricing",
pricing_items: "pricing",
capability_codes: "capabilities",
capabilities: "capabilities",
feature_refs: "capabilities",
required_capabilities: "capabilities",
recommended_upgrades: "products",
recommended_automation_packs: "automation-packs",
recommended_extensions: "extensions",
};
export function registryKindForField(
field?: string | null
): PresentationRegistryKind | null {
if (!field) return null;
return REFERENCE_KIND_BY_FIELD[field] || null;
}
const TECHNICAL_FIELD =
/(^id$|_id$|_code$|^code$|^kind$|^slug$|^stable_code$|^payload$|^raw_json$|^source$|^version_code$|^schema_version$|^rule_trace$|^href$|_route$|launch_strategy|created_by|updated_by|is_deleted|clone_of_id)/i;
export function isTechnicalField(key: string): boolean {
return TECHNICAL_FIELD.test(key);
@ -101,49 +674,137 @@ export function fieldLabel(key: string): string {
.map((token) => TOKEN_LABELS[token.toLowerCase()] || "")
.filter(Boolean)
.join(" ");
return translated || "اطلاعات تکمیلی";
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);
const normalized = raw.toLowerCase().replace(/[-\s]+/g, "_");
const translated = TOKEN_LABELS[normalized] || humanizeCommercialCode(raw);
return translated === "عنوان تجاری ثبت‌نشده" ? "سایر" : translated;
}
function hasPersian(value?: string | null): boolean {
return Boolean(value && /[\u0600-\u06ff]/.test(value));
}
function translatedTokens(tokens: string[]): string[] {
return tokens
.map((token) => TOKEN_LABELS[token.toLowerCase()] || "")
.filter(Boolean);
}
type CodeFormatterContext = {
translated: string[];
subject: string;
interval: string;
};
const CODE_FORMATTERS: Readonly<
Record<string, (context: CodeFormatterContext) => string>
> = {
price: ({ translated, interval }) => {
const business = translated[0] || "کسب‌وکار";
const tier = translated.slice(1).join(" ");
return `پلن ${business}${tier ? ` — نسخه ${tier}` : ""}${interval ? ` (${interval})` : ""}`;
},
pricing: ({ translated, interval }) =>
CODE_FORMATTERS.price({ translated, subject: translated.join(" "), interval }),
plan: ({ translated, interval }) =>
CODE_FORMATTERS.price({ translated, subject: translated.join(" "), interval }),
bundle: ({ translated }) => {
const business = translated.find((token) => token !== "شروع") || "کسب‌وکار";
const tier = translated.includes("شروع") ? "شروع" : translated.slice(1).join(" ");
return `بسته ${tier || "خدمات"} ${business}`.trim();
},
product: ({ subject }) => `سیستم ${subject || "مدیریت کسب‌وکار"}`,
capability: ({ subject }) => subject || "قابلیت کاربردی",
feature: ({ subject }) => subject || "قابلیت کاربردی",
tax: ({ subject }) => subject || "گروه مالیاتی",
coupon: ({ subject }) => subject || "تخفیف",
discount: ({ subject }) => subject || "تخفیف",
};
export function humanizeCommercialCode(code: unknown, explicitName?: string | null): string {
if (explicitName?.trim() && !looksTechnical(explicitName)) return explicitName.trim();
if (code == null) return "مورد تجاری";
if (explicitName && hasPersian(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;
if (!raw) return "عنوان تجاری ثبت‌نشده";
const normalized = raw.toLowerCase().replace(/_/g, "-");
if (TOKEN_LABELS[normalized]) return TOKEN_LABELS[normalized];
const tokens = raw.toLowerCase().split(/[._:/-]+/).filter(Boolean);
const kind = tokens[0];
const translated = tokens
.slice(1)
.map((token) => TOKEN_LABELS[token])
.filter(Boolean);
const body = tokens.slice(1);
const intervalToken = body.find((token) => /^(monthly|month|yearly|annual|year|trial)$/.test(token));
const interval = intervalToken ? TOKEN_LABELS[intervalToken] : "";
const subjectTokens = body.filter((token) => token !== intervalToken);
const translated = translatedTokens(subjectTokens);
const subject = translated.join(" ");
if (kind === "price" || kind === "plan") {
const planSubject = translated.filter((token) => token !== "ماهانه" && token !== "سالانه" && token !== "آزمایشی").join(" ");
return `پلن ${planSubject || "اشتراک"}`.trim();
const formatter = CODE_FORMATTERS[kind];
if (formatter) return formatter({ translated, subject, interval });
if (subject) return subject;
if (hasPersian(raw)) return raw;
return "عنوان تجاری ثبت‌نشده";
}
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 commercialEntityName(
kind: string,
code: unknown,
explicitName?: string | null
): string {
if (explicitName && hasPersian(explicitName)) return explicitName.trim();
const normalizedKind = normalizeRegistryKind(kind);
const raw = code == null ? "" : String(code);
const entityPrefixes: Partial<Record<PresentationRegistryKind, string>> = {
products: "product",
bundles: "bundle",
pricing: "price",
plans: "price",
capabilities: "capability",
};
const prefix = normalizedKind ? entityPrefixes[normalizedKind] : null;
if (prefix) {
const translated = humanizeCommercialCode(
raw.startsWith(`${prefix}.`) ? raw : `${prefix}.${raw}`
);
const genericFallbacks = [
"سیستم مدیریت کسب‌وکار",
"بسته خدمات کسب‌وکار",
"پلن کسب‌وکار",
"قابلیت کاربردی",
];
if (
genericFallbacks.includes(translated) &&
explicitName?.trim() &&
!looksTechnical(explicitName)
) {
return `${getRegistryPresentation(kind).label} ${explicitName.trim()}`;
}
return translated;
}
return humanizeCommercialCode(raw, explicitName);
}
export function commercialDescription(
kind: string,
description?: string | null
): string {
if (description && hasPersian(description)) return description.trim();
return getRegistryPresentation(kind).description;
}
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)
/^[a-z][a-z0-9_-]+$/i.test(value) ||
/\b(payload|json|registry|runtime|frontend|backend|metadata|heuristic|fallback|trace|api)\b/i.test(
value
)
);
}
@ -161,8 +822,13 @@ export function formatDate(value?: string | null): string {
: 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;
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, "اطلاعات تکمیلی");
}
@ -178,11 +844,14 @@ export function recommendationReason(reason?: string | null): string {
return reason;
}
export function presentValue(value: unknown): string | string[] | Record<string, unknown> {
export function presentValue(
value: unknown
): string | Array<string | Record<string, unknown>> | Record<string, unknown> {
if (Array.isArray(value)) {
return value.map((item) =>
typeof item === "string" ? humanizeCommercialCode(item) : enumLabel(item)
);
return value.map((item) => {
if (item && typeof item === "object") return item as Record<string, unknown>;
return 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);