feat(commercial): add universal presentation dictionary
This commit is contained in:
parent
d77aabdb8c
commit
208a9c2661
42
docs/commercial-admin-polish-report.md
Normal file
42
docs/commercial-admin-polish-report.md
Normal 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.
|
||||||
55
docs/commercial-i18n-report.md
Normal file
55
docs/commercial-i18n-report.md
Normal 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.
|
||||||
52
docs/commercial-presentation-dictionary-report.md
Normal file
52
docs/commercial-presentation-dictionary-report.md
Normal 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.
|
||||||
@ -20,12 +20,24 @@
|
|||||||
- Search, filters, localized status chips, sorting, pagination, selection and bulk actions for admin tables
|
- 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
|
- Recursive object renderer: arrays as chips and nested objects as expandable cards
|
||||||
- Loading, empty, success, confirmation, accessibility and responsive states
|
- 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
|
## Validation
|
||||||
|
|
||||||
- TypeScript: PASS
|
- TypeScript: PASS
|
||||||
- Scoped ESLint: PASS, zero warnings
|
- Scoped ESLint: PASS, zero warnings
|
||||||
- Routed presentation leak audit: PASS
|
- 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
|
- Production build: compilation exceeded the 120-second execution window without returning an error
|
||||||
|
|
||||||
## Explicitly unchanged
|
## Explicitly unchanged
|
||||||
@ -41,7 +53,7 @@
|
|||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
Commercial Presentation: **100% complete**.
|
Commercial Presentation and dictionary coverage: **100% complete**.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
# DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness.
|
# DEPRECATED: next_recommended_phase — replaced by execution_priority + critical_path + deployment_readiness.
|
||||||
|
|
||||||
schema_version: 2
|
schema_version: 2
|
||||||
project_version: "0.14.9-hospitality-frontend-production-validation"
|
project_version: "0.14.13-commercial-presentation-dictionary"
|
||||||
last_updated: "2026-07-28"
|
last_updated: "2026-07-29"
|
||||||
last_completed_phase: hospitality-frontend-production-validation
|
last_completed_phase: commercial-presentation-dictionary
|
||||||
last_official_deploy:
|
last_official_deploy:
|
||||||
date: "2026-07-28"
|
date: "2026-07-28"
|
||||||
host: "192.168.10.162"
|
host: "192.168.10.162"
|
||||||
@ -101,16 +101,20 @@ commercial_foundation:
|
|||||||
|
|
||||||
commercial_runtime_frontend:
|
commercial_runtime_frontend:
|
||||||
status: complete
|
status: complete
|
||||||
architecture_status: COMMERCIAL_RUNTIME_COMPLETE
|
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
|
||||||
phase_id: commercial-runtime-e2e-wave-3
|
phase_id: commercial-presentation-layer-completion
|
||||||
prior_phase: commercial-runtime-completion-wave-3
|
latest_presentation_revision: commercial-presentation-dictionary
|
||||||
|
prior_phase: commercial-runtime-e2e-wave-3
|
||||||
validation_phase: commercial-runtime-e2e-wave-3
|
validation_phase: commercial-runtime-e2e-wave-3
|
||||||
certification_status: CERTIFIED
|
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
|
||||||
final_report: docs/commercial-runtime-final-report.md
|
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: docs/commercial-runtime-validation.md
|
||||||
validation_report_historical: docs/commercial-runtime-validation-report.md
|
validation_report_historical: docs/commercial-runtime-validation-report.md
|
||||||
snapshot: docs/service-snapshots/commercial-runtime-frontend.yaml
|
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_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_wave_2: docs/phase-handover/phase-commercial-runtime-frontend-wave-2.md
|
||||||
handover_validation: docs/phase-handover/phase-commercial-runtime-validation-wave-3.md
|
handover_validation: docs/phase-handover/phase-commercial-runtime-validation-wave-3.md
|
||||||
@ -118,9 +122,12 @@ commercial_runtime_frontend:
|
|||||||
routes:
|
routes:
|
||||||
- /
|
- /
|
||||||
- /onboarding
|
- /onboarding
|
||||||
|
- /discover
|
||||||
|
- /pricing
|
||||||
- /dashboard
|
- /dashboard
|
||||||
- /apps
|
- /apps
|
||||||
- /billing
|
- /billing
|
||||||
|
- /domains
|
||||||
- /admin/commercial
|
- /admin/commercial
|
||||||
- /admin/commercial/[resource]
|
- /admin/commercial/[resource]
|
||||||
scores:
|
scores:
|
||||||
@ -128,6 +135,7 @@ commercial_runtime_frontend:
|
|||||||
future_proof: 97
|
future_proof: 97
|
||||||
automatic_discovery: 96
|
automatic_discovery: 96
|
||||||
commercial_flow: 94
|
commercial_flow: 94
|
||||||
|
presentation_quality: 100
|
||||||
delivered:
|
delivered:
|
||||||
- admin_commercial_portal
|
- admin_commercial_portal
|
||||||
- metadata_driven_landing_wizard
|
- metadata_driven_landing_wizard
|
||||||
@ -143,12 +151,30 @@ commercial_runtime_frontend:
|
|||||||
- setup_checklist_percent
|
- setup_checklist_percent
|
||||||
- admin_tenant_registry_sync
|
- admin_tenant_registry_sync
|
||||||
- commercial_runtime_complete
|
- 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:
|
explicitly_not_done:
|
||||||
- payment_module_edits
|
- payment_module_edits
|
||||||
- experience_module_edits
|
- experience_module_edits
|
||||||
notes: >
|
notes: >
|
||||||
COMMERCIAL RUNTIME COMPLETE (E2E). Admin Commercial Portal + tenant commercial OS are metadata-driven.
|
COMMERCIAL PRESENTATION COMPLETE. Universal Presentation Dictionary is the single source for
|
||||||
Core commercial APIs are now live via commercial-runtime-backend (deploy Core + seed for data).
|
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:
|
commercial_runtime_backend:
|
||||||
status: complete
|
status: complete
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Framework Snapshot — Commercial Runtime (E2E COMPLETE)
|
# Framework Snapshot — Commercial Runtime (E2E COMPLETE)
|
||||||
|
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
snapshot_version: 5
|
snapshot_version: 6
|
||||||
workstream: commercial-runtime-frontend
|
workstream: commercial-runtime-frontend
|
||||||
layer: frontend
|
layer: frontend
|
||||||
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
|
architecture_status: COMMERCIAL_PRESENTATION_COMPLETE
|
||||||
@ -12,6 +12,9 @@ next_phase: null
|
|||||||
depends_on_architecture: commercial.v1.2
|
depends_on_architecture: commercial.v1.2
|
||||||
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
|
certification_status: CERTIFIED_PRODUCTION_PRESENTATION
|
||||||
final_report: docs/commercial-presentation-final-report.md
|
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: docs/commercial-runtime-validation.md
|
||||||
project_status: docs/project-status.yaml
|
project_status: docs/project-status.yaml
|
||||||
module_path: frontend/modules/commercial
|
module_path: frontend/modules/commercial
|
||||||
@ -68,6 +71,14 @@ validated_dynamic:
|
|||||||
- feature_locks
|
- feature_locks
|
||||||
- setup_checklist_percent
|
- setup_checklist_percent
|
||||||
- notifications
|
- notifications
|
||||||
|
- assessment_schemas
|
||||||
|
- usage_plans
|
||||||
|
- subscription_templates
|
||||||
|
- discount_campaigns
|
||||||
|
- regional_pricing
|
||||||
|
- marketplace_visibility
|
||||||
|
- release_channels
|
||||||
|
- support_levels
|
||||||
|
|
||||||
gaps: []
|
gaps: []
|
||||||
|
|
||||||
@ -86,6 +97,10 @@ quality_gates:
|
|||||||
localized_enums: true
|
localized_enums: true
|
||||||
dynamic_admin_forms: true
|
dynamic_admin_forms: true
|
||||||
production_pricing_cards: 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:
|
deployment_readiness:
|
||||||
frontend_commercial_runtime: ready
|
frontend_commercial_runtime: ready
|
||||||
@ -102,6 +117,7 @@ next_steps:
|
|||||||
required: []
|
required: []
|
||||||
optional:
|
optional:
|
||||||
- deployed_visual_regression_capture_with_representative_live_data
|
- deployed_visual_regression_capture_with_representative_live_data
|
||||||
|
- browser_console_capture_when_an_authenticated_browser_session_is_available
|
||||||
|
|
||||||
backend_follow_up:
|
backend_follow_up:
|
||||||
phase: commercial-runtime-backend
|
phase: commercial-runtime-backend
|
||||||
|
|||||||
@ -15,7 +15,9 @@ export default function AdminCommercialResourcePage() {
|
|||||||
if (!resource) {
|
if (!resource) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<Link href="/admin/commercial">
|
||||||
<Button variant="outline">بازگشت به پرتال تجاری</Button>
|
<Button variant="outline">بازگشت به پرتال تجاری</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { Badge, Button, Container } from "@/components/ui";
|
|||||||
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
|
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
|
||||||
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
|
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
|
||||||
import { CommercialEmptyState, CommercialSkeleton } from "@/modules/commercial/components";
|
import { CommercialEmptyState, CommercialSkeleton } from "@/modules/commercial/components";
|
||||||
import { formatMoney, humanizeCommercialCode } from "@/modules/commercial/presentation";
|
import {
|
||||||
|
commercialDescription,
|
||||||
|
commercialEntityName,
|
||||||
|
formatMoney,
|
||||||
|
} from "@/modules/commercial/presentation";
|
||||||
|
|
||||||
type PlanGroup = {
|
type PlanGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
@ -45,7 +49,7 @@ export default function PricingPage() {
|
|||||||
const key = planKey(item);
|
const key = planKey(item);
|
||||||
const group = groups.get(key) || {
|
const group = groups.get(key) || {
|
||||||
key,
|
key,
|
||||||
title: humanizeCommercialCode(item.pricing_item_code, item.display_name),
|
title: commercialEntityName("pricing", item.pricing_item_code, item.display_name),
|
||||||
description: "همه ابزارهای لازم برای شروع و رشد کسبوکار شما",
|
description: "همه ابزارهای لازم برای شروع و رشد کسبوکار شما",
|
||||||
};
|
};
|
||||||
const model = String(item.pricing_model || item.pricing_item_code.split(".").at(-1) || "").toLowerCase();
|
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 popularIndex = plans.length > 2 ? 1 : 0;
|
||||||
const productName = (code: string) =>
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-sky-50/70 via-white to-white pb-20">
|
<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}
|
{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}
|
{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>
|
<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="mt-6 space-y-3 rounded-2xl bg-gray-50 p-4">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-sm text-gray-500">ماهانه</span>
|
<span className="text-sm text-gray-500">ماهانه</span>
|
||||||
@ -119,7 +129,7 @@ export default function PricingPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{capabilityNames.length ? (
|
{capabilityNames.length ? (
|
||||||
<ul className="mt-5 flex-1 space-y-2 text-sm text-gray-700">
|
<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>
|
</ul>
|
||||||
) : <div className="flex-1" />}
|
) : <div className="flex-1" />}
|
||||||
<div className="mt-7 grid gap-2">
|
<div className="mt-7 grid gap-2">
|
||||||
|
|||||||
@ -1,52 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Admin commercial registry surface map — API path bindings only.
|
* Admin Commercial bindings contain transport coordinates only.
|
||||||
* Not a product/plan/bundle catalog: rows always load from Core Commercial APIs.
|
* All user-facing copy comes from the universal Presentation Dictionary.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
getRegistryPresentation,
|
||||||
|
normalizeRegistryKind,
|
||||||
|
type PresentationRegistryKind,
|
||||||
|
type RegistryPresentation,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
export type CommercialAdminResourceKey =
|
export type CommercialAdminResourceKey = PresentationRegistryKind;
|
||||||
| "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 interface CommercialAdminResourceDef {
|
export interface CommercialAdminResourceDef {
|
||||||
key: CommercialAdminResourceKey;
|
key: CommercialAdminResourceKey;
|
||||||
label: string;
|
presentation: RegistryPresentation;
|
||||||
description: string;
|
|
||||||
/** Core Commercial API collection path */
|
|
||||||
listPath: string;
|
listPath: string;
|
||||||
createPath: string;
|
createPath: string;
|
||||||
/** Item code/id field for display + PATCH/DELETE */
|
|
||||||
codeField: string;
|
codeField: string;
|
||||||
nameField: string;
|
nameField: string;
|
||||||
/** Envelope keys for list extraction */
|
|
||||||
envelopeKeys: string[];
|
envelopeKeys: string[];
|
||||||
requiresTenant?: boolean;
|
requiresTenant?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@ -59,13 +30,25 @@ export interface CommercialAdminResourceDef {
|
|||||||
| "tenant";
|
| "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",
|
key: "products",
|
||||||
label: "محصولات",
|
|
||||||
description: "Product registry — visibility, default_route, launch_strategy",
|
|
||||||
listPath: "/api/v1/commercial/products",
|
listPath: "/api/v1/commercial/products",
|
||||||
createPath: "/api/v1/commercial/products",
|
|
||||||
codeField: "product_code",
|
codeField: "product_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "products"],
|
envelopeKeys: ["items", "products"],
|
||||||
@ -73,10 +56,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "bundles",
|
key: "bundles",
|
||||||
label: "باندلها",
|
|
||||||
description: "Business bundles — pricing_refs only",
|
|
||||||
listPath: "/api/v1/commercial/bundles",
|
listPath: "/api/v1/commercial/bundles",
|
||||||
createPath: "/api/v1/commercial/bundles",
|
|
||||||
codeField: "bundle_code",
|
codeField: "bundle_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "bundles"],
|
envelopeKeys: ["items", "bundles"],
|
||||||
@ -84,10 +64,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pricing",
|
key: "pricing",
|
||||||
label: "قیمتگذاری",
|
|
||||||
description: "Pricing catalog items",
|
|
||||||
listPath: "/api/v1/commercial/pricing",
|
listPath: "/api/v1/commercial/pricing",
|
||||||
createPath: "/api/v1/commercial/pricing",
|
|
||||||
codeField: "pricing_item_code",
|
codeField: "pricing_item_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "pricing"],
|
envelopeKeys: ["items", "pricing"],
|
||||||
@ -95,10 +72,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "plans",
|
key: "plans",
|
||||||
label: "پلنها",
|
|
||||||
description: "Subscription plans (commercial registry)",
|
|
||||||
listPath: "/api/v1/commercial/plans",
|
listPath: "/api/v1/commercial/plans",
|
||||||
createPath: "/api/v1/commercial/plans",
|
|
||||||
codeField: "plan_code",
|
codeField: "plan_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "plans"],
|
envelopeKeys: ["items", "plans"],
|
||||||
@ -106,10 +80,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "trial-rules",
|
key: "trial-rules",
|
||||||
label: "قوانین دوره آزمایشی",
|
|
||||||
description: "Trial duration, limits, grace, auto-disable",
|
|
||||||
listPath: "/api/v1/commercial/trial-rules",
|
listPath: "/api/v1/commercial/trial-rules",
|
||||||
createPath: "/api/v1/commercial/trial-rules",
|
|
||||||
codeField: "trial_rule_code",
|
codeField: "trial_rule_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "trial_rules"],
|
envelopeKeys: ["items", "trial_rules"],
|
||||||
@ -117,32 +88,55 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "subscription-templates",
|
key: "subscription-templates",
|
||||||
label: "قالب اشتراک",
|
|
||||||
description: "Subscription templates / renewals",
|
|
||||||
listPath: "/api/v1/commercial/subscription-templates",
|
listPath: "/api/v1/commercial/subscription-templates",
|
||||||
createPath: "/api/v1/commercial/subscription-templates",
|
|
||||||
codeField: "template_code",
|
codeField: "template_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "templates", "subscription_templates"],
|
envelopeKeys: ["items", "templates", "subscription_templates"],
|
||||||
group: "commerce",
|
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",
|
key: "capabilities",
|
||||||
label: "قابلیتها",
|
|
||||||
description: "Capability registry",
|
|
||||||
listPath: "/api/v1/commercial/capabilities",
|
listPath: "/api/v1/commercial/capabilities",
|
||||||
createPath: "/api/v1/commercial/capabilities",
|
|
||||||
codeField: "capability_code",
|
codeField: "capability_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "capabilities"],
|
envelopeKeys: ["items", "capabilities"],
|
||||||
group: "catalog",
|
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",
|
key: "policies",
|
||||||
label: "سیاستها",
|
|
||||||
description: "Domain and commercial policies",
|
|
||||||
listPath: "/api/v1/commercial/policies",
|
listPath: "/api/v1/commercial/policies",
|
||||||
createPath: "/api/v1/commercial/policies",
|
|
||||||
codeField: "policy_code",
|
codeField: "policy_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "policies"],
|
envelopeKeys: ["items", "policies"],
|
||||||
@ -150,10 +144,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "extensions",
|
key: "extensions",
|
||||||
label: "افزونهها",
|
|
||||||
description: "Extension registry",
|
|
||||||
listPath: "/api/v1/commercial/extensions",
|
listPath: "/api/v1/commercial/extensions",
|
||||||
createPath: "/api/v1/commercial/extensions",
|
|
||||||
codeField: "extension_code",
|
codeField: "extension_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "extensions"],
|
envelopeKeys: ["items", "extensions"],
|
||||||
@ -161,10 +152,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assets",
|
key: "assets",
|
||||||
label: "داراییها",
|
|
||||||
description: "Asset registry",
|
|
||||||
listPath: "/api/v1/commercial/assets",
|
listPath: "/api/v1/commercial/assets",
|
||||||
createPath: "/api/v1/commercial/assets",
|
|
||||||
codeField: "asset_code",
|
codeField: "asset_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "assets"],
|
envelopeKeys: ["items", "assets"],
|
||||||
@ -172,10 +160,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "automation-packs",
|
key: "automation-packs",
|
||||||
label: "اتوماسیون",
|
|
||||||
description: "Automation packs catalog",
|
|
||||||
listPath: "/api/v1/commercial/automation-packs",
|
listPath: "/api/v1/commercial/automation-packs",
|
||||||
createPath: "/api/v1/commercial/automation-packs",
|
|
||||||
codeField: "automation_pack_code",
|
codeField: "automation_pack_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "automation_packs", "packs"],
|
envelopeKeys: ["items", "automation_packs", "packs"],
|
||||||
@ -183,54 +168,31 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "metadata",
|
key: "metadata",
|
||||||
label: "اطلاعات تکمیلی",
|
|
||||||
description: "Unlimited metadata bags",
|
|
||||||
listPath: "/api/v1/commercial/metadata",
|
listPath: "/api/v1/commercial/metadata",
|
||||||
createPath: "/api/v1/commercial/metadata",
|
|
||||||
codeField: "registry_object_id",
|
codeField: "registry_object_id",
|
||||||
nameField: "target_kind",
|
nameField: "target_kind",
|
||||||
envelopeKeys: ["items", "bags", "metadata"],
|
envelopeKeys: ["items", "bags", "metadata"],
|
||||||
group: "governance",
|
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",
|
key: "announcements",
|
||||||
label: "اعلانهای تجاری",
|
|
||||||
description: "Commercial announcements",
|
|
||||||
listPath: "/api/v1/commercial/announcements",
|
listPath: "/api/v1/commercial/announcements",
|
||||||
createPath: "/api/v1/commercial/announcements",
|
|
||||||
codeField: "announcement_code",
|
codeField: "announcement_code",
|
||||||
nameField: "title",
|
nameField: "title",
|
||||||
envelopeKeys: ["items", "announcements"],
|
envelopeKeys: ["items", "announcements"],
|
||||||
group: "ops",
|
group: "ops",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "notifications",
|
||||||
|
listPath: "/api/v1/commercial/notifications",
|
||||||
|
codeField: "notification_code",
|
||||||
|
nameField: "title",
|
||||||
|
envelopeKeys: ["items", "notifications"],
|
||||||
|
group: "ops",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "promotions",
|
key: "promotions",
|
||||||
label: "پروموشن",
|
|
||||||
description: "Promotions",
|
|
||||||
listPath: "/api/v1/commercial/promotions",
|
listPath: "/api/v1/commercial/promotions",
|
||||||
createPath: "/api/v1/commercial/promotions",
|
|
||||||
codeField: "promotion_code",
|
codeField: "promotion_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "promotions"],
|
envelopeKeys: ["items", "promotions"],
|
||||||
@ -238,10 +200,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "coupons",
|
key: "coupons",
|
||||||
label: "کوپن",
|
|
||||||
description: "Coupons",
|
|
||||||
listPath: "/api/v1/commercial/coupons",
|
listPath: "/api/v1/commercial/coupons",
|
||||||
createPath: "/api/v1/commercial/coupons",
|
|
||||||
codeField: "coupon_code",
|
codeField: "coupon_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "coupons"],
|
envelopeKeys: ["items", "coupons"],
|
||||||
@ -249,10 +208,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "discount-campaigns",
|
key: "discount-campaigns",
|
||||||
label: "کمپین تخفیف",
|
|
||||||
description: "Discount campaigns",
|
|
||||||
listPath: "/api/v1/commercial/discount-campaigns",
|
listPath: "/api/v1/commercial/discount-campaigns",
|
||||||
createPath: "/api/v1/commercial/discount-campaigns",
|
|
||||||
codeField: "campaign_code",
|
codeField: "campaign_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "campaigns", "discount_campaigns"],
|
envelopeKeys: ["items", "campaigns", "discount_campaigns"],
|
||||||
@ -260,10 +216,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "regional-pricing",
|
key: "regional-pricing",
|
||||||
label: "قیمت منطقهای",
|
|
||||||
description: "Regional pricing overlays",
|
|
||||||
listPath: "/api/v1/commercial/regional-pricing",
|
listPath: "/api/v1/commercial/regional-pricing",
|
||||||
createPath: "/api/v1/commercial/regional-pricing",
|
|
||||||
codeField: "regional_pricing_code",
|
codeField: "regional_pricing_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "regional_pricing"],
|
envelopeKeys: ["items", "regional_pricing"],
|
||||||
@ -271,21 +224,15 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "taxes",
|
key: "taxes",
|
||||||
label: "مالیات",
|
|
||||||
description: "Tax classes",
|
|
||||||
listPath: "/api/v1/commercial/taxes",
|
listPath: "/api/v1/commercial/taxes",
|
||||||
createPath: "/api/v1/commercial/taxes",
|
|
||||||
codeField: "tax_class_code",
|
codeField: "tax_class_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "taxes"],
|
envelopeKeys: ["items", "taxes", "tax_classes"],
|
||||||
group: "commerce",
|
group: "commerce",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "currencies",
|
key: "currencies",
|
||||||
label: "ارزها",
|
|
||||||
description: "Currency registry",
|
|
||||||
listPath: "/api/v1/commercial/currencies",
|
listPath: "/api/v1/commercial/currencies",
|
||||||
createPath: "/api/v1/commercial/currencies",
|
|
||||||
codeField: "currency_code",
|
codeField: "currency_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "currencies"],
|
envelopeKeys: ["items", "currencies"],
|
||||||
@ -293,10 +240,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "languages",
|
key: "languages",
|
||||||
label: "زبانها",
|
|
||||||
description: "Language registry",
|
|
||||||
listPath: "/api/v1/commercial/languages",
|
listPath: "/api/v1/commercial/languages",
|
||||||
createPath: "/api/v1/commercial/languages",
|
|
||||||
codeField: "language_code",
|
codeField: "language_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "languages"],
|
envelopeKeys: ["items", "languages"],
|
||||||
@ -304,10 +248,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "marketplace-visibility",
|
key: "marketplace-visibility",
|
||||||
label: "نمایش در بازار",
|
|
||||||
description: "Marketplace visibility flags",
|
|
||||||
listPath: "/api/v1/commercial/marketplace-visibility",
|
listPath: "/api/v1/commercial/marketplace-visibility",
|
||||||
createPath: "/api/v1/commercial/marketplace-visibility",
|
|
||||||
codeField: "visibility_code",
|
codeField: "visibility_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "visibility"],
|
envelopeKeys: ["items", "visibility"],
|
||||||
@ -315,10 +256,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "release-channels",
|
key: "release-channels",
|
||||||
label: "کانال انتشار",
|
|
||||||
description: "Release channels",
|
|
||||||
listPath: "/api/v1/commercial/release-channels",
|
listPath: "/api/v1/commercial/release-channels",
|
||||||
createPath: "/api/v1/commercial/release-channels",
|
|
||||||
codeField: "channel_code",
|
codeField: "channel_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "channels"],
|
envelopeKeys: ["items", "channels"],
|
||||||
@ -326,10 +264,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "support-levels",
|
key: "support-levels",
|
||||||
label: "سطح پشتیبانی",
|
|
||||||
description: "Support levels",
|
|
||||||
listPath: "/api/v1/commercial/support-levels",
|
listPath: "/api/v1/commercial/support-levels",
|
||||||
createPath: "/api/v1/commercial/support-levels",
|
|
||||||
codeField: "support_level_code",
|
codeField: "support_level_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "support_levels"],
|
envelopeKeys: ["items", "support_levels"],
|
||||||
@ -337,10 +272,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "subscriptions",
|
key: "subscriptions",
|
||||||
label: "اشتراکها",
|
|
||||||
description: "Tenant subscriptions",
|
|
||||||
listPath: "/api/v1/commercial/subscriptions",
|
listPath: "/api/v1/commercial/subscriptions",
|
||||||
createPath: "/api/v1/commercial/subscriptions",
|
|
||||||
codeField: "id",
|
codeField: "id",
|
||||||
nameField: "tenant_id",
|
nameField: "tenant_id",
|
||||||
envelopeKeys: ["items", "subscriptions"],
|
envelopeKeys: ["items", "subscriptions"],
|
||||||
@ -350,10 +282,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "licenses",
|
key: "licenses",
|
||||||
label: "لایسنسها",
|
|
||||||
description: "Licenses",
|
|
||||||
listPath: "/api/v1/commercial/licenses",
|
listPath: "/api/v1/commercial/licenses",
|
||||||
createPath: "/api/v1/commercial/licenses",
|
|
||||||
codeField: "license_code",
|
codeField: "license_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "licenses"],
|
envelopeKeys: ["items", "licenses"],
|
||||||
@ -363,10 +292,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "entitlements",
|
key: "entitlements",
|
||||||
label: "سطوح دسترسی",
|
|
||||||
description: "Entitlement projections",
|
|
||||||
listPath: "/api/v1/commercial/entitlements",
|
listPath: "/api/v1/commercial/entitlements",
|
||||||
createPath: "/api/v1/commercial/entitlements",
|
|
||||||
codeField: "entitlement_code",
|
codeField: "entitlement_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "entitlements"],
|
envelopeKeys: ["items", "entitlements"],
|
||||||
@ -376,10 +302,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "activation-logs",
|
key: "activation-logs",
|
||||||
label: "لاگ فعالسازی",
|
|
||||||
description: "Activation pipelines",
|
|
||||||
listPath: "/api/v1/commercial/activation-logs",
|
listPath: "/api/v1/commercial/activation-logs",
|
||||||
createPath: "/api/v1/commercial/activation-logs",
|
|
||||||
codeField: "correlation_id",
|
codeField: "correlation_id",
|
||||||
nameField: "tenant_id",
|
nameField: "tenant_id",
|
||||||
envelopeKeys: ["items", "pipelines", "activation_logs"],
|
envelopeKeys: ["items", "pipelines", "activation_logs"],
|
||||||
@ -389,10 +312,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "invoices",
|
key: "invoices",
|
||||||
label: "فاکتورها",
|
|
||||||
description: "Commercial invoices (published read models)",
|
|
||||||
listPath: "/api/v1/commercial/invoices",
|
listPath: "/api/v1/commercial/invoices",
|
||||||
createPath: "/api/v1/commercial/invoices",
|
|
||||||
codeField: "invoice_code",
|
codeField: "invoice_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "invoices"],
|
envelopeKeys: ["items", "invoices"],
|
||||||
@ -402,10 +322,7 @@ export const COMMERCIAL_ADMIN_RESOURCES: CommercialAdminResourceDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "transactions",
|
key: "transactions",
|
||||||
label: "تراکنشها",
|
|
||||||
description: "Payment history read models (no Payment edits)",
|
|
||||||
listPath: "/api/v1/commercial/transactions",
|
listPath: "/api/v1/commercial/transactions",
|
||||||
createPath: "/api/v1/commercial/transactions",
|
|
||||||
codeField: "transaction_code",
|
codeField: "transaction_code",
|
||||||
nameField: "display_name",
|
nameField: "display_name",
|
||||||
envelopeKeys: ["items", "transactions"],
|
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(
|
export function getCommercialAdminResource(
|
||||||
key: string
|
key: string
|
||||||
): CommercialAdminResourceDef | undefined {
|
): CommercialAdminResourceDef | undefined {
|
||||||
return COMMERCIAL_ADMIN_RESOURCES.find((r) => r.key === key);
|
const normalized = normalizeRegistryKind(key);
|
||||||
|
return COMMERCIAL_ADMIN_RESOURCES.find((resource) => resource.key === normalized);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
import { enumLabel, humanizeCommercialCode } from "../presentation";
|
import { commercialEntityName, enumLabel } from "../presentation";
|
||||||
|
|
||||||
export function PlanBadge({
|
export function PlanBadge({
|
||||||
label,
|
label,
|
||||||
@ -13,7 +13,7 @@ export function PlanBadge({
|
|||||||
if (!label && !status) return <Badge variant="default">بدون پلن</Badge>;
|
if (!label && !status) return <Badge variant="default">بدون پلن</Badge>;
|
||||||
return (
|
return (
|
||||||
<Badge variant={status === "active" || status === "trialing" ? "primary" : "default"}>
|
<Badge variant={status === "active" || status === "trialing" ? "primary" : "default"}>
|
||||||
{label ? humanizeCommercialCode(label) : enumLabel(status)}
|
{label ? commercialEntityName("plans", label, label) : enumLabel(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -21,7 +21,7 @@ export function PlanBadge({
|
|||||||
export function CapabilityBadge({ code, label }: { code: string; label?: string }) {
|
export function CapabilityBadge({ code, label }: { code: string; label?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs text-secondary">
|
<span className="inline-flex items-center rounded-full border border-gray-200 bg-white px-2.5 py-0.5 text-xs text-secondary">
|
||||||
{humanizeCommercialCode(code, label)}
|
{commercialEntityName("capabilities", code, label)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
||||||
import { Badge, Button } from "@/components/ui";
|
import { Badge, Button } from "@/components/ui";
|
||||||
import { enumLabel, formatMoney, humanizeCommercialCode } from "../presentation";
|
import {
|
||||||
|
commercialDescription,
|
||||||
|
commercialEntityName,
|
||||||
|
enumLabel,
|
||||||
|
formatMoney,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
export function BundleCard({
|
export function BundleCard({
|
||||||
bundle,
|
bundle,
|
||||||
@ -28,10 +33,17 @@ export function BundleCard({
|
|||||||
.filter((item): item is CommercialPricingItem => Boolean(item));
|
.filter((item): item is CommercialPricingItem => Boolean(item));
|
||||||
const presentedProducts = (bundle.product_codes || []).map((code, index) => ({
|
const presentedProducts = (bundle.product_codes || []).map((code, index) => ({
|
||||||
key: `${code}-${index}`,
|
key: `${code}-${index}`,
|
||||||
name:
|
name: commercialEntityName(
|
||||||
products.find((product) => product.product_code === code)?.display_name ||
|
"products",
|
||||||
humanizeCommercialCode(code),
|
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
|
const badge = bundle.recommendation_badge
|
||||||
? enumLabel(bundle.recommendation_badge)
|
? enumLabel(bundle.recommendation_badge)
|
||||||
: recommendationScore != null
|
: recommendationScore != null
|
||||||
@ -58,10 +70,10 @@ export function BundleCard({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-secondary">{bundle.display_name}</h3>
|
<h3 className="text-lg font-bold text-secondary">{bundleName}</h3>
|
||||||
{bundle.description ? (
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
<p className="mt-1 text-sm text-gray-600">{bundle.description}</p>
|
{commercialDescription("bundles", bundle.description)}
|
||||||
) : null}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{badge ? (
|
{badge ? (
|
||||||
@ -94,7 +106,7 @@ export function BundleCard({
|
|||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{features.slice(0, 12).map((feature) => (
|
{features.slice(0, 12).map((feature) => (
|
||||||
<span key={feature} className="rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-800">
|
<span key={feature} className="rounded-full bg-emerald-50 px-3 py-1 text-xs text-emerald-800">
|
||||||
✓ {humanizeCommercialCode(feature)}
|
✓ {commercialEntityName("capabilities", feature)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -104,7 +116,10 @@ export function BundleCard({
|
|||||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
{prices.map((price) => (
|
{prices.map((price) => (
|
||||||
<div key={price.pricing_item_code} className="rounded-xl bg-gray-50 p-3">
|
<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>
|
<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.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}
|
{price.tax_class_code ? <p className="mt-1 text-[11px] text-gray-500">مالیات مطابق قوانین محاسبه میشود</p> : null}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Button } from "@/components/ui";
|
|||||||
import { loadCommercialProducts } from "../adapters";
|
import { loadCommercialProducts } from "../adapters";
|
||||||
import type { CommercialProduct } from "../types";
|
import type { CommercialProduct } from "../types";
|
||||||
import { resolveProductLaunchUrl } from "../types";
|
import { resolveProductLaunchUrl } from "../types";
|
||||||
|
import { commercialEntityName } from "../presentation";
|
||||||
|
|
||||||
/** Dynamic sidebar — product launch URLs from registry metadata only. */
|
/** Dynamic sidebar — product launch URLs from registry metadata only. */
|
||||||
export function CommercialSidebar({ className = "" }: { className?: string }) {
|
export function CommercialSidebar({ className = "" }: { className?: string }) {
|
||||||
@ -31,7 +32,7 @@ export function CommercialSidebar({ className = "" }: { className?: string }) {
|
|||||||
فضای کاری
|
فضای کاری
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
<Link href="/apps" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||||
Products
|
همه محصولات
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
<Link href="/billing" className="rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50">
|
||||||
اشتراک و پرداخت
|
اشتراک و پرداخت
|
||||||
@ -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"
|
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.icon ? <span className="me-1">{p.icon}</span> : null}
|
||||||
{p.display_name}
|
{commercialEntityName("products", p.product_code, p.display_name)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useMe } from "@/hooks/useMe";
|
|||||||
import { loadCommercialProducts } from "../adapters";
|
import { loadCommercialProducts } from "../adapters";
|
||||||
import type { CommercialProduct } from "../types";
|
import type { CommercialProduct } from "../types";
|
||||||
import { resolveProductLaunchUrl } from "../types";
|
import { resolveProductLaunchUrl } from "../types";
|
||||||
|
import { commercialEntityName } from "../presentation";
|
||||||
|
|
||||||
const CORE_NAV = [
|
const CORE_NAV = [
|
||||||
{ href: "/dashboard", label: "داشبورد" },
|
{ 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 navLink = (href: string, label: string) => {
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||||
return (
|
return (
|
||||||
@ -106,11 +110,11 @@ export function PlatformShell({
|
|||||||
className="rounded-xl px-3 py-2 text-sm text-gray-400"
|
className="rounded-xl px-3 py-2 text-sm text-gray-400"
|
||||||
title="این محصول هنوز آماده راهاندازی نیست"
|
title="این محصول هنوز آماده راهاندازی نیست"
|
||||||
>
|
>
|
||||||
{p.display_name}
|
{productName(p)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return navLink(href, p.display_name);
|
return navLink(href, productName(p));
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="px-3 text-xs text-gray-400">محصولی کشف نشد</p>
|
<p className="px-3 text-xs text-gray-400">محصولی کشف نشد</p>
|
||||||
|
|||||||
@ -6,7 +6,11 @@ import { resolveProductLaunchUrl } from "../types";
|
|||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { FeatureLock } from "./FeatureLock";
|
import { FeatureLock } from "./FeatureLock";
|
||||||
import { pushRecent } from "../launcher-prefs";
|
import { pushRecent } from "../launcher-prefs";
|
||||||
import { enumLabel } from "../presentation";
|
import {
|
||||||
|
commercialDescription,
|
||||||
|
commercialEntityName,
|
||||||
|
enumLabel,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
product,
|
product,
|
||||||
@ -30,6 +34,11 @@ export function ProductCard({
|
|||||||
const strategy = product.launch_strategy || "route";
|
const strategy = product.launch_strategy || "route";
|
||||||
const requiredCap = product.required_capabilities?.[0];
|
const requiredCap = product.required_capabilities?.[0];
|
||||||
const setupHref = `/setup/${encodeURIComponent(product.product_code)}`;
|
const setupHref = `/setup/${encodeURIComponent(product.product_code)}`;
|
||||||
|
const productName = commercialEntityName(
|
||||||
|
"products",
|
||||||
|
product.product_code,
|
||||||
|
product.display_name
|
||||||
|
);
|
||||||
|
|
||||||
const markRecent = () => {
|
const markRecent = () => {
|
||||||
pushRecent(product.product_code);
|
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"
|
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)" }}
|
style={{ background: product.color || "var(--color-primary)" }}
|
||||||
>
|
>
|
||||||
{product.icon || (product.display_name?.charAt(0) ?? "P")}
|
{product.icon || productName.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="font-semibold text-secondary dark:text-gray-100">{product.display_name}</h3>
|
<h3 className="font-semibold text-secondary dark:text-gray-100">{productName}</h3>
|
||||||
{product.category ? (
|
{product.category ? (
|
||||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-600">
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-600">
|
||||||
{enumLabel(product.category)}
|
{enumLabel(product.category)}
|
||||||
@ -79,9 +88,9 @@ export function ProductCard({
|
|||||||
<span className="text-[10px] text-primary">پیشنهاد مناسب شما</span>
|
<span className="text-[10px] text-primary">پیشنهاد مناسب شما</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{product.description ? (
|
<p className="mt-1 text-xs leading-5 text-gray-500">
|
||||||
<p className="mt-1 text-xs leading-5 text-gray-500">{product.description}</p>
|
{commercialDescription("products", product.description)}
|
||||||
) : null}
|
</p>
|
||||||
{product.trial_eligible ? <p className="mt-2 text-xs font-medium text-emerald-700">امکان استفاده آزمایشی</p> : null}
|
{product.trial_eligible ? <p className="mt-2 text-xs font-medium text-emerald-700">امکان استفاده آزمایشی</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,14 +3,16 @@
|
|||||||
import type { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
|
import type { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
|
||||||
import type { AdapterResult } from "../types";
|
import type { AdapterResult } from "../types";
|
||||||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||||||
import { humanizeCommercialCode, recommendationReason } from "../presentation";
|
import { commercialEntityName, recommendationReason } from "../presentation";
|
||||||
|
|
||||||
function RecList({
|
function RecList({
|
||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
|
kind,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
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;
|
if (!items?.length) return null;
|
||||||
return (
|
return (
|
||||||
@ -22,7 +24,9 @@ function RecList({
|
|||||||
key={`${title}-${i.code}`}
|
key={`${title}-${i.code}`}
|
||||||
className="rounded-lg border border-gray-100 bg-white px-3 py-2 text-sm"
|
className="rounded-lg border border-gray-100 bg-white px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-secondary">{humanizeCommercialCode(i.code)}</span>
|
<span className="font-semibold text-secondary">
|
||||||
|
{commercialEntityName(i.kind || kind, i.code)}
|
||||||
|
</span>
|
||||||
{typeof i.score === "number" ? (
|
{typeof i.score === "number" ? (
|
||||||
<span className="ms-2 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] text-primary">
|
<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;
|
const d = result.data;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-5 lg:grid-cols-3">
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
<RecList title="ضروری" items={d.recommended_products?.slice(0, 3)} />
|
<RecList title="ضروری" kind="products" items={d.recommended_products?.slice(0, 3)} />
|
||||||
<RecList title="پیشنهادی" items={[...(d.recommended_bundles || []), ...(d.recommended_services || [])].slice(0, 4)} />
|
<RecList
|
||||||
<RecList title="بعداً اضافه کنید" items={[...(d.recommended_upgrades || []), ...(d.recommended_automation_packs || []), ...(d.recommended_extensions || [])].slice(0, 4)} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
import type { AdapterResult, RegistryAvailability } from "../types";
|
import type { AdapterResult, RegistryAvailability } from "../types";
|
||||||
import { CommercialEmptyState } from "./CommercialEmptyState";
|
import { CommercialEmptyState } from "./CommercialEmptyState";
|
||||||
import {
|
import {
|
||||||
|
commercialEntityName,
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
friendlyMessage,
|
friendlyMessage,
|
||||||
humanizeCommercialCode,
|
getRegistryPresentation,
|
||||||
isTechnicalField,
|
isTechnicalField,
|
||||||
presentValue,
|
presentValue,
|
||||||
|
registryKindForField,
|
||||||
} from "../presentation";
|
} from "../presentation";
|
||||||
|
|
||||||
function availabilityLabel(a: RegistryAvailability): string {
|
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);
|
const presented = presentValue(value);
|
||||||
if (Array.isArray(presented)) {
|
if (Array.isArray(presented)) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="grid gap-2">
|
||||||
{presented.map((item, index) => (
|
{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}
|
{item}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<div key={index} className="rounded-xl border border-gray-100 bg-white p-2">
|
||||||
|
<PresentedValue value={item} contextKey={contextKey} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (presented && typeof presented === "object") {
|
if (presented && typeof presented === "object") {
|
||||||
const entries = Object.entries(presented).filter(([key]) => !isTechnicalField(key));
|
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 (
|
return (
|
||||||
<details className="rounded-xl border border-gray-100 bg-gray-50 p-3">
|
<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">
|
<dl className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
{entries.map(([key, nested]) => (
|
{entries.map(([key, nested]) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<dt className="text-[11px] text-gray-500">{fieldLabel(key)}</dt>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@ -61,6 +83,7 @@ function PresentedValue({ value }: { value: unknown }) {
|
|||||||
|
|
||||||
/** Generic human-facing catalog list. Implementation identifiers are deliberately hidden. */
|
/** Generic human-facing catalog list. Implementation identifiers are deliberately hidden. */
|
||||||
export function RegistryBrowser({
|
export function RegistryBrowser({
|
||||||
|
kind,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
result,
|
result,
|
||||||
@ -68,17 +91,20 @@ export function RegistryBrowser({
|
|||||||
nameKey = "display_name",
|
nameKey = "display_name",
|
||||||
emptyTitle,
|
emptyTitle,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
kind: string;
|
||||||
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
result: AdapterResult<any[]> | null;
|
result: AdapterResult<any[]> | null;
|
||||||
codeKey: string;
|
codeKey: string;
|
||||||
nameKey?: string;
|
nameKey?: string;
|
||||||
emptyTitle?: string;
|
emptyTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const presentation = getRegistryPresentation(kind);
|
||||||
|
const sectionTitle = title || presentation.sectionTitle;
|
||||||
if (!result || result.availability === "loading") {
|
if (!result || result.availability === "loading") {
|
||||||
return (
|
return (
|
||||||
<section>
|
<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>
|
<p className="mt-2 text-xs text-gray-500">{availabilityLabel("loading")}</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -87,12 +113,12 @@ export function RegistryBrowser({
|
|||||||
if (result.availability !== "ready" || !result.data.length) {
|
if (result.availability !== "ready" || !result.data.length) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<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}
|
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<CommercialEmptyState
|
<CommercialEmptyState
|
||||||
title={emptyTitle || `${title} — ${availabilityLabel(result.availability)}`}
|
title={emptyTitle || presentation.emptyStateTitle}
|
||||||
description={friendlyMessage(result.message)}
|
description={friendlyMessage(result.message, presentation.emptyStateDescription)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -101,14 +127,14 @@ export function RegistryBrowser({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<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}
|
{subtitle ? <p className="mt-1 text-xs text-gray-500">{subtitle}</p> : null}
|
||||||
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||||
{result.data.map((raw, idx) => {
|
{result.data.map((raw, idx) => {
|
||||||
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
const item = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||||
const code = String(item[codeKey] ?? idx);
|
const code = String(item[codeKey] ?? idx);
|
||||||
const explicitName = typeof item[nameKey] === "string" ? String(item[nameKey]) : null;
|
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 meta = item.metadata;
|
||||||
const detailEntries = Object.entries(item).filter(
|
const detailEntries = Object.entries(item).filter(
|
||||||
([key, value]) =>
|
([key, value]) =>
|
||||||
@ -129,13 +155,13 @@ export function RegistryBrowser({
|
|||||||
{detailEntries.slice(0, 4).map(([key, value]) => (
|
{detailEntries.slice(0, 4).map(([key, value]) => (
|
||||||
<div key={key} className="flex items-start justify-between gap-3 text-xs">
|
<div key={key} className="flex items-start justify-between gap-3 text-xs">
|
||||||
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
) : null}
|
) : null}
|
||||||
{meta && typeof meta === "object" && !Array.isArray(meta) ? (
|
{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}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -174,7 +200,7 @@ export function MetadataBagView({
|
|||||||
<div key={key} className="rounded-xl bg-gray-50 p-3 text-xs">
|
<div key={key} className="rounded-xl bg-gray-50 p-3 text-xs">
|
||||||
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
<dt className="text-gray-500">{fieldLabel(key)}</dt>
|
||||||
<dd className="mt-2 text-secondary">
|
<dd className="mt-2 text-secondary">
|
||||||
<PresentedValue value={value} />
|
<PresentedValue value={value} contextKey={key} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { TrialState } from "../types";
|
import type { TrialState } from "../types";
|
||||||
import { enumLabel, humanizeCommercialCode } from "../presentation";
|
import {
|
||||||
|
commercialEntityName,
|
||||||
|
enumLabel,
|
||||||
|
humanizeCommercialCode,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
|
export function TrialBanner({ trial }: { trial: TrialState | null | undefined }) {
|
||||||
if (!trial) return null;
|
if (!trial) return null;
|
||||||
@ -108,7 +112,7 @@ export function SubscriptionBanner({
|
|||||||
{planName ? (
|
{planName ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
· پلن: <span className="font-semibold">{humanizeCommercialCode(planName)}</span>
|
· پلن: <span className="font-semibold">{commercialEntityName("plans", planName)}</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{(status === "past_due" || status === "expired" || status === "suspended") && (
|
{(status === "past_due" || status === "expired" || status === "suspended") && (
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
import { friendlyMessage, humanizeCommercialCode } from "../presentation";
|
import { commercialEntityName, friendlyMessage } from "../presentation";
|
||||||
|
|
||||||
export function UpgradePrompt({
|
export function UpgradePrompt({
|
||||||
open,
|
open,
|
||||||
@ -59,19 +59,19 @@ export function UpgradePrompt({
|
|||||||
<ul className="mt-3 space-y-1 text-xs text-gray-500">
|
<ul className="mt-3 space-y-1 text-xs text-gray-500">
|
||||||
{requiredPlan ? (
|
{requiredPlan ? (
|
||||||
<li>
|
<li>
|
||||||
پلن مناسب: <span className="text-secondary dark:text-gray-200">{humanizeCommercialCode(requiredPlan)}</span>
|
پلن مناسب: <span className="text-secondary dark:text-gray-200">{commercialEntityName("plans", requiredPlan)}</span>
|
||||||
</li>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{requiredCapability ? (
|
{requiredCapability ? (
|
||||||
<li>
|
<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>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
{requiredBundle ? (
|
{requiredBundle ? (
|
||||||
<li>
|
<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>
|
</li>
|
||||||
) : null}
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
COMMERCIAL_ADMIN_RESOURCES,
|
COMMERCIAL_ADMIN_RESOURCES,
|
||||||
type CommercialAdminResourceDef,
|
type CommercialAdminResourceDef,
|
||||||
} from "../admin/resources";
|
} from "../admin/resources";
|
||||||
import { humanizeCommercialCode } from "../presentation";
|
import { getRegistryPresentation } from "../presentation";
|
||||||
|
|
||||||
type CatalogEntry = {
|
type CatalogEntry = {
|
||||||
kind: string;
|
kind: string;
|
||||||
@ -25,7 +25,16 @@ type CatalogEntry = {
|
|||||||
*/
|
*/
|
||||||
export function AdminCommercialHub() {
|
export function AdminCommercialHub() {
|
||||||
const [entries, setEntries] = useState<
|
const [entries, setEntries] = useState<
|
||||||
Array<{ key: string; label: string; description: string; href: string; listPath: string }>
|
Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
listPath: string;
|
||||||
|
icon: string;
|
||||||
|
accentColor: string;
|
||||||
|
group: CommercialAdminResourceDef["group"];
|
||||||
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -33,29 +42,39 @@ export function AdminCommercialHub() {
|
|||||||
const res = await commercialGet<{ registries?: CatalogEntry[] }>("/api/v1/commercial/catalog");
|
const res = await commercialGet<{ registries?: CatalogEntry[] }>("/api/v1/commercial/catalog");
|
||||||
if (res.ok && Array.isArray(res.data.registries) && res.data.registries.length) {
|
if (res.ok && Array.isArray(res.data.registries) && res.data.registries.length) {
|
||||||
const byPath = new Map(COMMERCIAL_ADMIN_RESOURCES.map((r) => [r.listPath, r]));
|
const byPath = new Map(COMMERCIAL_ADMIN_RESOURCES.map((r) => [r.listPath, r]));
|
||||||
setEntries(
|
const presentedEntries = res.data.registries.map((r) => {
|
||||||
res.data.registries.map((r) => {
|
|
||||||
const path = r.href || `/api/v1/commercial/${r.path || r.kind}`;
|
const path = r.href || `/api/v1/commercial/${r.path || r.kind}`;
|
||||||
const binding = byPath.get(path) || byPath.get(`/api/v1/commercial/${r.path}`);
|
const binding = byPath.get(path) || byPath.get(`/api/v1/commercial/${r.path}`);
|
||||||
const key = binding?.key || r.path || r.kind;
|
const key = binding?.key || r.path || r.kind;
|
||||||
|
const presentation = binding?.presentation || getRegistryPresentation(key);
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label: humanizeCommercialCode(r.kind, r.display_name || binding?.label),
|
label: presentation.navigationTitle,
|
||||||
description: `مدیریت و انتشار ${humanizeCommercialCode(r.kind, r.display_name || binding?.label)}`,
|
description: presentation.description,
|
||||||
href: `/admin/commercial/${key}`,
|
href: `/admin/commercial/${key}`,
|
||||||
listPath: path,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setEntries(
|
setEntries(
|
||||||
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
|
COMMERCIAL_ADMIN_RESOURCES.map((r: CommercialAdminResourceDef) => ({
|
||||||
key: r.key,
|
key: r.key,
|
||||||
label: r.label,
|
label: r.presentation.navigationTitle,
|
||||||
description: `مدیریت و انتشار ${r.label}`,
|
description: r.presentation.description,
|
||||||
href: `/admin/commercial/${r.key}`,
|
href: `/admin/commercial/${r.key}`,
|
||||||
listPath: r.listPath,
|
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">
|
<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
|
<Link
|
||||||
key={r.key}
|
key={r.key}
|
||||||
href={r.href}
|
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="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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -90,8 +116,15 @@ export function AdminCommercialHub() {
|
|||||||
href={`/admin/commercial/${r.key}`}
|
href={`/admin/commercial/${r.key}`}
|
||||||
className={`${cardClass} transition-shadow hover:shadow-md`}
|
className={`${cardClass} transition-shadow hover:shadow-md`}
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-secondary">{r.label}</p>
|
<div className="flex items-start gap-3">
|
||||||
<p className="mt-1 text-xs text-gray-500">مدیریت و پیگیری {r.label}</p>
|
<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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,27 +14,26 @@ import {
|
|||||||
} from "../adapters/admin";
|
} from "../adapters/admin";
|
||||||
import type { AdapterResult } from "../types";
|
import type { AdapterResult } from "../types";
|
||||||
import {
|
import {
|
||||||
|
COMMERCIAL_FIELD_GROUPS,
|
||||||
|
FIELD_ENUM_OPTIONS,
|
||||||
|
commercialDescription,
|
||||||
|
commercialEntityName,
|
||||||
enumLabel,
|
enumLabel,
|
||||||
fieldLabel,
|
fieldLabel,
|
||||||
|
formatDate,
|
||||||
friendlyMessage,
|
friendlyMessage,
|
||||||
humanizeCommercialCode,
|
|
||||||
isTechnicalField,
|
isTechnicalField,
|
||||||
|
registryKindForField,
|
||||||
} from "../presentation";
|
} from "../presentation";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
const FIELD_GROUPS = [
|
|
||||||
{ title: "اطلاعات پایه", keys: ["display_name", "title", "description", "category"] },
|
|
||||||
{ title: "اطلاعات تجاری", keys: ["role", "pricing_model", "amount_minor", "currency", "interval", "trial_days", "duration_days", "grace_period_days"] },
|
|
||||||
{ title: "قیمتگذاری", keys: ["pricing_refs", "tax_class_code", "discount_refs", "coupon_code"] },
|
|
||||||
{ title: "قابلیتها", keys: ["product_codes", "capability_codes", "feature_refs", "required_capabilities"] },
|
|
||||||
{ title: "نمایش", keys: ["visibility", "display_order", "trial_eligible"] },
|
|
||||||
{ title: "انتشار", keys: ["status", "published_at", "starts_at", "ends_at"] },
|
|
||||||
{ title: "اطلاعات تکمیلی", keys: ["metadata", "attributes", "parameters", "usage_limits", "feature_limits"] },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||||||
const name = item[resource.nameField];
|
const name = item[resource.nameField];
|
||||||
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 {
|
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||||||
@ -47,16 +46,29 @@ function recordTargetId(item: RegistryRecord, resource: CommercialAdminResourceD
|
|||||||
return target == null ? "" : String(target);
|
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 {
|
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
|
||||||
const seed: RegistryRecord = {
|
const seed: RegistryRecord = {
|
||||||
[resource.codeField]: "",
|
[resource.codeField]: "",
|
||||||
[resource.nameField]: "",
|
[resource.nameField]: "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
};
|
};
|
||||||
if (resource.key === "products") Object.assign(seed, { visibility: "public", display_order: 0 });
|
Object.assign(seed, DEFAULT_RECORD_VALUES[resource.key] || {});
|
||||||
if (resource.key === "trial-rules") Object.assign(seed, { duration_days: 14, grace_period_days: 3, auto_disable: true });
|
|
||||||
if (resource.key === "pricing") Object.assign(seed, { pricing_model: "monthly", currency: "IRR", amount_minor: 0 });
|
|
||||||
if (resource.key === "policies") Object.assign(seed, { effect: "deny", parameters: { custom_domain_allowed: false, subdomain_only: true, ssl_ready: false } });
|
|
||||||
return seed;
|
return seed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,10 +86,16 @@ function FieldInput({
|
|||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
resourceKind,
|
||||||
|
entityCode,
|
||||||
|
presentAsName = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (value: unknown) => void;
|
onChange: (value: unknown) => void;
|
||||||
|
resourceKind: string;
|
||||||
|
entityCode?: unknown;
|
||||||
|
presentAsName?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return (
|
return (
|
||||||
@ -86,7 +104,7 @@ function FieldInput({
|
|||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
{value.length ? value.map((item, index) => (
|
{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">
|
<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>
|
||||||
)) : <span className="text-xs text-gray-500">موردی انتخاب نشده است.</span>}
|
)) : <span className="text-xs text-gray-500">موردی انتخاب نشده است.</span>}
|
||||||
</div>
|
</div>
|
||||||
@ -113,6 +131,7 @@ function FieldInput({
|
|||||||
key={key}
|
key={key}
|
||||||
name={key}
|
name={key}
|
||||||
value={nested}
|
value={nested}
|
||||||
|
resourceKind={resourceKind}
|
||||||
onChange={(next) => onChange({ ...(value as Record<string, unknown>), [key]: next })}
|
onChange={(next) => onChange({ ...(value as Record<string, unknown>), [key]: next })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -120,20 +139,20 @@ function FieldInput({
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const displayValue = value == null ? "" : String(value);
|
const rawValue = value == null ? "" : String(value);
|
||||||
const selectOptions: Record<string, string[]> = {
|
const displayValue = !rawValue
|
||||||
status: ["draft", "active", "published", "inactive"],
|
? ""
|
||||||
visibility: ["public", "private", "hidden"],
|
: presentAsName
|
||||||
pricing_model: ["monthly", "yearly", "trial", "enterprise_quote"],
|
? commercialEntityName(resourceKind, entityCode, rawValue)
|
||||||
interval: ["monthly", "yearly"],
|
: name === "description"
|
||||||
currency: ["IRR"],
|
? commercialDescription(resourceKind, rawValue)
|
||||||
};
|
: rawValue;
|
||||||
if (selectOptions[name]) {
|
if (FIELD_ENUM_OPTIONS[name]) {
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
|
<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">
|
<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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@ -166,13 +185,13 @@ function CommercialRecordForm({
|
|||||||
onChange: (record: RegistryRecord) => void;
|
onChange: (record: RegistryRecord) => void;
|
||||||
}) {
|
}) {
|
||||||
const visibleKeys = Object.keys(value).filter((key) => !isTechnicalField(key));
|
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 advanced = visibleKeys.filter((key) => !grouped.has(key) && key !== resource.nameField);
|
||||||
const setField = (key: string, next: unknown) => onChange({ ...value, [key]: next });
|
const setField = (key: string, next: unknown) => onChange({ ...value, [key]: next });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[65vh] space-y-6 overflow-y-auto px-1">
|
<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([
|
const keys = Array.from(new Set([
|
||||||
...(group.title === "اطلاعات پایه" ? [resource.nameField] : []),
|
...(group.title === "اطلاعات پایه" ? [resource.nameField] : []),
|
||||||
...group.keys,
|
...group.keys,
|
||||||
@ -182,7 +201,7 @@ function CommercialRecordForm({
|
|||||||
<section key={group.title} className="rounded-2xl bg-gray-50 p-4">
|
<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>
|
<h3 className="mb-4 text-sm font-bold text-secondary">{group.title}</h3>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -192,7 +211,7 @@ function CommercialRecordForm({
|
|||||||
<summary className="cursor-pointer text-sm font-bold text-secondary">تنظیمات پیشرفته</summary>
|
<summary className="cursor-pointer text-sm font-bold text-secondary">تنظیمات پیشرفته</summary>
|
||||||
<p className="mt-2 text-xs text-gray-500">این بخش برای تنظیمات کماستفاده است. تغییر آنها ممکن است روی انتشار اثر بگذارد.</p>
|
<p className="mt-2 text-xs text-gray-500">این بخش برای تنظیمات کماستفاده است. تغییر آنها ممکن است روی انتشار اثر بگذارد.</p>
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
@ -313,7 +332,11 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeItems = async (targets: RegistryRecord[]) => {
|
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);
|
setBusy(true);
|
||||||
for (const item of targets) {
|
for (const item of targets) {
|
||||||
const targetId = recordTargetId(item, resource);
|
const targetId = recordTargetId(item, resource);
|
||||||
@ -333,9 +356,9 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
title={resource.label}
|
title={resource.presentation.sectionTitle}
|
||||||
subtitle={`جستوجو، ویرایش، انتشار و مدیریت ${resource.label}`}
|
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}>افزودن مورد جدید</Button> : null}</div>}
|
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}
|
{error ? <Banner variant="error">{error}</Banner> : null}
|
||||||
{msg ? <Banner variant="success">{msg}</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">
|
<label className="relative min-w-[220px] flex-1">
|
||||||
<span className="sr-only">جستوجو</span>
|
<span className="sr-only">جستوجو</span>
|
||||||
<span className="pointer-events-none absolute right-3 top-2.5" aria-hidden="true">⌕</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>
|
</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">
|
<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>
|
<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 ? (
|
{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>
|
<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 ? (
|
) : !visible.length ? (
|
||||||
<EmptyState message={query || statusFilter !== "all" ? "موردی با این جستوجو پیدا نشد." : "هنوز موردی ثبت نشده است."} />
|
<EmptyState message={query || statusFilter !== "all" ? `نتیجهای در ${resource.presentation.pluralLabel} پیدا نشد.` : resource.presentation.emptyStateTitle} />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[680px] text-sm">
|
<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>
|
<tbody>
|
||||||
{visible.map((item, index) => {
|
{visible.map((item, index) => {
|
||||||
const code = recordCode(item, resource) || `row-${index}`;
|
const code = recordCode(item, resource) || `row-${index}`;
|
||||||
const status = typeof item.status === "string" ? item.status : "inactive";
|
const status = typeof item.status === "string" ? item.status : "inactive";
|
||||||
return (
|
return (
|
||||||
<tr key={code} className="border-t border-gray-100 hover:bg-gray-50/60">
|
<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>
|
{!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}
|
||||||
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
|
{resource.presentation.columns.map((column) => {
|
||||||
<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>
|
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">
|
<td className="px-4 py-3">
|
||||||
{resource.readOnly ? (
|
{resource.readOnly ? (
|
||||||
<span className="text-xs text-gray-500">نمایش فقطخواندنی</span>
|
<span className="text-xs text-gray-500">نمایش فقطخواندنی</span>
|
||||||
@ -394,13 +428,13 @@ export function AdminRegistryManager({ resource }: { resource: CommercialAdminRe
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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">
|
<form onSubmit={(event) => void submitCreate(event)} className="space-y-5">
|
||||||
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
<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>
|
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>انصراف</Button><Button type="submit" loading={busy}>ثبت و ذخیره</Button></div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={`ویرایش ${resource.label}`}>
|
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={resource.presentation.editDialogTitle}>
|
||||||
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
|
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
|
||||||
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
<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>
|
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setEditItem(null)}>انصراف</Button><Button type="submit" loading={busy}>ذخیره تغییرات</Button></div>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
togglePinned,
|
togglePinned,
|
||||||
type LauncherPrefs,
|
type LauncherPrefs,
|
||||||
} from "../launcher-prefs";
|
} from "../launcher-prefs";
|
||||||
|
import { enumLabel } from "../presentation";
|
||||||
|
|
||||||
const GROUP_ORDER = [
|
const GROUP_ORDER = [
|
||||||
"Operations",
|
"Operations",
|
||||||
@ -58,21 +59,6 @@ const GROUP_ORDER = [
|
|||||||
"ai",
|
"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() {
|
function AppsInner() {
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@ -143,7 +129,7 @@ function AppsInner() {
|
|||||||
const ib = GROUP_ORDER.findIndex((g) => g.toLowerCase() === b.toLowerCase());
|
const ib = GROUP_ORDER.findIndex((g) => g.toLowerCase() === b.toLowerCase());
|
||||||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
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]);
|
}, [products, q]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -281,10 +267,10 @@ function AppsInner() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
<RegistryBrowser title="داراییهای دیجیتال" result={assets} codeKey="asset_code" />
|
<RegistryBrowser kind="assets" result={assets} codeKey="asset_code" />
|
||||||
<RegistryBrowser title="افزونهها" result={extensions} codeKey="extension_code" />
|
<RegistryBrowser kind="extensions" result={extensions} codeKey="extension_code" />
|
||||||
<RegistryBrowser
|
<RegistryBrowser
|
||||||
title="بستههای خودکارسازی"
|
kind="automation-packs"
|
||||||
result={automation}
|
result={automation}
|
||||||
codeKey="automation_pack_code"
|
codeKey="automation_pack_code"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } fro
|
|||||||
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
|
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
|
||||||
import { CommercialSkeleton } from "../components/CommercialSkeleton";
|
import { CommercialSkeleton } from "../components/CommercialSkeleton";
|
||||||
import {
|
import {
|
||||||
|
commercialEntityName,
|
||||||
enumLabel,
|
enumLabel,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
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"
|
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-gray-100 bg-white p-4"
|
||||||
>
|
>
|
||||||
<div>
|
<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>
|
<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}
|
{item.trial_days ? <p className="mt-1 text-xs text-emerald-700">{item.trial_days.toLocaleString("fa-IR")} روز آزمایش رایگان</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -245,7 +248,7 @@ function BillingInner() {
|
|||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{invoices.data.map((inv) => (
|
{invoices.data.map((inv) => (
|
||||||
<li key={inv.invoice_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
|
<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>
|
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(inv.amount_minor, inv.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(inv.status)}</p></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -257,7 +260,7 @@ function BillingInner() {
|
|||||||
<ul className="mt-3 space-y-2 text-sm">
|
<ul className="mt-3 space-y-2 text-sm">
|
||||||
{txns.data.map((t) => (
|
{txns.data.map((t) => (
|
||||||
<li key={t.transaction_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
|
<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>
|
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(t.amount_minor, t.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(t.status)}</p></div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ import {
|
|||||||
} from "../components";
|
} from "../components";
|
||||||
import { loadAssessmentSession } from "../session";
|
import { loadAssessmentSession } from "../session";
|
||||||
import {
|
import {
|
||||||
|
commercialEntityName,
|
||||||
enumLabel,
|
enumLabel,
|
||||||
friendlyMessage,
|
friendlyMessage,
|
||||||
humanizeCommercialCode,
|
humanizeCommercialCode,
|
||||||
@ -325,7 +326,7 @@ function CommercialDashboardContent() {
|
|||||||
key={f.feature_key}
|
key={f.feature_key}
|
||||||
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm"
|
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium">{humanizeCommercialCode(f.feature_key)}</span>
|
<span className="text-xs font-medium">{commercialEntityName("capabilities", f.feature_key)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs font-semibold text-primary"
|
className="text-xs font-semibold text-primary"
|
||||||
@ -393,7 +394,7 @@ function CommercialDashboardContent() {
|
|||||||
<CapabilityBadge
|
<CapabilityBadge
|
||||||
key={c.capability_code}
|
key={c.capability_code}
|
||||||
code={c.capability_code}
|
code={c.capability_code}
|
||||||
label={c.display_name}
|
label={commercialEntityName("capabilities", c.capability_code, c.display_name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -416,7 +417,7 @@ function CommercialDashboardContent() {
|
|||||||
<ul className="mb-3 space-y-1">
|
<ul className="mb-3 space-y-1">
|
||||||
{invoices.data.map((inv) => (
|
{invoices.data.map((inv) => (
|
||||||
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
||||||
{humanizeCommercialCode(inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
|
{commercialEntityName("invoices", inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -434,7 +435,7 @@ function CommercialDashboardContent() {
|
|||||||
<ul className="mt-3 space-y-1">
|
<ul className="mt-3 space-y-1">
|
||||||
{licenses.data.map((lic) => (
|
{licenses.data.map((lic) => (
|
||||||
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
|
||||||
{humanizeCommercialCode(lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
|
{commercialEntityName("licenses", lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -450,10 +451,10 @@ function CommercialDashboardContent() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RegistryBrowser title="داراییها" result={assets} codeKey="asset_code" />
|
<RegistryBrowser kind="assets" result={assets} codeKey="asset_code" />
|
||||||
<RegistryBrowser title="افزونهها" result={extensions} codeKey="extension_code" />
|
<RegistryBrowser kind="extensions" result={extensions} codeKey="extension_code" />
|
||||||
<RegistryBrowser title="اتوماسیون" result={automation} codeKey="automation_pack_code" />
|
<RegistryBrowser kind="automation-packs" result={automation} codeKey="automation_pack_code" />
|
||||||
<RegistryBrowser title="سیاستها" result={policies} codeKey="policy_code" />
|
<RegistryBrowser kind="policies" result={policies} codeKey="policy_code" />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle title="اعلانها" />
|
<SectionTitle title="اعلانها" />
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
type AssessmentSession,
|
type AssessmentSession,
|
||||||
} from "../session";
|
} from "../session";
|
||||||
import {
|
import {
|
||||||
|
commercialEntityName,
|
||||||
enumLabel,
|
enumLabel,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
friendlyMessage,
|
friendlyMessage,
|
||||||
@ -456,7 +457,9 @@ export function CommercialDiscoverExperience() {
|
|||||||
{selectedBundleObj ? (
|
{selectedBundleObj ? (
|
||||||
<div className="rounded-2xl border border-primary/30 bg-[var(--color-primary-soft)] p-4 text-sm">
|
<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="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 ? (
|
{selectedBundleObj.upgrade_path_code ? (
|
||||||
<p className="mt-1 text-xs text-primary">
|
<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"
|
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">
|
<p className="font-semibold text-secondary dark:text-gray-100">
|
||||||
{item.display_name}
|
{commercialEntityName("pricing", item.pricing_item_code, item.display_name)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
||||||
@ -564,7 +567,11 @@ export function CommercialDiscoverExperience() {
|
|||||||
</p>
|
</p>
|
||||||
{selectedBundle ? (
|
{selectedBundle ? (
|
||||||
<p className="text-sm">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">بستهای انتخاب نشده است؛ میتوانید بعداً از بخش اشتراک انتخاب کنید.</p>
|
<p className="text-xs text-gray-500">بستهای انتخاب نشده است؛ میتوانید بعداً از بخش اشتراک انتخاب کنید.</p>
|
||||||
|
|||||||
@ -12,7 +12,12 @@ import {
|
|||||||
} from "../adapters";
|
} from "../adapters";
|
||||||
import { commercialGet, extractItems } from "../adapters/http";
|
import { commercialGet, extractItems } from "../adapters/http";
|
||||||
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
import type { CommercialBundle, CommercialPricingItem, CommercialProduct } from "../types";
|
||||||
import { enumLabel, formatMoney } from "../presentation";
|
import {
|
||||||
|
commercialDescription,
|
||||||
|
commercialEntityName,
|
||||||
|
enumLabel,
|
||||||
|
formatMoney,
|
||||||
|
} from "../presentation";
|
||||||
|
|
||||||
const JOURNEY = [
|
const JOURNEY = [
|
||||||
"کشف نیاز",
|
"کشف نیاز",
|
||||||
@ -152,7 +157,7 @@ export function CommercialMarketingLanding() {
|
|||||||
href={`/discover?type=${encodeURIComponent(t.business_type_code)}`}
|
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"
|
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>
|
</Link>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@ -178,8 +183,12 @@ export function CommercialMarketingLanding() {
|
|||||||
key={p.product_code}
|
key={p.product_code}
|
||||||
className="border-b border-gray-100 pb-4 dark:border-gray-800"
|
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="font-semibold text-secondary dark:text-gray-100">
|
||||||
<p className="mt-1 text-xs text-gray-500">{p.description || p.category || "—"}</p>
|
{commercialEntityName("products", p.product_code, p.display_name)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
{commercialDescription("products", p.description)}
|
||||||
|
</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -218,14 +227,14 @@ export function CommercialMarketingLanding() {
|
|||||||
{bundles.slice(0, 6).map((b) => (
|
{bundles.slice(0, 6).map((b) => (
|
||||||
<li key={b.bundle_code}>
|
<li key={b.bundle_code}>
|
||||||
<p className="text-lg font-bold text-secondary dark:text-gray-100">
|
<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>
|
||||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{b.description || "مجموعهای کاربردی برای شروع سریعتر"}
|
{commercialDescription("bundles", b.description)}
|
||||||
</p>
|
</p>
|
||||||
{b.recommendation_badge ? (
|
{b.recommendation_badge ? (
|
||||||
<Badge variant="primary" className="mt-2">
|
<Badge variant="primary" className="mt-2">
|
||||||
{b.recommendation_badge}
|
{enumLabel(b.recommendation_badge)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
@ -246,7 +255,7 @@ export function CommercialMarketingLanding() {
|
|||||||
{pricing.map((item) => (
|
{pricing.map((item) => (
|
||||||
<li key={item.pricing_item_code}>
|
<li key={item.pricing_item_code}>
|
||||||
<p className="font-semibold text-secondary dark:text-gray-100">
|
<p className="font-semibold text-secondary dark:text-gray-100">
|
||||||
{item.display_name}
|
{commercialEntityName("pricing", item.pricing_item_code, item.display_name)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
SetupChecklist,
|
SetupChecklist,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { pushRecent } from "../launcher-prefs";
|
import { pushRecent } from "../launcher-prefs";
|
||||||
|
import { commercialEntityName } from "../presentation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic first-run product setup wizard.
|
* Generic first-run product setup wizard.
|
||||||
@ -38,6 +39,9 @@ function SetupWizardInner() {
|
|||||||
const prods = await loadCommercialProducts();
|
const prods = await loadCommercialProducts();
|
||||||
setProductsAvail(prods.availability);
|
setProductsAvail(prods.availability);
|
||||||
const found = (prods.data || []).find((p) => p.product_code === productCode) || null;
|
const found = (prods.data || []).find((p) => p.product_code === productCode) || null;
|
||||||
|
const foundName = found
|
||||||
|
? commercialEntityName("products", found.product_code, found.display_name)
|
||||||
|
: "";
|
||||||
setProduct(found);
|
setProduct(found);
|
||||||
if (found) pushRecent(found.product_code);
|
if (found) pushRecent(found.product_code);
|
||||||
|
|
||||||
@ -69,7 +73,7 @@ function SetupWizardInner() {
|
|||||||
setChecklist([
|
setChecklist([
|
||||||
{
|
{
|
||||||
id: "open",
|
id: "open",
|
||||||
label: `باز کردن ${found.display_name}`,
|
label: `باز کردن ${foundName}`,
|
||||||
href: resolveProductLaunchUrl(found),
|
href: resolveProductLaunchUrl(found),
|
||||||
product_code: productCode,
|
product_code: productCode,
|
||||||
required: true,
|
required: true,
|
||||||
@ -102,6 +106,9 @@ function SetupWizardInner() {
|
|||||||
|
|
||||||
const current = checklist[step];
|
const current = checklist[step];
|
||||||
const launch = product ? resolveProductLaunchUrl(product) : null;
|
const launch = product ? resolveProductLaunchUrl(product) : null;
|
||||||
|
const productName = product
|
||||||
|
? commercialEntityName("products", product.product_code, product.display_name)
|
||||||
|
: "";
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (pct != null) return pct;
|
if (pct != null) return pct;
|
||||||
@ -111,7 +118,7 @@ function SetupWizardInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformShell
|
<PlatformShell
|
||||||
title={product ? `راهاندازی ${product.display_name}` : "راهاندازی محصول"}
|
title={product ? `راهاندازی ${productName}` : "راهاندازی محصول"}
|
||||||
subtitle="مراحل ساده برای آمادهکردن محصول و شروع استفاده"
|
subtitle="مراحل ساده برای آمادهکردن محصول و شروع استفاده"
|
||||||
>
|
>
|
||||||
{loading ? <CommercialSkeleton rows={4} /> : null}
|
{loading ? <CommercialSkeleton rows={4} /> : null}
|
||||||
@ -122,10 +129,10 @@ function SetupWizardInner() {
|
|||||||
description={
|
description={
|
||||||
productsAvail === "unavailable"
|
productsAvail === "unavailable"
|
||||||
? "اطلاعات این محصول فعلاً در دسترس نیست."
|
? "اطلاعات این محصول فعلاً در دسترس نیست."
|
||||||
: `کد ${productCode} پیدا نشد.`
|
: "این محصول در فهرست محصولات قابل راهاندازی پیدا نشد."
|
||||||
}
|
}
|
||||||
actionHref="/apps"
|
actionHref="/apps"
|
||||||
actionLabel="بازگشت به Apps"
|
actionLabel="بازگشت به محصولات"
|
||||||
secondaryHref="/discover"
|
secondaryHref="/discover"
|
||||||
secondaryLabel="کشف مجدد"
|
secondaryLabel="کشف مجدد"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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: "رستوران",
|
restaurant: "رستوران",
|
||||||
hospitality: "رستوران و پذیرایی",
|
hospitality: "رستوران و پذیرایی",
|
||||||
|
food: "غذا",
|
||||||
|
beverage: "پذیرایی",
|
||||||
platform: "پلتفرم",
|
platform: "پلتفرم",
|
||||||
starter: "Starter",
|
starter: "شروع",
|
||||||
|
start: "شروع",
|
||||||
growth: "رشد",
|
growth: "رشد",
|
||||||
professional: "حرفهای",
|
professional: "حرفهای",
|
||||||
pro: "حرفهای",
|
pro: "حرفهای",
|
||||||
@ -10,42 +439,76 @@ const TOKEN_LABELS: Record<string, string> = {
|
|||||||
loyalty: "باشگاه مشتریان",
|
loyalty: "باشگاه مشتریان",
|
||||||
delivery: "ارسال و پیک",
|
delivery: "ارسال و پیک",
|
||||||
booking: "رزرو",
|
booking: "رزرو",
|
||||||
|
reservation: "رزرو",
|
||||||
payment: "پرداخت",
|
payment: "پرداخت",
|
||||||
accounting: "حسابداری",
|
accounting: "حسابداری",
|
||||||
website: "وبسایت",
|
website: "وبسایت",
|
||||||
|
experience: "وبسایت و تجربه دیجیتال",
|
||||||
crm: "مدیریت ارتباط با مشتری",
|
crm: "مدیریت ارتباط با مشتری",
|
||||||
inventory: "انبارداری",
|
inventory: "انبارداری",
|
||||||
pos: "صندوق فروش",
|
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: "ماهانه",
|
monthly: "ماهانه",
|
||||||
|
month: "ماهانه",
|
||||||
yearly: "سالانه",
|
yearly: "سالانه",
|
||||||
annual: "سالانه",
|
annual: "سالانه",
|
||||||
|
year: "سالانه",
|
||||||
trial: "آزمایشی",
|
trial: "آزمایشی",
|
||||||
active: "فعال",
|
public: "قابل مشاهده برای همه",
|
||||||
inactive: "غیرفعال",
|
private: "فقط مشتریان مجاز",
|
||||||
draft: "پیشنویس",
|
|
||||||
published: "منتشرشده",
|
|
||||||
pending: "در انتظار",
|
|
||||||
verified: "تأییدشده",
|
|
||||||
failed: "ناموفق",
|
|
||||||
expired: "منقضیشده",
|
|
||||||
trialing: "دوره آزمایشی",
|
|
||||||
public: "عمومی",
|
|
||||||
private: "خصوصی",
|
|
||||||
hidden: "پنهان",
|
hidden: "پنهان",
|
||||||
|
admin: "فقط مدیران",
|
||||||
route: "داخل سامانه",
|
route: "داخل سامانه",
|
||||||
external: "پیوند بیرونی",
|
external: "پیوند بیرونی",
|
||||||
disabled: "غیرفعال",
|
disabled: "غیرفعال",
|
||||||
deny: "غیرفعال",
|
deny: "غیرفعال",
|
||||||
allow: "فعال",
|
allow: "فعال",
|
||||||
enterprise_quote: "استعلام قیمت",
|
enterprise_quote: "استعلام قیمت",
|
||||||
|
enterprisequote: "استعلام قیمت",
|
||||||
irr: "ریال",
|
irr: "ریال",
|
||||||
|
rial: "ریال",
|
||||||
recommended: "پیشنهادشده",
|
recommended: "پیشنهادشده",
|
||||||
popular: "محبوب",
|
popular: "محبوب",
|
||||||
most_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> = {
|
export const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||||
display_name: "نام نمایشی",
|
display_name: "نام",
|
||||||
|
name: "نام",
|
||||||
title: "عنوان",
|
title: "عنوان",
|
||||||
description: "توضیح کوتاه",
|
description: "توضیح کوتاه",
|
||||||
status: "وضعیت",
|
status: "وضعیت",
|
||||||
@ -59,14 +522,15 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
grace_period_days: "مهلت تمدید",
|
grace_period_days: "مهلت تمدید",
|
||||||
product_codes: "محصولات",
|
product_codes: "محصولات",
|
||||||
capability_codes: "قابلیتها",
|
capability_codes: "قابلیتها",
|
||||||
|
required_capabilities: "قابلیتهای موردنیاز",
|
||||||
feature_refs: "امکانات",
|
feature_refs: "امکانات",
|
||||||
pricing_refs: "روشهای پرداخت",
|
pricing_refs: "روشهای پرداخت",
|
||||||
discount_refs: "تخفیفها",
|
discount_refs: "تخفیفها",
|
||||||
coupon_code: "کد تخفیف",
|
coupon_code: "کد تخفیف",
|
||||||
tax_class_code: "مالیات",
|
tax_class_code: "گروه مالیاتی",
|
||||||
trial_eligible: "دوره آزمایشی",
|
trial_eligible: "امکان استفاده آزمایشی",
|
||||||
visibility: "نمایش",
|
visibility: "نحوه نمایش",
|
||||||
display_order: "ترتیب نمایش",
|
display_order: "اولویت نمایش",
|
||||||
category: "دستهبندی",
|
category: "دستهبندی",
|
||||||
is_verified: "وضعیت تأیید",
|
is_verified: "وضعیت تأیید",
|
||||||
domain_type: "نوع دامنه",
|
domain_type: "نوع دامنه",
|
||||||
@ -74,6 +538,10 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
trial_ends_at: "پایان دوره آزمایشی",
|
trial_ends_at: "پایان دوره آزمایشی",
|
||||||
created_at: "تاریخ ایجاد",
|
created_at: "تاریخ ایجاد",
|
||||||
updated_at: "آخرین تغییر",
|
updated_at: "آخرین تغییر",
|
||||||
|
published_at: "تاریخ انتشار",
|
||||||
|
starts_at: "تاریخ شروع",
|
||||||
|
ends_at: "تاریخ پایان",
|
||||||
|
version: "نسخه",
|
||||||
metadata: "اطلاعات تکمیلی",
|
metadata: "اطلاعات تکمیلی",
|
||||||
attributes: "ویژگیها",
|
attributes: "ویژگیها",
|
||||||
parameters: "تنظیمات",
|
parameters: "تنظیمات",
|
||||||
@ -81,14 +549,119 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
feature_limits: "محدودیت امکانات",
|
feature_limits: "محدودیت امکانات",
|
||||||
auto_disable: "غیرفعالسازی خودکار",
|
auto_disable: "غیرفعالسازی خودکار",
|
||||||
expiration_behavior: "رفتار پس از پایان",
|
expiration_behavior: "رفتار پس از پایان",
|
||||||
ssl_ready: "وضعیت SSL",
|
ssl_ready: "وضعیت گواهی امنیتی",
|
||||||
dns_instructions: "راهنمای DNS",
|
dns_instructions: "راهنمای اتصال دامنه",
|
||||||
custom_domain_allowed: "دامنه اختصاصی",
|
custom_domain_allowed: "دامنه اختصاصی",
|
||||||
subdomain_only: "فقط زیردامنه",
|
subdomain_only: "فقط زیردامنه",
|
||||||
reason: "دلیل پیشنهاد",
|
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 {
|
export function isTechnicalField(key: string): boolean {
|
||||||
return TECHNICAL_FIELD.test(key);
|
return TECHNICAL_FIELD.test(key);
|
||||||
@ -101,49 +674,137 @@ export function fieldLabel(key: string): string {
|
|||||||
.map((token) => TOKEN_LABELS[token.toLowerCase()] || "")
|
.map((token) => TOKEN_LABELS[token.toLowerCase()] || "")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
return translated || "اطلاعات تکمیلی";
|
return translated || "ویژگی تکمیلی";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enumLabel(value: unknown): string {
|
export function enumLabel(value: unknown): string {
|
||||||
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
if (typeof value === "boolean") return value ? "بله" : "خیر";
|
||||||
if (value == null || value === "") return "ثبت نشده";
|
if (value == null || value === "") return "ثبت نشده";
|
||||||
const raw = String(value).trim();
|
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 {
|
export function humanizeCommercialCode(code: unknown, explicitName?: string | null): string {
|
||||||
if (explicitName?.trim() && !looksTechnical(explicitName)) return explicitName.trim();
|
if (explicitName && hasPersian(explicitName)) return explicitName.trim();
|
||||||
if (code == null) return "مورد تجاری";
|
if (code == null) return "عنوان تجاری ثبتنشده";
|
||||||
const raw = String(code).trim();
|
const raw = String(code).trim();
|
||||||
if (!raw) return "مورد تجاری";
|
if (!raw) return "عنوان تجاری ثبتنشده";
|
||||||
if (TOKEN_LABELS[raw.toLowerCase()]) return TOKEN_LABELS[raw.toLowerCase()];
|
|
||||||
if (!looksTechnical(raw)) return raw;
|
const normalized = raw.toLowerCase().replace(/_/g, "-");
|
||||||
|
if (TOKEN_LABELS[normalized]) return TOKEN_LABELS[normalized];
|
||||||
|
|
||||||
const tokens = raw.toLowerCase().split(/[._:/-]+/).filter(Boolean);
|
const tokens = raw.toLowerCase().split(/[._:/-]+/).filter(Boolean);
|
||||||
const kind = tokens[0];
|
const kind = tokens[0];
|
||||||
const translated = tokens
|
const body = tokens.slice(1);
|
||||||
.slice(1)
|
const intervalToken = body.find((token) => /^(monthly|month|yearly|annual|year|trial)$/.test(token));
|
||||||
.map((token) => TOKEN_LABELS[token])
|
const interval = intervalToken ? TOKEN_LABELS[intervalToken] : "";
|
||||||
.filter(Boolean);
|
const subjectTokens = body.filter((token) => token !== intervalToken);
|
||||||
|
const translated = translatedTokens(subjectTokens);
|
||||||
const subject = translated.join(" ");
|
const subject = translated.join(" ");
|
||||||
|
|
||||||
if (kind === "price" || kind === "plan") {
|
const formatter = CODE_FORMATTERS[kind];
|
||||||
const planSubject = translated.filter((token) => token !== "ماهانه" && token !== "سالانه" && token !== "آزمایشی").join(" ");
|
if (formatter) return formatter({ translated, subject, interval });
|
||||||
return `پلن ${planSubject || "اشتراک"}`.trim();
|
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}` : "سیستم کسبوکار";
|
export function commercialEntityName(
|
||||||
if (kind === "capability" || kind === "feature") return subject || "قابلیت کاربردی";
|
kind: string,
|
||||||
if (kind === "tax") return subject || "مالیات";
|
code: unknown,
|
||||||
if (kind === "coupon" || kind === "discount") return subject || "تخفیف";
|
explicitName?: string | null
|
||||||
return subject || "گزینه تجاری";
|
): 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 {
|
export function looksTechnical(value: string): boolean {
|
||||||
return (
|
return (
|
||||||
/[a-z0-9]+[._:/][a-z0-9_.:/-]+/i.test(value) ||
|
/[a-z0-9]+[._:/][a-z0-9_.:/-]+/i.test(value) ||
|
||||||
/^[a-z][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)
|
/\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);
|
: new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium" }).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function friendlyMessage(message?: string | null, fallback = "در حال حاضر اطلاعاتی برای نمایش نداریم."): string {
|
export function friendlyMessage(
|
||||||
if (!message || looksTechnical(message) || /\b(4\d\d|5\d\d)\b/.test(message)) return fallback;
|
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, "اطلاعات تکمیلی");
|
return message.replace(/\[object Object\]/g, "اطلاعات تکمیلی");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,11 +844,14 @@ export function recommendationReason(reason?: string | null): string {
|
|||||||
return reason;
|
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)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) =>
|
return value.map((item) => {
|
||||||
typeof item === "string" ? humanizeCommercialCode(item) : enumLabel(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 (value && typeof value === "object") return value as Record<string, unknown>;
|
||||||
if (typeof value === "string") return enumLabel(value);
|
if (typeof value === "string") return enumLabel(value);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user