+
+ {mobileOpen ? (
+
+
setMobileOpen(false)} />
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/modules/communication/components/createPortalLayout.tsx b/frontend/modules/communication/components/createPortalLayout.tsx
new file mode 100644
index 0000000..241af66
--- /dev/null
+++ b/frontend/modules/communication/components/createPortalLayout.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { AuthGuard } from "@/components/AuthGuard";
+import { useMe } from "@/hooks/useMe";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { LoadingState, ErrorState } from "@/components/ds";
+import { CommunicationPortalShell } from "@/modules/communication/components/CommunicationPortalShell";
+
+function PortalGate({ children }: { children: React.ReactNode }) {
+ const { me, loading, error, reload } = useMe();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (loading) return;
+ if (me?.onboarding_required) router.replace("/onboarding");
+ }, [me, loading, router]);
+
+ if (loading) return
;
+ if (error) return
;
+ if (!me?.current_tenant_id) {
+ return
;
+ }
+
+ return
{children};
+}
+
+export function CommunicationRootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/modules/communication/constants/featureRegistry.ts b/frontend/modules/communication/constants/featureRegistry.ts
new file mode 100644
index 0000000..f680552
--- /dev/null
+++ b/frontend/modules/communication/constants/featureRegistry.ts
@@ -0,0 +1,234 @@
+import type { FeatureLockConfig } from "@/modules/communication/types";
+
+/** Backend-active features (from /capabilities features[] + channel status). */
+export const SMS_FEATURES = [
+ "sms",
+ "provider_framework",
+ "communication_router",
+ "template_engine",
+ "dynamic_contact_sources",
+ "queue_engine",
+ "delivery_tracking",
+ "otp",
+ "webhooks",
+ "failover",
+ "circuit_breaker",
+ "rate_limiting",
+ "idempotency",
+ "metrics",
+] as const;
+
+/** Future modules — always locked until backend registers capability. */
+export const FUTURE_MODULE_REGISTRY: Record
= {
+ email: {
+ feature: "channel_email",
+ title: "پلتفرم ایمیل",
+ description: "ارسال ایمیل سازمانی با SMTP، قالبهای HTML، پیگیری تحویل و failover.",
+ phase: "Roadmap",
+ bundle: "Communication Pro",
+ capabilities: ["SMTP adapters", "HTML templates", "Bounce handling", "DKIM/SPF guides"],
+ integrations: ["CRM", "Marketing", "Accounting"],
+ },
+ whatsapp: {
+ feature: "channel_whatsapp",
+ title: "واتساپ بیزینس",
+ description: "پیامرسانی WhatsApp Cloud API با قالبهای تأییدشده و webhook تحویل.",
+ phase: "Roadmap",
+ bundle: "Omnichannel Pack",
+ capabilities: ["Template sync", "Read receipts", "Media messages", "Opt-in management"],
+ integrations: ["CRM", "Support", "Campaigns"],
+ },
+ telegram: {
+ feature: "channel_telegram",
+ title: "تلگرام",
+ description: "ربات تلگرام برای اعلانها، OTP و پشتیبانی.",
+ phase: "Roadmap",
+ bundle: "Omnichannel Pack",
+ capabilities: ["Bot API", "Inline keyboards", "Channel broadcasts"],
+ integrations: ["Support", "Notifications"],
+ },
+ rubika: {
+ feature: "channel_rubika",
+ title: "روبیکا",
+ description: "پیامرسانی اجتماعی بومی با adapter اختصاصی.",
+ phase: "Roadmap",
+ bundle: "Regional Pack",
+ capabilities: ["Bot messaging", "Rich cards", "Delivery callbacks"],
+ integrations: ["CRM", "Loyalty"],
+ },
+ push: {
+ feature: "channel_push",
+ title: "Push Notifications",
+ description: "اعلان push موبایل و وب via FCM با ثبت توکن دستگاه.",
+ phase: "Roadmap",
+ bundle: "Mobile Pack",
+ capabilities: ["FCM", "Device registry", "Silent push", "Topic subscriptions"],
+ integrations: ["Mobile apps", "PWA"],
+ },
+ voice: {
+ feature: "channel_voice",
+ title: "تماس صوتی",
+ description: "OTP و اعلان صوتی با gateway تلفنی.",
+ phase: "Roadmap",
+ bundle: "Voice Pack",
+ capabilities: ["TTS", "Call status webhooks", "OTP voice path"],
+ integrations: ["Identity", "Security"],
+ },
+ video: {
+ feature: "channel_video",
+ title: "تماس ویدیویی",
+ description: "جلسات ویدیویی و تماس تصویری برای پشتیبانی.",
+ phase: "Roadmap",
+ bundle: "Enterprise",
+ capabilities: ["WebRTC rooms", "Recording refs", "Scheduling"],
+ integrations: ["CRM", "Support"],
+ },
+ live_chat: {
+ feature: "live_chat",
+ title: "Live Chat",
+ description: "چت زنده وبسایت با routing به اپراتور.",
+ phase: "Roadmap",
+ bundle: "Support Pack",
+ capabilities: ["Widget embed", "Agent queue", "Canned responses"],
+ integrations: ["CRM", "Experience"],
+ },
+ omnichannel_inbox: {
+ feature: "omnichannel_inbox",
+ title: "صندوق ورودی Omnichannel",
+ description: "تجمیع SMS، ایمیل، چت و شبکههای اجتماعی در یک inbox.",
+ phase: "Roadmap",
+ bundle: "Omnichannel Pack",
+ capabilities: ["Unified threads", "Agent assignment", "SLA timers"],
+ integrations: ["All channels", "CRM"],
+ },
+ marketing_automation: {
+ feature: "marketing_automation",
+ title: "Marketing Automation",
+ description: "اتوماسیون کمپین چندمرحلهای با trigger و segment.",
+ phase: "Roadmap",
+ bundle: "Marketing Pro",
+ capabilities: ["Drip campaigns", "A/B tests", "Segment builder"],
+ integrations: ["CRM", "Loyalty", "Analytics"],
+ },
+ automation_triggers: {
+ feature: "automation_triggers",
+ title: "Automation Triggers",
+ description: "تریگرهای رویداد-محور برای ارسال خودکار پیام.",
+ phase: "Roadmap",
+ bundle: "Automation",
+ capabilities: ["Event hooks", "Conditions", "Delay steps"],
+ integrations: ["All business modules"],
+ },
+ journey_builder: {
+ feature: "journey_builder",
+ title: "Journey Builder",
+ description: "طراح بصری مسیر مشتری با شاخه و wait steps.",
+ phase: "Roadmap",
+ bundle: "Marketing Pro",
+ capabilities: ["Visual canvas", "Branch logic", "Goal tracking"],
+ integrations: ["CRM", "Loyalty"],
+ },
+ ai_campaigns: {
+ feature: "ai_campaigns",
+ title: "AI Campaigns",
+ description: "پیشنهاد محتوا و زمانبندی هوشمند کمپین.",
+ phase: "Roadmap",
+ bundle: "AI Pack",
+ capabilities: ["Content generation", "Send-time optimization", "Churn prediction"],
+ integrations: ["AI Platform", "Analytics"],
+ },
+ webhook_center: {
+ feature: "webhook_center",
+ title: "Webhook Center",
+ description: "مدیریت متمرکز webhookهای ورودی و خروجی.",
+ phase: "Roadmap",
+ bundle: "Developer",
+ capabilities: ["Inbound/outbound", "Retry logs", "Signature rotation"],
+ integrations: ["All providers"],
+ },
+ connectors: {
+ feature: "third_party_connectors",
+ title: "Third-party Connectors",
+ description: "اتصال به سرویسهای خارجی و marketplace adapters.",
+ phase: "Roadmap",
+ bundle: "Integrations",
+ capabilities: ["Plugin registry", "OAuth flows", "Credential vault refs"],
+ integrations: ["Zapier-style", "Custom REST"],
+ },
+ conversations: {
+ feature: "customer_conversation_timeline",
+ title: "Customer Conversation Timeline",
+ description: "خط زمانی یکپارچه مکالمات مشتری across channels.",
+ phase: "Roadmap",
+ bundle: "CRM Plus",
+ capabilities: ["360 timeline", "Agent notes", "Context handoff"],
+ integrations: ["CRM", "Customer360"],
+ },
+ campaign_orchestrator: {
+ feature: "campaign_orchestrator",
+ title: "Campaign Orchestrator",
+ description: "هماهنگی کمپینهای چندکاناله با بودجه و cap.",
+ phase: "Roadmap",
+ bundle: "Marketing Pro",
+ capabilities: ["Cross-channel caps", "Budget guard", "Orchestration rules"],
+ integrations: ["SMS", "Email", "Push"],
+ },
+ notification_center: {
+ feature: "notification_center",
+ title: "Notification Center",
+ description: "مرکز اعلان in-app و out-of-app برای tenant users.",
+ phase: "Roadmap",
+ bundle: "Platform",
+ capabilities: ["In-app feed", "Preference center", "Digest mode"],
+ integrations: ["All modules"],
+ },
+ template_categories: {
+ feature: "template_categories",
+ title: "دستهبندی قالبها",
+ description: "سازماندهی قالبها در دستههای قابل جستجو.",
+ phase: "Roadmap",
+ bundle: "SMS Pro",
+ capabilities: ["Category tree", "Bulk assign", "Permissions per category"],
+ integrations: ["Templates"],
+ },
+ roles: {
+ feature: "communication_roles",
+ title: "نقشهای ارتباطات",
+ description: "مدیریت نقش اختصاصی Communication Platform.",
+ phase: "Roadmap",
+ bundle: "Enterprise",
+ capabilities: ["Role templates", "Scoped permissions", "Audit"],
+ integrations: ["Identity"],
+ },
+ audit: {
+ feature: "communication_audit_ui",
+ title: "ممیزی ارتباطات",
+ description: "رابط کاربری جستجوی audit log (backend موجود — UI در roadmap).",
+ phase: "Roadmap",
+ bundle: "Enterprise",
+ capabilities: ["Entity filter", "Export", "Retention policy"],
+ integrations: ["Compliance"],
+ },
+ api_keys: {
+ feature: "communication_api_keys",
+ title: "کلیدهای API",
+ description: "صدور و چرخش کلید API برای یکپارچهسازی سرویس-به-سرویس.",
+ phase: "Roadmap",
+ bundle: "Developer",
+ capabilities: ["Scoped keys", "Rotation", "Usage metrics"],
+ integrations: ["Core Identity"],
+ },
+ integrations_hub: {
+ feature: "integrations_hub",
+ title: "Integrations",
+ description: "مرکز یکپارچهسازی با ماژولهای TorbatYar.",
+ phase: "Roadmap",
+ bundle: "Platform",
+ capabilities: ["Module connectors", "Event subscriptions", "Health checks"],
+ integrations: ["CRM", "Loyalty", "Delivery", "Experience"],
+ },
+};
+
+export function getFutureModule(key: string): FeatureLockConfig | undefined {
+ return FUTURE_MODULE_REGISTRY[key];
+}
diff --git a/frontend/modules/communication/constants/permissions.ts b/frontend/modules/communication/constants/permissions.ts
new file mode 100644
index 0000000..51d682a
--- /dev/null
+++ b/frontend/modules/communication/constants/permissions.ts
@@ -0,0 +1,27 @@
+/** Mirrors backend app/permissions/definitions.py */
+export const COMMUNICATION_PERMISSIONS = [
+ "communication.view",
+ "communication.manage",
+ "communication.providers.view",
+ "communication.providers.manage",
+ "communication.senders.view",
+ "communication.senders.manage",
+ "communication.templates.view",
+ "communication.templates.manage",
+ "communication.templates.approve",
+ "communication.contacts.view",
+ "communication.contacts.manage",
+ "communication.sources.view",
+ "communication.sources.manage",
+ "communication.messages.view",
+ "communication.messages.send",
+ "communication.messages.cancel",
+ "communication.queue.view",
+ "communication.queue.manage",
+ "communication.otp.request",
+ "communication.otp.verify",
+ "communication.webhooks.manage",
+ "communication.monitoring.view",
+] as const;
+
+export type CommunicationPermission = (typeof COMMUNICATION_PERMISSIONS)[number];
diff --git a/frontend/modules/communication/constants/portals.ts b/frontend/modules/communication/constants/portals.ts
new file mode 100644
index 0000000..4fae087
--- /dev/null
+++ b/frontend/modules/communication/constants/portals.ts
@@ -0,0 +1,190 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ LayoutDashboard,
+ MessageSquare,
+ Server,
+ FileText,
+ Users,
+ Send,
+ ListOrdered,
+ BarChart3,
+ Activity,
+ Settings,
+ Shield,
+ Key,
+ Plug,
+ Mail,
+ Bell,
+ Phone,
+ Video,
+ MessagesSquare,
+ Workflow,
+ Brain,
+ Webhook,
+ Layers,
+ History,
+ BookOpen,
+ Target,
+ Radio,
+ Lock,
+} from "lucide-react";
+
+export type PortalNavItem = {
+ href: string;
+ label: string;
+ feature?: string | null;
+ locked?: boolean;
+ lockKey?: string;
+};
+
+export type PortalNavGroup = {
+ id: string;
+ label: string;
+ icon: LucideIcon;
+ href?: string;
+ feature?: string | null;
+ items?: PortalNavItem[];
+};
+
+export const COMMUNICATION_PORTAL = {
+ id: "communication" as const,
+ label: "تربت ارتباط",
+ description: "پلتفرم Enterprise Communication — SMS Production Ready",
+ basePath: "/communication",
+ icon: MessageSquare,
+};
+
+export const COMMUNICATION_NAV: PortalNavGroup[] = [
+ {
+ id: "dashboards",
+ label: "داشبوردها",
+ icon: LayoutDashboard,
+ items: [
+ { href: "/communication", label: "داشبورد اجرایی" },
+ { href: "/communication/dashboard", label: "داشبورد ارتباطات" },
+ { href: "/communication/providers/dashboard", label: "داشبورد ارائهدهندگان" },
+ { href: "/communication/sms", label: "داشبورد SMS", feature: "sms" },
+ ],
+ },
+ {
+ id: "sms",
+ label: "پلتفرم SMS",
+ icon: Send,
+ feature: "sms",
+ items: [
+ { href: "/communication/sms/providers", label: "ارائهدهندگان SMS", feature: "sms" },
+ { href: "/communication/sms/templates", label: "قالبها", feature: "sms" },
+ {
+ href: "/communication/sms/template-categories",
+ label: "دستهبندی قالبها",
+ locked: true,
+ lockKey: "template_categories",
+ },
+ { href: "/communication/sms/senders", label: "Sender ID", feature: "sms" },
+ { href: "/communication/sms/campaigns", label: "کمپینهای SMS", feature: "sms" },
+ { href: "/communication/sms/send", label: "ارسال پیام", feature: "sms" },
+ { href: "/communication/sms/queue", label: "صف SMS", feature: "queue_engine" },
+ { href: "/communication/sms/delivery-reports", label: "گزارش تحویل", feature: "delivery_tracking" },
+ { href: "/communication/sms/messages", label: "تاریخچه پیام", feature: "sms" },
+ ],
+ },
+ {
+ id: "contacts",
+ label: "مخاطبین",
+ icon: Users,
+ feature: "dynamic_contact_sources",
+ items: [
+ { href: "/communication/contacts/recipients", label: "گیرندگان", feature: "dynamic_contact_sources" },
+ { href: "/communication/contacts/sources", label: "منابع پویا", feature: "dynamic_contact_sources" },
+ { href: "/communication/contacts/address-books", label: "دفترچه آدرس", feature: "dynamic_contact_sources" },
+ ],
+ },
+ {
+ id: "channels",
+ label: "کانالهای آینده",
+ icon: Layers,
+ items: [
+ { href: "/communication/email", label: "ایمیل", locked: true, lockKey: "email" },
+ { href: "/communication/whatsapp", label: "واتساپ", locked: true, lockKey: "whatsapp" },
+ { href: "/communication/telegram", label: "تلگرام", locked: true, lockKey: "telegram" },
+ { href: "/communication/rubika", label: "روبیکا", locked: true, lockKey: "rubika" },
+ { href: "/communication/push", label: "Push", locked: true, lockKey: "push" },
+ { href: "/communication/voice", label: "تماس صوتی", locked: true, lockKey: "voice" },
+ { href: "/communication/video", label: "تماس ویدیویی", locked: true, lockKey: "video" },
+ { href: "/communication/live-chat", label: "Live Chat", locked: true, lockKey: "live_chat" },
+ { href: "/communication/inbox", label: "Omnichannel Inbox", locked: true, lockKey: "omnichannel_inbox" },
+ ],
+ },
+ {
+ id: "automation",
+ label: "اتوماسیون",
+ icon: Workflow,
+ items: [
+ { href: "/communication/automation", label: "Marketing Automation", locked: true, lockKey: "marketing_automation" },
+ { href: "/communication/automation/triggers", label: "Automation Triggers", locked: true, lockKey: "automation_triggers" },
+ { href: "/communication/journey-builder", label: "Journey Builder", locked: true, lockKey: "journey_builder" },
+ { href: "/communication/ai-campaigns", label: "AI Campaigns", locked: true, lockKey: "ai_campaigns" },
+ { href: "/communication/campaign-orchestrator", label: "Campaign Orchestrator", locked: true, lockKey: "campaign_orchestrator" },
+ ],
+ },
+ {
+ id: "insights",
+ label: "گزارش و تحلیل",
+ icon: BarChart3,
+ items: [
+ { href: "/communication/reports", label: "گزارشها", feature: "metrics" },
+ { href: "/communication/analytics", label: "آنالیتیکس", feature: "metrics" },
+ { href: "/communication/otp", label: "OTP", feature: "otp" },
+ { href: "/communication/conversations", label: "خط زمانی مکالمات", locked: true, lockKey: "conversations" },
+ ],
+ },
+ {
+ id: "platform",
+ label: "پلتفرم",
+ icon: Server,
+ items: [
+ { href: "/communication/monitoring", label: "مانیتoring", feature: "metrics" },
+ { href: "/communication/health", label: "سلامت سرویس" },
+ { href: "/communication/capabilities", label: "قابلیتها" },
+ { href: "/communication/webhooks", label: "Webhook Center", locked: true, lockKey: "webhook_center" },
+ { href: "/communication/connectors", label: "Connectors", locked: true, lockKey: "connectors" },
+ { href: "/communication/notifications", label: "Notification Center", locked: true, lockKey: "notification_center" },
+ ],
+ },
+ {
+ id: "admin",
+ label: "مدیریت",
+ icon: Settings,
+ items: [
+ { href: "/communication/settings", label: "تنظیمات", feature: "provider_framework" },
+ { href: "/communication/permissions", label: "مجوزها" },
+ { href: "/communication/roles", label: "نقشها", locked: true, lockKey: "roles" },
+ { href: "/communication/audit", label: "ممیزی", locked: true, lockKey: "audit" },
+ { href: "/communication/api-keys", label: "کلید API", locked: true, lockKey: "api_keys" },
+ { href: "/communication/integrations", label: "Integrations", locked: true, lockKey: "integrations_hub" },
+ ],
+ },
+];
+
+export function navForCommunication(): PortalNavGroup[] {
+ return COMMUNICATION_NAV;
+}
+
+/** Show all nav items; do not hide locked/future modules. */
+export function filterNavForDisplay(
+ nav: PortalNavGroup[],
+ features?: Record
+): PortalNavGroup[] {
+ return nav.map((group) => ({
+ ...group,
+ items: group.items?.map((item) => ({
+ ...item,
+ locked:
+ item.locked ||
+ (item.feature ? features?.[item.feature] !== true : false) ||
+ false,
+ })),
+ }));
+}
+
+export { Lock };
diff --git a/frontend/modules/communication/constants/resourceRegistry.ts b/frontend/modules/communication/constants/resourceRegistry.ts
new file mode 100644
index 0000000..b70bf15
--- /dev/null
+++ b/frontend/modules/communication/constants/resourceRegistry.ts
@@ -0,0 +1,151 @@
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import type { CommunicationListConfig } from "@/modules/communication/components/CommunicationListCrudPage";
+
+export const communicationResources = {
+ providers: {
+ title: "ارائهدهندگان SMS",
+ description: "پیکربندی mock، Payamak و failover",
+ resourceKey: "providers",
+ permission: "communication.providers.view",
+ api: {
+ list: (tenantId: string) => communicationApi.providers.list(tenantId),
+ create: (tenantId: string, body: Record) =>
+ communicationApi.providers.create(tenantId, body),
+ update: (tenantId: string, id: string, body: Record) =>
+ communicationApi.providers.update(tenantId, id, body),
+ remove: (tenantId: string, id: string) => communicationApi.providers.remove(tenantId, id),
+ },
+ columns: [
+ { key: "name", header: "نام" },
+ { key: "provider_kind", header: "نوع" },
+ { key: "channel", header: "کانال" },
+ { key: "status", header: "وضعیت", type: "status" as const },
+ { key: "priority", header: "اولویت" },
+ { key: "circuit_state", header: "Circuit" },
+ ],
+ createFields: [
+ { name: "name", label: "نام", required: true },
+ {
+ name: "provider_kind",
+ label: "نوع",
+ type: "select" as const,
+ required: true,
+ options: [
+ { value: "mock", label: "Mock" },
+ { value: "payamak", label: "Payamak" },
+ ],
+ },
+ { name: "channel", label: "کانال", required: true, placeholder: "sms" },
+ { name: "priority", label: "اولویت", type: "number" as const },
+ { name: "rate_limit_per_minute", label: "Rate limit/min", type: "number" as const },
+ ],
+ } satisfies CommunicationListConfig,
+
+ senders: {
+ title: "Sender ID",
+ description: "شمارههای ارسال SMS",
+ resourceKey: "senders",
+ permission: "communication.senders.view",
+ api: {
+ list: (tenantId: string) => communicationApi.providers.senders.list(tenantId),
+ create: (tenantId: string, body: Record) =>
+ communicationApi.providers.senders.create(tenantId, body),
+ },
+ columns: [
+ { key: "value", header: "شماره / شناسه" },
+ { key: "label", header: "برچسب" },
+ { key: "channel", header: "کانال" },
+ { key: "is_default", header: "پیشفرض" },
+ { key: "is_active", header: "فعال" },
+ ],
+ createFields: [
+ { name: "channel", label: "کانال", required: true, placeholder: "sms" },
+ { name: "value", label: "مقدار", required: true },
+ { name: "label", label: "برچسب" },
+ ],
+ readOnly: false,
+ } satisfies CommunicationListConfig,
+
+ templates: {
+ title: "قالبهای پیام",
+ resourceKey: "templates",
+ permission: "communication.templates.view",
+ api: {
+ list: (tenantId: string) => communicationApi.templates.list(tenantId),
+ create: (tenantId: string, body: Record) =>
+ communicationApi.templates.create(tenantId, body),
+ },
+ columns: [
+ { key: "template_key", header: "کلید" },
+ { key: "name", header: "نام" },
+ { key: "channel", header: "کانال" },
+ { key: "locale", header: "زبان" },
+ { key: "status", header: "وضعیت", type: "status" as const },
+ { key: "version", header: "نسخه" },
+ ],
+ createFields: [
+ { name: "template_key", label: "کلید قالب", required: true },
+ { name: "name", label: "نام", required: true },
+ { name: "channel", label: "کانال", required: true, placeholder: "sms" },
+ { name: "locale", label: "زبان", placeholder: "fa" },
+ { name: "body", label: "متن", type: "textarea" as const, required: true },
+ ],
+ } satisfies CommunicationListConfig,
+
+ recipients: {
+ title: "گیرندگان",
+ resourceKey: "recipients",
+ permission: "communication.contacts.view",
+ api: {
+ list: (tenantId: string) => communicationApi.contacts.manual.list(tenantId),
+ create: (tenantId: string, body: Record) =>
+ communicationApi.contacts.manual.create(tenantId, body),
+ },
+ columns: [
+ { key: "display_name", header: "نام" },
+ { key: "phone", header: "موبایل" },
+ { key: "email", header: "ایمیل" },
+ { key: "created_at", header: "ایجاد", type: "datetime" as const },
+ ],
+ createFields: [
+ { name: "display_name", label: "نام نمایشی" },
+ { name: "phone", label: "موبایل", required: true },
+ { name: "email", label: "ایمیل", type: "email" as const },
+ ],
+ } satisfies CommunicationListConfig,
+
+ contactSources: {
+ title: "منابع پویای مخاطب",
+ resourceKey: "contactSources",
+ permission: "communication.sources.view",
+ api: {
+ list: (tenantId: string) => communicationApi.contacts.sources.list(tenantId),
+ create: (tenantId: string, body: Record) =>
+ communicationApi.contacts.sources.create(tenantId, body),
+ },
+ columns: [
+ { key: "name", header: "نام" },
+ { key: "source_type", header: "نوع" },
+ { key: "base_url", header: "URL پایه" },
+ { key: "is_active", header: "فعال" },
+ ],
+ createFields: [
+ { name: "name", label: "نام", required: true },
+ {
+ name: "source_type",
+ label: "نوع منبع",
+ type: "select" as const,
+ required: true,
+ options: [
+ { value: "crm", label: "CRM" },
+ { value: "loyalty", label: "Loyalty" },
+ { value: "sports", label: "Sports" },
+ { value: "restaurant", label: "Restaurant" },
+ { value: "external_rest", label: "REST خارجی" },
+ ],
+ },
+ { name: "base_url", label: "URL پایه" },
+ { name: "endpoint_path", label: "مسیر endpoint" },
+ ],
+ } satisfies CommunicationListConfig,
+};
diff --git a/frontend/modules/communication/design-system/CommunicationStatGrid.tsx b/frontend/modules/communication/design-system/CommunicationStatGrid.tsx
new file mode 100644
index 0000000..95b9cee
--- /dev/null
+++ b/frontend/modules/communication/design-system/CommunicationStatGrid.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { StatCard } from "@/components/ds";
+
+export function CommunicationStatGrid({
+ stats,
+}: {
+ stats: { label: string; value: string; hint?: string }[];
+}) {
+ return (
+
+ {stats.map((s) => (
+
+ ))}
+
+ );
+}
diff --git a/frontend/modules/communication/design-system/CommunicationStatusChip.tsx b/frontend/modules/communication/design-system/CommunicationStatusChip.tsx
new file mode 100644
index 0000000..5e81238
--- /dev/null
+++ b/frontend/modules/communication/design-system/CommunicationStatusChip.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { Badge } from "@/components/ds";
+import { messageStatusLabels, messageStatusTone } from "./tokens";
+
+export function CommunicationStatusChip({ status }: { status: string }) {
+ const label = messageStatusLabels[status] ?? status;
+ const tone = messageStatusTone[status] ?? "default";
+ return {label};
+}
diff --git a/frontend/modules/communication/design-system/CommunicationTablePage.tsx b/frontend/modules/communication/design-system/CommunicationTablePage.tsx
new file mode 100644
index 0000000..80432fa
--- /dev/null
+++ b/frontend/modules/communication/design-system/CommunicationTablePage.tsx
@@ -0,0 +1,152 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { PageHeader, DataTable, EmptyState, Input, Pagination, Skeleton } from "@/components/ds";
+import { Search } from "lucide-react";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export type CommunicationTableColumn = {
+ key: string;
+ header: string;
+ className?: string;
+ render?: (row: Record) => React.ReactNode;
+ searchable?: boolean;
+ sortable?: boolean;
+ defaultVisible?: boolean;
+};
+
+export function CommunicationTablePage({
+ title,
+ description,
+ breadcrumb,
+ columns,
+ rows,
+ empty,
+ actions,
+ searchPlaceholder = "جستجو…",
+ filterFn,
+ toolbar,
+ page,
+ pageSize,
+ onPageChange,
+ selectedIds,
+ onSelectionChange,
+ bulkActions,
+ loading,
+}: {
+ title: string;
+ description?: string;
+ breadcrumb?: string;
+ columns: CommunicationTableColumn[];
+ rows: Record[];
+ empty?: React.ReactNode;
+ actions?: React.ReactNode;
+ searchPlaceholder?: string;
+ filterFn?: (row: Record, query: string) => boolean;
+ toolbar?: React.ReactNode;
+ page?: number;
+ pageSize?: number;
+ onPageChange?: (page: number) => void;
+ selectedIds?: Set;
+ onSelectionChange?: (ids: Set) => void;
+ bulkActions?: React.ReactNode;
+ loading?: boolean;
+}) {
+ const [query, setQuery] = useState("");
+ const [sortKey, setSortKey] = useState(null);
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+
+ const visibleColumns = columns;
+
+ const filtered = useMemo(() => {
+ let data = rows;
+ const q = query.trim().toLowerCase();
+ if (q) {
+ data = filterFn
+ ? data.filter((r) => filterFn(r, q))
+ : data.filter((r) =>
+ visibleColumns.some((c) => {
+ if (c.searchable === false) return false;
+ const val = r[c.key];
+ return val != null && String(val).toLowerCase().includes(q);
+ })
+ );
+ }
+ if (sortKey) {
+ data = [...data].sort((a, b) => {
+ const av = a[sortKey];
+ const bv = b[sortKey];
+ const cmp = String(av ?? "").localeCompare(String(bv ?? ""), "fa");
+ return sortDir === "asc" ? cmp : -cmp;
+ });
+ }
+ return data;
+ }, [rows, query, visibleColumns, filterFn, sortKey, sortDir]);
+
+ const paged = useMemo(() => {
+ if (!pageSize || !page) return filtered;
+ const start = (page - 1) * pageSize;
+ return filtered.slice(start, start + pageSize);
+ }, [filtered, page, pageSize]);
+
+ const totalPages = pageSize ? Math.max(1, Math.ceil(filtered.length / pageSize)) : 1;
+
+ if (loading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ {breadcrumb ?
: null}
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+ {toolbar}
+
+ {selectedIds && selectedIds.size > 0 && bulkActions ? (
+
+ {selectedIds.size} مورد انتخاب شده
+ {bulkActions}
+
+ ) : null}
+ {paged.length === 0 ? (
+ empty ??
+ ) : (
+ <>
+
({
+ key: c.key,
+ header: c.header,
+ className: c.className,
+ render: c.render
+ ? (row: Record) => c.render!(row)
+ : undefined,
+ }))}
+ rows={paged}
+ />
+ {pageSize && page && onPageChange ? (
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/modules/communication/design-system/index.ts b/frontend/modules/communication/design-system/index.ts
new file mode 100644
index 0000000..f4b9b08
--- /dev/null
+++ b/frontend/modules/communication/design-system/index.ts
@@ -0,0 +1,5 @@
+export { CommunicationTablePage } from "./CommunicationTablePage";
+export type { CommunicationTableColumn } from "./CommunicationTablePage";
+export { CommunicationStatGrid } from "./CommunicationStatGrid";
+export { CommunicationStatusChip } from "./CommunicationStatusChip";
+export * from "./tokens";
diff --git a/frontend/modules/communication/design-system/tokens.ts b/frontend/modules/communication/design-system/tokens.ts
new file mode 100644
index 0000000..39b780d
--- /dev/null
+++ b/frontend/modules/communication/design-system/tokens.ts
@@ -0,0 +1,43 @@
+/** Communication module visual tokens — indigo/violet messaging palette. */
+export const communicationTokens = {
+ accent: "var(--communication-accent)",
+ accentSoft: "var(--communication-accent-soft)",
+ success: "var(--communication-success)",
+ warning: "var(--communication-warning)",
+ danger: "var(--communication-danger)",
+} as const;
+
+export const messageStatusLabels: Record = {
+ queued: "در صف",
+ sent: "ارسالشده",
+ delivered: "تحویلشده",
+ failed: "ناموفق",
+ cancelled: "لغوشده",
+ expired: "منقضی",
+ draft: "پیشنویس",
+ pending: "در انتظار",
+ approved: "تأییدشده",
+ rejected: "ردشده",
+ active: "فعال",
+ inactive: "غیرفعال",
+ degraded: "تضعیفشده",
+};
+
+export const messageStatusTone: Record<
+ string,
+ "default" | "primary" | "success" | "warning" | "danger" | "info"
+> = {
+ queued: "info",
+ sent: "primary",
+ delivered: "success",
+ failed: "danger",
+ cancelled: "warning",
+ expired: "default",
+ draft: "default",
+ pending: "info",
+ approved: "success",
+ rejected: "danger",
+ active: "success",
+ inactive: "default",
+ degraded: "warning",
+};
diff --git a/frontend/modules/communication/features/aiCampaigns.tsx b/frontend/modules/communication/features/aiCampaigns.tsx
new file mode 100644
index 0000000..c979c92
--- /dev/null
+++ b/frontend/modules/communication/features/aiCampaigns.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.ai_campaigns!;
+export const AiCampaignsPage = createCommunicationLockedPage(config);
+export default AiCampaignsPage;
diff --git a/frontend/modules/communication/features/apiKeys.tsx b/frontend/modules/communication/features/apiKeys.tsx
new file mode 100644
index 0000000..81e4b04
--- /dev/null
+++ b/frontend/modules/communication/features/apiKeys.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.api_keys!;
+export const ApiKeysPage = createCommunicationLockedPage(config);
+export default ApiKeysPage;
diff --git a/frontend/modules/communication/features/audit.tsx b/frontend/modules/communication/features/audit.tsx
new file mode 100644
index 0000000..23510e3
--- /dev/null
+++ b/frontend/modules/communication/features/audit.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.audit!;
+export const AuditPage = createCommunicationLockedPage(config);
+export default AuditPage;
diff --git a/frontend/modules/communication/features/automationTriggers.tsx b/frontend/modules/communication/features/automationTriggers.tsx
new file mode 100644
index 0000000..300a1cc
--- /dev/null
+++ b/frontend/modules/communication/features/automationTriggers.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.automation_triggers!;
+export const AutomationTriggersPage = createCommunicationLockedPage(config);
+export default AutomationTriggersPage;
diff --git a/frontend/modules/communication/features/campaignOrchestrator.tsx b/frontend/modules/communication/features/campaignOrchestrator.tsx
new file mode 100644
index 0000000..b622e41
--- /dev/null
+++ b/frontend/modules/communication/features/campaignOrchestrator.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.campaign_orchestrator!;
+export const CampaignOrchestratorPage = createCommunicationLockedPage(config);
+export default CampaignOrchestratorPage;
diff --git a/frontend/modules/communication/features/campaigns.tsx b/frontend/modules/communication/features/campaigns.tsx
new file mode 100644
index 0000000..90b4557
--- /dev/null
+++ b/frontend/modules/communication/features/campaigns.tsx
@@ -0,0 +1 @@
+export { SendMessagePage as CampaignsPage, default } from "./sendMessage";
diff --git a/frontend/modules/communication/features/capabilities.tsx b/frontend/modules/communication/features/capabilities.tsx
new file mode 100644
index 0000000..4e46699
--- /dev/null
+++ b/frontend/modules/communication/features/capabilities.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useCommunicationCapabilities } from "@/modules/communication/hooks/useCommunicationCapabilities";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function CapabilitiesPage() {
+ const q = useCommunicationCapabilities();
+ if (q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const features = q.data?.features ?? {};
+ const channels = q.data?.channels ?? [];
+
+ return (
+
+
+
+
+ {Object.entries(features).map(([k, v]) => (
+
+
+ {k}
+ {v ? "فعال" : "غیرفعال"}
+
+
+ ))}
+
+
+
+ کانالها
+
+ {channels.map((c) => (
+
+ {c.channel}: {c.status}
+
+ ))}
+
+
+
+
+ );
+}
+
+export default CapabilitiesPage;
diff --git a/frontend/modules/communication/features/connectors.tsx b/frontend/modules/communication/features/connectors.tsx
new file mode 100644
index 0000000..f8b7199
--- /dev/null
+++ b/frontend/modules/communication/features/connectors.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.connectors!;
+export const ConnectorsPage = createCommunicationLockedPage(config);
+export default ConnectorsPage;
diff --git a/frontend/modules/communication/features/contactSources.tsx b/frontend/modules/communication/features/contactSources.tsx
new file mode 100644
index 0000000..a368924
--- /dev/null
+++ b/frontend/modules/communication/features/contactSources.tsx
@@ -0,0 +1,5 @@
+import { createCommunicationListPage } from "@/modules/communication/components/CommunicationListCrudPage";
+import { communicationResources } from "@/modules/communication/constants/resourceRegistry";
+
+export const ContactSourcesPage = createCommunicationListPage(communicationResources.contactSources);
+export default ContactSourcesPage;
diff --git a/frontend/modules/communication/features/conversations.tsx b/frontend/modules/communication/features/conversations.tsx
new file mode 100644
index 0000000..2427c0c
--- /dev/null
+++ b/frontend/modules/communication/features/conversations.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.conversations!;
+export const ConversationsPage = createCommunicationLockedPage(config);
+export default ConversationsPage;
diff --git a/frontend/modules/communication/features/dashboard.tsx b/frontend/modules/communication/features/dashboard.tsx
new file mode 100644
index 0000000..ad9946d
--- /dev/null
+++ b/frontend/modules/communication/features/dashboard.tsx
@@ -0,0 +1 @@
+export { ExecutiveDashboard as CommunicationDashboard, default } from "./executiveDashboard";
diff --git a/frontend/modules/communication/features/deliveryReports.tsx b/frontend/modules/communication/features/deliveryReports.tsx
new file mode 100644
index 0000000..1ea28e7
--- /dev/null
+++ b/frontend/modules/communication/features/deliveryReports.tsx
@@ -0,0 +1 @@
+export { MessagesPage as DeliveryReportsPage, default } from "./messages";
diff --git a/frontend/modules/communication/features/email.tsx b/frontend/modules/communication/features/email.tsx
new file mode 100644
index 0000000..133e83c
--- /dev/null
+++ b/frontend/modules/communication/features/email.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.email!;
+export const EmailPage = createCommunicationLockedPage(config);
+export default EmailPage;
diff --git a/frontend/modules/communication/features/executiveDashboard.tsx b/frontend/modules/communication/features/executiveDashboard.tsx
new file mode 100644
index 0000000..9340d73
--- /dev/null
+++ b/frontend/modules/communication/features/executiveDashboard.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import {
+ useCommunicationCapabilities,
+ useCommunicationMonitoring,
+} from "@/modules/communication/hooks/useCommunicationCapabilities";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, StatCard, Card, CardContent, Button, Badge } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function ExecutiveDashboard() {
+ const { tenantId } = useTenantId();
+ const caps = useCommunicationCapabilities();
+ const monitoring = useCommunicationMonitoring(tenantId);
+
+ const countsQ = useQuery({
+ queryKey: ["communication", tenantId, "exec-counts"],
+ queryFn: async () => {
+ const [providers, templates, messages] = await Promise.all([
+ communicationApi.providers.list(tenantId!),
+ communicationApi.templates.list(tenantId!),
+ communicationApi.messages.list(tenantId!, { limit: 500 }),
+ ]);
+ return {
+ providers: providers.length,
+ templates: templates.length,
+ messages: messages.length,
+ delivered: messages.filter((m) => m.status === "delivered").length,
+ };
+ },
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || countsQ.isLoading || caps.isLoading) return ;
+ if (countsQ.error) return countsQ.refetch()} />;
+
+ const c = countsQ.data!;
+ const stats = monitoring.data;
+
+ return (
+
+
+
v{caps.data?.version} · SMS MVP
+ }
+ />
+
+
+
+
+
+
+ {stats ? (
+
+
+
+ صف پیام
+
+ {JSON.stringify(stats.queue_by_status, null, 2)}
+
+
+
+
+
+ وضعیت پیامها
+
+ {JSON.stringify(stats.messages_by_status, null, 2)}
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ExecutiveDashboard;
diff --git a/frontend/modules/communication/features/health.tsx b/frontend/modules/communication/features/health.tsx
new file mode 100644
index 0000000..d89f080
--- /dev/null
+++ b/frontend/modules/communication/features/health.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { useCommunicationHealth, useCommunicationHealthReady } from "@/modules/communication/hooks/useCommunicationCapabilities";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function HealthPage() {
+ const health = useCommunicationHealth();
+ const ready = useCommunicationHealthReady();
+ if (health.isLoading) return ;
+ if (health.error) return health.refetch()} />;
+
+ return (
+
+
+
+
+
+ {health.data?.status}
+ {health.data?.service} — v{health.data?.version}
+ {ready.data ? (
+ DB: {ready.data.database} — {ready.data.status}
+ ) : null}
+
+
+
+ );
+}
+
+export default HealthPage;
diff --git a/frontend/modules/communication/features/integrationsHub.tsx b/frontend/modules/communication/features/integrationsHub.tsx
new file mode 100644
index 0000000..99e44f1
--- /dev/null
+++ b/frontend/modules/communication/features/integrationsHub.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.integrations_hub!;
+export const IntegrationsHubPage = createCommunicationLockedPage(config);
+export default IntegrationsHubPage;
diff --git a/frontend/modules/communication/features/journeyBuilder.tsx b/frontend/modules/communication/features/journeyBuilder.tsx
new file mode 100644
index 0000000..8a9a123
--- /dev/null
+++ b/frontend/modules/communication/features/journeyBuilder.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.journey_builder!;
+export const JourneyBuilderPage = createCommunicationLockedPage(config);
+export default JourneyBuilderPage;
diff --git a/frontend/modules/communication/features/liveChat.tsx b/frontend/modules/communication/features/liveChat.tsx
new file mode 100644
index 0000000..6db5dd4
--- /dev/null
+++ b/frontend/modules/communication/features/liveChat.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.live_chat!;
+export const LiveChatPage = createCommunicationLockedPage(config);
+export default LiveChatPage;
diff --git a/frontend/modules/communication/features/marketingAutomation.tsx b/frontend/modules/communication/features/marketingAutomation.tsx
new file mode 100644
index 0000000..bf441cd
--- /dev/null
+++ b/frontend/modules/communication/features/marketingAutomation.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.marketing_automation!;
+export const MarketingAutomationPage = createCommunicationLockedPage(config);
+export default MarketingAutomationPage;
diff --git a/frontend/modules/communication/features/messages.tsx b/frontend/modules/communication/features/messages.tsx
new file mode 100644
index 0000000..e334797
--- /dev/null
+++ b/frontend/modules/communication/features/messages.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader, CommunicationPageError, exportToCsv } from "@/modules/communication/pages/shared";
+import { CommunicationTablePage, CommunicationStatusChip } from "@/modules/communication/design-system";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import { Button, Drawer, Badge } from "@/components/ds";
+import { Timeline } from "@/src/shared/ui";
+import { Download, RefreshCw } from "lucide-react";
+import { useCommunicationPermissions } from "@/modules/communication/hooks/useCommunicationPermissions";
+import { PermissionDeniedState } from "@/src/shared/ui";
+
+export function MessagesPage() {
+ const { tenantId } = useTenantId();
+ const perms = useCommunicationPermissions();
+ const [status, setStatus] = useState("");
+ const [detailId, setDetailId] = useState(null);
+
+ const listQ = useQuery({
+ queryKey: ["communication", tenantId, "messages", status],
+ queryFn: () => communicationApi.messages.list(tenantId!, status ? { status } : undefined),
+ enabled: !!tenantId,
+ });
+
+ const timelineQ = useQuery({
+ queryKey: ["communication", tenantId, "timeline", detailId],
+ queryFn: () => communicationApi.messages.timeline(tenantId!, detailId!),
+ enabled: !!tenantId && !!detailId,
+ });
+
+ if (!tenantId || listQ.isLoading) return ;
+ if (listQ.error) return listQ.refetch()} />;
+ if (!perms.can("communication.messages.view")) return ;
+
+ const rows = listQ.data ?? [];
+
+ return (
+ <>
+ },
+ { key: "channel", header: "کانال" },
+ { key: "sent_at", header: "ارسال", render: (r) => (r.sent_at ? new Date(String(r.sent_at)).toLocaleString("fa-IR") : "—") },
+ {
+ key: "id",
+ header: "جزئیات",
+ render: (r) => (
+
+ ),
+ },
+ ]}
+ rows={rows}
+ toolbar={
+
+ }
+ actions={
+
+
+
+
+ }
+ />
+ setDetailId(null)} title="Timeline تحویل">
+ {timelineQ.isLoading ? : null}
+ {timelineQ.data ? (
+ ({
+ id: String(e.id),
+ title: String(e.status),
+ subtitle: e.detail ? String(e.detail) : undefined,
+ timestamp: String(e.created_at ?? new Date().toISOString()),
+ }))}
+ />
+ ) : null}
+
+ >
+ );
+}
+
+export default MessagesPage;
diff --git a/frontend/modules/communication/features/monitoring.tsx b/frontend/modules/communication/features/monitoring.tsx
new file mode 100644
index 0000000..4904b95
--- /dev/null
+++ b/frontend/modules/communication/features/monitoring.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function MonitoringPage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["communication", tenantId, "monitoring"],
+ queryFn: () => communicationApi.monitoring.stats(tenantId!),
+ enabled: !!tenantId,
+ });
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ return (
+
+
+
+
{JSON.stringify(q.data, null, 2)}
+
+ );
+}
+
+export const AnalyticsPage = function Analytics() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["communication", tenantId, "metrics"],
+ queryFn: () => communicationApi.monitoring.metrics(tenantId!),
+ enabled: !!tenantId,
+ });
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+ return (
+
+
+
+
{JSON.stringify(q.data, null, 2)}
+
+ );
+};
+
+export const ReportsPage = MonitoringPage;
+export default MonitoringPage;
diff --git a/frontend/modules/communication/features/notificationCenter.tsx b/frontend/modules/communication/features/notificationCenter.tsx
new file mode 100644
index 0000000..e38f2c2
--- /dev/null
+++ b/frontend/modules/communication/features/notificationCenter.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.notification_center!;
+export const NotificationCenterPage = createCommunicationLockedPage(config);
+export default NotificationCenterPage;
diff --git a/frontend/modules/communication/features/omnichannelInbox.tsx b/frontend/modules/communication/features/omnichannelInbox.tsx
new file mode 100644
index 0000000..3a3f3be
--- /dev/null
+++ b/frontend/modules/communication/features/omnichannelInbox.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.omnichannel_inbox!;
+export const OmnichannelInboxPage = createCommunicationLockedPage(config);
+export default OmnichannelInboxPage;
diff --git a/frontend/modules/communication/features/otp.tsx b/frontend/modules/communication/features/otp.tsx
new file mode 100644
index 0000000..37577b1
--- /dev/null
+++ b/frontend/modules/communication/features/otp.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Button, FormField, Input } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import { useCommunicationPermissions } from "@/modules/communication/hooks/useCommunicationPermissions";
+import { PermissionDeniedState } from "@/src/shared/ui";
+
+export function OtpPage() {
+ const { tenantId } = useTenantId();
+ const perms = useCommunicationPermissions();
+ const [dest, setDest] = useState("");
+ const [code, setCode] = useState("");
+ const [challengeId, setChallengeId] = useState(null);
+
+ const requestM = useMutation({
+ mutationFn: () =>
+ communicationApi.otp.request(tenantId!, { destination: dest, channel: "sms", purpose: "business" }),
+ onSuccess: (r) => {
+ toast.success("OTP درخواست شد");
+ setChallengeId(String(r.challenge_id));
+ if (r.debug_code) toast.message(`Debug: ${r.debug_code}`);
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ const verifyM = useMutation({
+ mutationFn: () =>
+ communicationApi.otp.verify(tenantId!, {
+ destination: dest,
+ code,
+ challenge_id: challengeId,
+ }),
+ onSuccess: (r) => toast.success(r.verified ? "تأیید شد" : "ناموفق"),
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId) return ;
+ if (!perms.can("communication.otp.request")) return ;
+
+ return (
+
+ );
+}
+
+export default OtpPage;
diff --git a/frontend/modules/communication/features/permissions.tsx b/frontend/modules/communication/features/permissions.tsx
new file mode 100644
index 0000000..c7f572f
--- /dev/null
+++ b/frontend/modules/communication/features/permissions.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { COMMUNICATION_PERMISSIONS } from "@/modules/communication/constants/permissions";
+import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function PermissionsPage() {
+ return (
+
+
+
+
+ {COMMUNICATION_PERMISSIONS.map((p) => (
+
+
+ {p}
+ تعریفشده
+
+
+ ))}
+
+
+ );
+}
+
+export default PermissionsPage;
diff --git a/frontend/modules/communication/features/providerDashboard.tsx b/frontend/modules/communication/features/providerDashboard.tsx
new file mode 100644
index 0000000..0c76eca
--- /dev/null
+++ b/frontend/modules/communication/features/providerDashboard.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { useCommunicationMonitoring } from "@/modules/communication/hooks/useCommunicationCapabilities";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, StatCard, Card, CardContent } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import { CommunicationStatusChip } from "@/modules/communication/design-system";
+
+export function ProviderDashboard() {
+ const { tenantId } = useTenantId();
+ const monitoring = useCommunicationMonitoring(tenantId);
+ const statusQ = useQuery({
+ queryKey: ["communication", tenantId, "provider-status"],
+ queryFn: () => communicationApi.providers.status(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ if (!tenantId || statusQ.isLoading) return ;
+ if (statusQ.error) return statusQ.refetch()} />;
+
+ const rows = statusQ.data ?? [];
+
+ return (
+
+
+
+
+
+ r.healthy).length)} />
+
+
+
+
+ {rows.map((p) => (
+
+
+
{String(p.name)}
+
{String(p.provider_kind)} · {String(p.channel)}
+
+
+
+ ))}
+ {rows.length === 0 ? ارائهدهندهای ثبت نشده
: null}
+
+
+
+ );
+}
+
+export default ProviderDashboard;
diff --git a/frontend/modules/communication/features/providers.tsx b/frontend/modules/communication/features/providers.tsx
new file mode 100644
index 0000000..a11dd39
--- /dev/null
+++ b/frontend/modules/communication/features/providers.tsx
@@ -0,0 +1,5 @@
+import { createCommunicationListPage } from "@/modules/communication/components/CommunicationListCrudPage";
+import { communicationResources } from "@/modules/communication/constants/resourceRegistry";
+
+export const ProvidersPage = createCommunicationListPage(communicationResources.providers);
+export default ProvidersPage;
diff --git a/frontend/modules/communication/features/push.tsx b/frontend/modules/communication/features/push.tsx
new file mode 100644
index 0000000..4d08239
--- /dev/null
+++ b/frontend/modules/communication/features/push.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.push!;
+export const PushPage = createCommunicationLockedPage(config);
+export default PushPage;
diff --git a/frontend/modules/communication/features/queue.tsx b/frontend/modules/communication/features/queue.tsx
new file mode 100644
index 0000000..a24cad7
--- /dev/null
+++ b/frontend/modules/communication/features/queue.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Button, StatCard } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import { CommunicationTablePage, CommunicationStatusChip } from "@/modules/communication/design-system";
+
+export function QueuePage() {
+ const { tenantId } = useTenantId();
+
+ const statsQ = useQuery({
+ queryKey: ["communication", tenantId, "queue-stats"],
+ queryFn: () => communicationApi.messages.queue.stats(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const dlqQ = useQuery({
+ queryKey: ["communication", tenantId, "dlq"],
+ queryFn: () => communicationApi.messages.queue.deadLetters(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const processM = useMutation({
+ mutationFn: () => communicationApi.messages.queue.process(tenantId!, 50),
+ onSuccess: (r) => {
+ toast.success(`${r.processed} مورد پردازش شد`);
+ statsQ.refetch();
+ dlqQ.refetch();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId || statsQ.isLoading) return ;
+ if (statsQ.error) return statsQ.refetch()} />;
+
+ const stats = statsQ.data as Record;
+ const dlq = dlqQ.data ?? [];
+
+ return (
+
+
+
processM.mutate()} loading={processM.isPending}>پردازش صف}
+ />
+
+ {Object.entries((stats as { by_status?: Record }).by_status ?? stats).slice(0, 6).map(([k, v]) => (
+ typeof v === "number" ? : null
+ ))}
+
+ },
+ { key: "attempt", header: "تلاش" },
+ { key: "last_error", header: "خطا" },
+ ]}
+ rows={dlq}
+ />
+
+ );
+}
+
+export default QueuePage;
diff --git a/frontend/modules/communication/features/recipients.tsx b/frontend/modules/communication/features/recipients.tsx
new file mode 100644
index 0000000..1f61853
--- /dev/null
+++ b/frontend/modules/communication/features/recipients.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationListPage } from "@/modules/communication/components/CommunicationListCrudPage";
+import { communicationResources } from "@/modules/communication/constants/resourceRegistry";
+
+export const RecipientsPage = createCommunicationListPage(communicationResources.recipients);
+export const AddressBooksPage = RecipientsPage;
+export default RecipientsPage;
diff --git a/frontend/modules/communication/features/roles.tsx b/frontend/modules/communication/features/roles.tsx
new file mode 100644
index 0000000..ee231ea
--- /dev/null
+++ b/frontend/modules/communication/features/roles.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.roles!;
+export const RolesPage = createCommunicationLockedPage(config);
+export default RolesPage;
diff --git a/frontend/modules/communication/features/rubika.tsx b/frontend/modules/communication/features/rubika.tsx
new file mode 100644
index 0000000..c29e972
--- /dev/null
+++ b/frontend/modules/communication/features/rubika.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.rubika!;
+export const RubikaPage = createCommunicationLockedPage(config);
+export default RubikaPage;
diff --git a/frontend/modules/communication/features/sendMessage.tsx b/frontend/modules/communication/features/sendMessage.tsx
new file mode 100644
index 0000000..59a7521
--- /dev/null
+++ b/frontend/modules/communication/features/sendMessage.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { useForm } from "react-hook-form";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Button, FormField, Input, Textarea, Select } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import { useCommunicationPermissions } from "@/modules/communication/hooks/useCommunicationPermissions";
+import { PermissionDeniedState } from "@/src/shared/ui";
+
+export function SendMessagePage() {
+ const { tenantId } = useTenantId();
+ const perms = useCommunicationPermissions();
+ const form = useForm({
+ defaultValues: {
+ channel: "sms",
+ to_address: "",
+ body: "",
+ template_key: "",
+ priority: "normal",
+ correlation_id: "",
+ scheduled_at: "",
+ },
+ });
+
+ const templatesQ = useQuery({
+ queryKey: ["communication", tenantId, "templates-select"],
+ queryFn: () => communicationApi.templates.list(tenantId!),
+ enabled: !!tenantId,
+ });
+
+ const sendM = useMutation({
+ mutationFn: (body: Record) => communicationApi.messages.send(tenantId!, body),
+ onSuccess: (data) => {
+ toast.success(`${data.length} پیام در صف قرار گرفت`);
+ form.reset({ channel: "sms", priority: "normal", to_address: "", body: "", template_key: "", correlation_id: "", scheduled_at: "" });
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ if (!tenantId || templatesQ.isLoading) return ;
+ if (!perms.can("communication.messages.send")) return ;
+
+ return (
+
+ );
+}
+
+export default SendMessagePage;
diff --git a/frontend/modules/communication/features/senders.tsx b/frontend/modules/communication/features/senders.tsx
new file mode 100644
index 0000000..ffaf92c
--- /dev/null
+++ b/frontend/modules/communication/features/senders.tsx
@@ -0,0 +1,5 @@
+import { createCommunicationListPage } from "@/modules/communication/components/CommunicationListCrudPage";
+import { communicationResources } from "@/modules/communication/constants/resourceRegistry";
+
+export const SendersPage = createCommunicationListPage(communicationResources.senders);
+export default SendersPage;
diff --git a/frontend/modules/communication/features/settings.tsx b/frontend/modules/communication/features/settings.tsx
new file mode 100644
index 0000000..29b8df9
--- /dev/null
+++ b/frontend/modules/communication/features/settings.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import Link from "next/link";
+import { communicationApi } from "@/modules/communication/services/communication-api";
+import { useTenantId } from "@/hooks/useTenantId";
+import { useQuery } from "@tanstack/react-query";
+import { CommunicationPageLoader } from "@/modules/communication/pages/shared";
+import { PageHeader, Card, CardContent, Button } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+
+export function SettingsPage() {
+ const { tenantId } = useTenantId();
+ const q = useQuery({
+ queryKey: ["communication", tenantId, "settings-providers"],
+ queryFn: () => communicationApi.providers.list(tenantId!),
+ enabled: !!tenantId,
+ });
+ if (!tenantId || q.isLoading) return ;
+ return (
+
+
+
+
+
+ {q.data?.length ?? 0} ارائهدهنده فعال
+
+
+
+
+ );
+}
+
+export default SettingsPage;
diff --git a/frontend/modules/communication/features/smsDashboard.tsx b/frontend/modules/communication/features/smsDashboard.tsx
new file mode 100644
index 0000000..bd97464
--- /dev/null
+++ b/frontend/modules/communication/features/smsDashboard.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useCommunicationMonitoring } from "@/modules/communication/hooks/useCommunicationCapabilities";
+import { useTenantId } from "@/hooks/useTenantId";
+import { CommunicationPageLoader, CommunicationPageError } from "@/modules/communication/pages/shared";
+import { PageHeader, StatCard, Card, CardContent } from "@/components/ds";
+import { CommunicationBreadcrumbs } from "@/modules/communication/components/CommunicationBreadcrumbs";
+import Link from "next/link";
+import { Button } from "@/components/ds";
+
+export function SmsDashboard() {
+ const { tenantId } = useTenantId();
+ const q = useCommunicationMonitoring(tenantId);
+
+ if (!tenantId || q.isLoading) return ;
+ if (q.error) return q.refetch()} />;
+
+ const m = q.data!;
+ const msg = m.messages_by_status ?? {};
+ const queue = m.queue_by_status ?? {};
+
+ return (
+
+
+
+
+ {Object.entries(msg).map(([k, v]) => (
+
+ ))}
+ {Object.entries(queue).map(([k, v]) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SmsDashboard;
diff --git a/frontend/modules/communication/features/telegram.tsx b/frontend/modules/communication/features/telegram.tsx
new file mode 100644
index 0000000..1dc6da6
--- /dev/null
+++ b/frontend/modules/communication/features/telegram.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.telegram!;
+export const TelegramPage = createCommunicationLockedPage(config);
+export default TelegramPage;
diff --git a/frontend/modules/communication/features/templateCategories.tsx b/frontend/modules/communication/features/templateCategories.tsx
new file mode 100644
index 0000000..98ebb51
--- /dev/null
+++ b/frontend/modules/communication/features/templateCategories.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.template_categories!;
+export const TemplateCategoriesPage = createCommunicationLockedPage(config);
+export default TemplateCategoriesPage;
diff --git a/frontend/modules/communication/features/templates.tsx b/frontend/modules/communication/features/templates.tsx
new file mode 100644
index 0000000..8351b56
--- /dev/null
+++ b/frontend/modules/communication/features/templates.tsx
@@ -0,0 +1,5 @@
+import { createCommunicationListPage } from "@/modules/communication/components/CommunicationListCrudPage";
+import { communicationResources } from "@/modules/communication/constants/resourceRegistry";
+
+export const TemplatesPage = createCommunicationListPage(communicationResources.templates);
+export default TemplatesPage;
diff --git a/frontend/modules/communication/features/video.tsx b/frontend/modules/communication/features/video.tsx
new file mode 100644
index 0000000..aeaf39b
--- /dev/null
+++ b/frontend/modules/communication/features/video.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.video!;
+export const VideoPage = createCommunicationLockedPage(config);
+export default VideoPage;
diff --git a/frontend/modules/communication/features/voice.tsx b/frontend/modules/communication/features/voice.tsx
new file mode 100644
index 0000000..ea7ff6d
--- /dev/null
+++ b/frontend/modules/communication/features/voice.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.voice!;
+export const VoicePage = createCommunicationLockedPage(config);
+export default VoicePage;
diff --git a/frontend/modules/communication/features/webhookCenter.tsx b/frontend/modules/communication/features/webhookCenter.tsx
new file mode 100644
index 0000000..4ce8801
--- /dev/null
+++ b/frontend/modules/communication/features/webhookCenter.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.webhook_center!;
+export const WebhookCenterPage = createCommunicationLockedPage(config);
+export default WebhookCenterPage;
diff --git a/frontend/modules/communication/features/whatsapp.tsx b/frontend/modules/communication/features/whatsapp.tsx
new file mode 100644
index 0000000..f5cb00d
--- /dev/null
+++ b/frontend/modules/communication/features/whatsapp.tsx
@@ -0,0 +1,6 @@
+import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.whatsapp!;
+export const WhatsappPage = createCommunicationLockedPage(config);
+export default WhatsappPage;
diff --git a/frontend/modules/communication/hooks/useCommunicationCapabilities.ts b/frontend/modules/communication/hooks/useCommunicationCapabilities.ts
new file mode 100644
index 0000000..61810e5
--- /dev/null
+++ b/frontend/modules/communication/hooks/useCommunicationCapabilities.ts
@@ -0,0 +1,61 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import {
+ communicationApi,
+ normalizeCapabilities,
+} from "@/modules/communication/services/communication-api";
+
+export function useCommunicationCapabilities() {
+ return useQuery({
+ queryKey: ["communication", "capabilities"],
+ queryFn: async () => normalizeCapabilities(await communicationApi.capabilities()),
+ staleTime: 60_000,
+ });
+}
+
+export function useCommunicationHealth() {
+ return useQuery({
+ queryKey: ["communication", "health"],
+ queryFn: () => communicationApi.health(),
+ });
+}
+
+export function useCommunicationHealthReady() {
+ return useQuery({
+ queryKey: ["communication", "health-ready"],
+ queryFn: () => communicationApi.healthReady(),
+ });
+}
+
+export function useCommunicationMetrics() {
+ return useQuery({
+ queryKey: ["communication", "metrics"],
+ queryFn: () => communicationApi.metrics(),
+ staleTime: 30_000,
+ });
+}
+
+export function useCommunicationMonitoring(tenantId: string | null) {
+ return useQuery({
+ queryKey: ["communication", tenantId, "monitoring-stats"],
+ queryFn: () => communicationApi.monitoring.stats(tenantId!),
+ enabled: !!tenantId,
+ staleTime: 15_000,
+ });
+}
+
+export function useCommunicationLookups(_tenantId: string | null) {
+ return {
+ isLoading: false,
+ data: { providers: [] as Record[] },
+ };
+}
+
+export function isFeatureEnabled(
+ features: Record | undefined,
+ feature: string
+): boolean {
+ if (!features) return false;
+ return features[feature] === true;
+}
diff --git a/frontend/modules/communication/hooks/useCommunicationPermissions.ts b/frontend/modules/communication/hooks/useCommunicationPermissions.ts
new file mode 100644
index 0000000..d1757ae
--- /dev/null
+++ b/frontend/modules/communication/hooks/useCommunicationPermissions.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useMemo } from "react";
+import { useMe } from "@/hooks/useMe";
+
+const ADMIN_ROLES = new Set(["platform_admin", "tenant_owner", "tenant_admin"]);
+
+export function useCommunicationPermissions() {
+ const { me } = useMe();
+
+ return useMemo(() => {
+ const extended = me as { roles?: string[]; permissions?: string[] } | null | undefined;
+ const roles = new Set(extended?.roles ?? []);
+ const isAdmin =
+ me?.platform_role === "platform_admin" ||
+ [...roles].some((r) => ADMIN_ROLES.has(r));
+ const perms = new Set(extended?.permissions ?? []);
+
+ const can = (permission: string) => {
+ if (isAdmin) return true;
+ if (perms.has("communication.manage")) return true;
+ if (perms.has(permission)) return true;
+ if (permission.endsWith(".view") && perms.has("communication.view")) return true;
+ const parts = permission.split(".");
+ if (parts.length >= 2 && perms.has(`${parts[0]}.${parts[1]}.manage`)) return true;
+ return false;
+ };
+
+ return { can, isAdmin, roles, permissions: perms };
+ }, [me]);
+}
diff --git a/frontend/modules/communication/pages/shared.tsx b/frontend/modules/communication/pages/shared.tsx
new file mode 100644
index 0000000..b36fcf0
--- /dev/null
+++ b/frontend/modules/communication/pages/shared.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { LoadingState, ErrorState, EmptyState } from "@/components/ds";
+import { CommunicationApiError } from "@/modules/communication/services/communication-api";
+import { PermissionDeniedState } from "@/src/shared/ui";
+
+export function CommunicationPageLoader({ label = "در حال بارگذاری…" }: { label?: string }) {
+ return ;
+}
+
+export function CommunicationPageError({
+ error,
+ onRetry,
+}: {
+ error: unknown;
+ onRetry?: () => void;
+}) {
+ if (error instanceof CommunicationApiError && error.status === 403) {
+ return (
+
+ );
+ }
+ const message = error instanceof Error ? error.message : "خطا در دریافت دادهها";
+ return ;
+}
+
+export function CommunicationEmptyState({
+ title = "موردی یافت نشد",
+ description = "هنوز دادهای ثبت نشده است.",
+ action,
+}: {
+ title?: string;
+ description?: string;
+ action?: React.ReactNode;
+}) {
+ return ;
+}
+
+export function exportToCsv(title: string, rows: Record[], columns: string[]) {
+ if (rows.length === 0) return;
+ const header = columns.join(",");
+ const body = rows
+ .map((r) => columns.map((c) => JSON.stringify(r[c] ?? "")).join(","))
+ .join("\n");
+ const blob = new Blob(["\uFEFF" + header + "\n" + body], { type: "text/csv;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${title}-export.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/frontend/modules/communication/services/communication-api.ts b/frontend/modules/communication/services/communication-api.ts
new file mode 100644
index 0000000..44769a5
--- /dev/null
+++ b/frontend/modules/communication/services/communication-api.ts
@@ -0,0 +1,280 @@
+/**
+ * Communication Platform API — Torbat Communication (real backend only).
+ * Browser → /api/communication BFF; SSR → direct upstream :8005
+ */
+
+import { getValidAccessToken, refreshSession } from "@/lib/auth";
+import type {
+ CapabilitiesRawResponse,
+ HealthResponse,
+ MetricsResponse,
+ MonitoringMetrics,
+ MonitoringStats,
+ CommunicationListParams,
+} from "@/modules/communication/types";
+
+const COMM_BASE =
+ typeof window !== "undefined"
+ ? "/api/communication"
+ : process.env.COMMUNICATION_SERVICE_URL ||
+ process.env.NEXT_PUBLIC_COMMUNICATION_API_URL ||
+ "http://localhost:8005";
+
+export class CommunicationApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "CommunicationApiError";
+ }
+}
+
+type RequestOptions = RequestInit & { tenantId: string; auth?: boolean };
+
+async function request(path: string, options: RequestOptions): Promise {
+ const { tenantId, auth = true, headers: customHeaders, ...init } = options;
+ const headers = new Headers(customHeaders);
+ if (!headers.has("Content-Type") && init.body) {
+ headers.set("Content-Type", "application/json");
+ }
+ headers.set("X-Tenant-ID", tenantId);
+ if (auth) {
+ const token = await getValidAccessToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ }
+
+ const url = path.startsWith("http")
+ ? path
+ : `${COMM_BASE.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
+
+ let res: Response;
+ try {
+ res = await fetch(url, { ...init, headers });
+ } catch (err) {
+ const detail = err instanceof Error ? err.message : "network_error";
+ throw new Error(`اتصال به سرویس تربت ارتباط برقرار نشد (${url}) — ${detail}`);
+ }
+
+ if (res.status === 401 && auth) {
+ const refreshed = await refreshSession({ force: true });
+ if (refreshed) {
+ headers.set("Authorization", `Bearer ${refreshed}`);
+ res = await fetch(url, { ...init, headers });
+ }
+ }
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new CommunicationApiError(
+ res.status,
+ body?.error?.code || body?.detail || "unknown_error",
+ body?.error?.message || body?.detail || res.statusText
+ );
+ }
+ if (res.status === 204) return undefined as T;
+ return res.json() as Promise;
+}
+
+function qs(params?: CommunicationListParams) {
+ if (!params) return "";
+ const sp = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
+ });
+ const s = sp.toString();
+ return s ? `?${s}` : "";
+}
+
+export const communicationApi = {
+ health: (): Promise =>
+ fetch(`${COMM_BASE}/health`).then((r) => r.json()),
+
+ healthReady: (): Promise<{ status: string; database: string }> =>
+ fetch(`${COMM_BASE}/health/ready`).then((r) => r.json()),
+
+ capabilities: (): Promise =>
+ fetch(`${COMM_BASE}/capabilities`).then((r) => r.json()),
+
+ metrics: (): Promise =>
+ fetch(`${COMM_BASE}/metrics`).then((r) => r.json()),
+
+ providers: {
+ list: (tenantId: string) =>
+ request[]>("/api/v1/providers", { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request>(`/api/v1/providers/${id}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>("/api/v1/providers", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ update: (tenantId: string, id: string, body: Record) =>
+ request>(`/api/v1/providers/${id}`, {
+ tenantId,
+ method: "PATCH",
+ body: JSON.stringify(body),
+ }),
+ remove: (tenantId: string, id: string) =>
+ request(`/api/v1/providers/${id}`, { tenantId, method: "DELETE" }),
+ status: (tenantId: string) =>
+ request[]>("/api/v1/providers/status", { tenantId }),
+ balance: (tenantId: string, id: string) =>
+ request>(`/api/v1/providers/${id}/balance`, { tenantId }),
+ healthCheck: (tenantId: string, id: string) =>
+ request>(`/api/v1/providers/${id}/health`, {
+ tenantId,
+ method: "POST",
+ }),
+ senders: {
+ list: (tenantId: string) =>
+ request[]>("/api/v1/providers/senders/list", { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>("/api/v1/providers/senders", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ },
+
+ templates: {
+ list: (tenantId: string) =>
+ request[]>("/api/v1/templates", { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request>(`/api/v1/templates/${id}`, { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>("/api/v1/templates", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ submit: (tenantId: string, id: string) =>
+ request>(`/api/v1/templates/${id}/submit`, {
+ tenantId,
+ method: "POST",
+ }),
+ approve: (tenantId: string, id: string) =>
+ request>(`/api/v1/templates/${id}/approve`, {
+ tenantId,
+ method: "POST",
+ }),
+ reject: (tenantId: string, id: string, reason?: string) =>
+ request>(
+ `/api/v1/templates/${id}/reject${reason ? qs({ reason }) : ""}`,
+ { tenantId, method: "POST" }
+ ),
+ preview: (tenantId: string, id: string, variables: Record) =>
+ request<{ rendered: string; subject?: string }>(`/api/v1/templates/${id}/preview`, {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify({ variables }),
+ }),
+ },
+
+ contacts: {
+ manual: {
+ list: (tenantId: string) =>
+ request[]>("/api/v1/contacts/manual", { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>("/api/v1/contacts/manual", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ importCsv: (tenantId: string, body: { csv_text: string }) =>
+ request[]>("/api/v1/contacts/manual/import-csv", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ sources: {
+ list: (tenantId: string) =>
+ request[]>("/api/v1/contacts/sources", { tenantId }),
+ create: (tenantId: string, body: Record) =>
+ request>("/api/v1/contacts/sources", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+ resolve: (tenantId: string, body: Record) =>
+ request[]>("/api/v1/contacts/resolve", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ messages: {
+ send: (tenantId: string, body: Record) =>
+ request[]>("/api/v1/messages/send", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ list: (tenantId: string, params?: CommunicationListParams) =>
+ request[]>(`/api/v1/messages${qs(params)}`, { tenantId }),
+ get: (tenantId: string, id: string) =>
+ request>(`/api/v1/messages/${id}`, { tenantId }),
+ stats: (tenantId: string) =>
+ request>("/api/v1/messages/stats", { tenantId }),
+ timeline: (tenantId: string, id: string) =>
+ request[]>(`/api/v1/messages/${id}/timeline`, { tenantId }),
+ cancel: (tenantId: string, id: string) =>
+ request>(`/api/v1/messages/${id}/cancel`, {
+ tenantId,
+ method: "POST",
+ }),
+ queue: {
+ process: (tenantId: string, limit = 20) =>
+ request<{ processed: number }>(`/api/v1/messages/queue/process${qs({ limit })}`, {
+ tenantId,
+ method: "POST",
+ }),
+ deadLetters: (tenantId: string) =>
+ request[]>("/api/v1/messages/queue/dead-letters", { tenantId }),
+ stats: (tenantId: string) =>
+ request>("/api/v1/messages/queue/stats", { tenantId }),
+ },
+ },
+
+ otp: {
+ request: (tenantId: string, body: Record) =>
+ request>("/api/v1/otp/request", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ verify: (tenantId: string, body: Record) =>
+ request>("/api/v1/otp/verify", {
+ tenantId,
+ method: "POST",
+ body: JSON.stringify(body),
+ }),
+ },
+
+ monitoring: {
+ stats: (tenantId: string) =>
+ request("/api/v1/monitoring/stats", { tenantId }),
+ metrics: (tenantId: string) =>
+ request("/api/v1/monitoring/metrics", { tenantId }),
+ },
+};
+
+export function normalizeCapabilities(raw: CapabilitiesRawResponse) {
+ const features: Record = {};
+ for (const f of raw.features ?? []) features[f] = true;
+ for (const ch of raw.channels ?? []) {
+ features[`channel_${ch.channel}`] = ch.status === "active";
+ }
+ return {
+ ...raw,
+ commercial_product: "Torbat Communication",
+ phase: "8",
+ features,
+ };
+}
diff --git a/frontend/modules/communication/types/index.ts b/frontend/modules/communication/types/index.ts
new file mode 100644
index 0000000..a74d02a
--- /dev/null
+++ b/frontend/modules/communication/types/index.ts
@@ -0,0 +1,61 @@
+export type HealthResponse = {
+ status: string;
+ service: string;
+ version: string;
+ database?: string;
+};
+
+export type ChannelInfo = {
+ channel: string;
+ status: string;
+ providers: string[];
+};
+
+export type CapabilitiesRawResponse = {
+ service: string;
+ version: string;
+ channels: ChannelInfo[];
+ features: string[];
+ independent?: boolean;
+ requires_other_modules?: boolean;
+ registered_provider_kinds?: string[];
+ contact_source_types?: string[];
+};
+
+export type NormalizedCapabilities = CapabilitiesRawResponse & {
+ features: Record;
+ commercial_product: string;
+ phase: string;
+};
+
+export type MetricsResponse = {
+ service: string;
+ version: string;
+ features: Record | string[];
+};
+
+export type MonitoringStats = {
+ messages_by_status: Record;
+ queue_by_status: Record;
+ providers: Record[];
+ rate_limit_deferrals?: number;
+ checked_at?: string;
+};
+
+export type MonitoringMetrics = MonitoringStats & {
+ tenant_id?: string;
+ provider_delivery?: Record[];
+};
+
+export type FeatureLockConfig = {
+ feature: string;
+ title: string;
+ description: string;
+ phase?: string;
+ bundle?: string;
+ capabilities: string[];
+ integrations?: string[];
+ icon?: string;
+};
+
+export type CommunicationListParams = Record;
diff --git a/frontend/package.json b/frontend/package.json
index 4c13c3b..06f816a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,7 +23,10 @@
"validate:accounting-bundle": "node scripts/validate-accounting-bundle.mjs",
"validate:accounting": "npm run validate:accounting-imports && npm run validate:accounting-routes && npm run validate:accounting-circular && npm run validate:architecture",
"validate:hospitality-routes": "node scripts/validate-hospitality-routes.mjs",
- "validate:hospitality": "npm run validate:hospitality-routes && npm run validate:architecture"
+ "validate:hospitality": "npm run validate:hospitality-routes && npm run validate:architecture",
+ "validate:delivery-routes": "node scripts/validate-delivery-routes.mjs",
+ "validate:delivery": "npm run validate:delivery-routes && npm run validate:architecture",
+ "scaffold:communication": "node scripts/scaffold-communication-module.mjs"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
diff --git a/frontend/scripts/scaffold-communication-module.mjs b/frontend/scripts/scaffold-communication-module.mjs
new file mode 100644
index 0000000..f1b4556
--- /dev/null
+++ b/frontend/scripts/scaffold-communication-module.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+/** Scaffolds communication app routes + locked feature pages */
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
+const APP = path.join(ROOT, "app", "communication");
+const FEATURES = path.join(ROOT, "modules", "communication", "features");
+
+const lockKeyMap = {
+ email: "email",
+ whatsapp: "whatsapp",
+ telegram: "telegram",
+ rubika: "rubika",
+ push: "push",
+ voice: "voice",
+ video: "video",
+ liveChat: "live_chat",
+ omnichannelInbox: "omnichannel_inbox",
+ marketingAutomation: "marketing_automation",
+ automationTriggers: "automation_triggers",
+ journeyBuilder: "journey_builder",
+ aiCampaigns: "ai_campaigns",
+ webhookCenter: "webhook_center",
+ connectors: "connectors",
+ conversations: "conversations",
+ campaignOrchestrator: "campaign_orchestrator",
+ notificationCenter: "notification_center",
+ templateCategories: "template_categories",
+ roles: "roles",
+ audit: "audit",
+ apiKeys: "api_keys",
+ integrationsHub: "integrations_hub",
+};
+
+const PAGES = [
+ ["", "ExecutiveDashboard", "executiveDashboard"],
+ ["dashboard", "CommunicationDashboard", "dashboard"],
+ ["providers/dashboard", "ProviderDashboard", "providerDashboard"],
+ ["sms", "SmsDashboard", "smsDashboard"],
+ ["sms/providers", "ProvidersPage", "providers"],
+ ["sms/templates", "TemplatesPage", "templates"],
+ ["sms/template-categories", "TemplateCategoriesPage", "templateCategories"],
+ ["sms/senders", "SendersPage", "senders"],
+ ["sms/campaigns", "CampaignsPage", "campaigns"],
+ ["sms/send", "SendMessagePage", "sendMessage"],
+ ["sms/queue", "QueuePage", "queue"],
+ ["sms/delivery-reports", "DeliveryReportsPage", "deliveryReports"],
+ ["sms/messages", "MessagesPage", "messages"],
+ ["contacts/recipients", "RecipientsPage", "recipients"],
+ ["contacts/sources", "ContactSourcesPage", "contactSources"],
+ ["contacts/address-books", "AddressBooksPage", "recipients"],
+ ["email", "EmailPage", "email"],
+ ["whatsapp", "WhatsappPage", "whatsapp"],
+ ["telegram", "TelegramPage", "telegram"],
+ ["rubika", "RubikaPage", "rubika"],
+ ["push", "PushPage", "push"],
+ ["voice", "VoicePage", "voice"],
+ ["video", "VideoPage", "video"],
+ ["live-chat", "LiveChatPage", "liveChat"],
+ ["inbox", "OmnichannelInboxPage", "omnichannelInbox"],
+ ["automation", "MarketingAutomationPage", "marketingAutomation"],
+ ["automation/triggers", "AutomationTriggersPage", "automationTriggers"],
+ ["journey-builder", "JourneyBuilderPage", "journeyBuilder"],
+ ["ai-campaigns", "AiCampaignsPage", "aiCampaigns"],
+ ["webhooks", "WebhookCenterPage", "webhookCenter"],
+ ["connectors", "ConnectorsPage", "connectors"],
+ ["conversations", "ConversationsPage", "conversations"],
+ ["campaign-orchestrator", "CampaignOrchestratorPage", "campaignOrchestrator"],
+ ["notifications", "NotificationCenterPage", "notificationCenter"],
+ ["monitoring", "MonitoringPage", "monitoring"],
+ ["analytics", "AnalyticsPage", "monitoring"],
+ ["reports", "ReportsPage", "monitoring"],
+ ["health", "HealthPage", "health"],
+ ["capabilities", "CapabilitiesPage", "capabilities"],
+ ["settings", "SettingsPage", "settings"],
+ ["permissions", "PermissionsPage", "permissions"],
+ ["roles", "RolesPage", "roles"],
+ ["audit", "AuditPage", "audit"],
+ ["api-keys", "ApiKeysPage", "apiKeys"],
+ ["integrations", "IntegrationsHubPage", "integrationsHub"],
+ ["otp", "OtpPage", "otp"],
+];
+
+function ensureDir(d) {
+ fs.mkdirSync(d, { recursive: true });
+}
+
+function writeLockedFeature(featureFile, lockKey, exportName) {
+ const file = path.join(FEATURES, `${featureFile}.tsx`);
+ if (fs.existsSync(file)) return;
+ fs.writeFileSync(
+ file,
+ `import { createCommunicationLockedPage } from "@/modules/communication/components/CommunicationFeatureLock";
+import { FUTURE_MODULE_REGISTRY } from "@/modules/communication/constants/featureRegistry";
+
+const config = FUTURE_MODULE_REGISTRY.${lockKey}!;
+export const ${exportName} = createCommunicationLockedPage(config);
+export default ${exportName};
+`
+ );
+}
+
+function writeRoute(routePath, exportName, featureFile) {
+ const dir = path.join(APP, routePath);
+ ensureDir(dir);
+ fs.writeFileSync(
+ path.join(dir, "page.tsx"),
+ `"use client";
+export { ${exportName} as default } from "@/modules/communication/features/${featureFile}";
+`
+ );
+ fs.writeFileSync(
+ path.join(dir, "loading.tsx"),
+ `import { LoadingState } from "@/components/ds";
+export default function Loading() { return ; }
+`
+ );
+ fs.writeFileSync(
+ path.join(dir, "error.tsx"),
+ `"use client";
+import { ErrorState } from "@/components/ds";
+export default function Error({ error, reset }: { error: Error; reset: () => void }) {
+ return ;
+}
+`
+ );
+}
+
+ensureDir(APP);
+fs.writeFileSync(
+ path.join(APP, "layout.tsx"),
+ `"use client";
+import { CommunicationRootLayout } from "@/modules/communication/components/createPortalLayout";
+export default CommunicationRootLayout;
+`
+);
+fs.writeFileSync(
+ path.join(APP, "not-found.tsx"),
+ `import Link from "next/link";
+import { EmptyState, Button } from "@/components/ds";
+export default function NotFound() {
+ return (
+ } />
+ );
+}
+`
+);
+
+for (const [file, key] of Object.entries(lockKeyMap)) {
+ const exportName = file.charAt(0).toUpperCase() + file.slice(1) + "Page";
+ writeLockedFeature(file, key, exportName);
+}
+
+for (const [route, exportName, featureFile] of PAGES) {
+ writeRoute(route, exportName, featureFile);
+}
+
+console.log(`Scaffolded ${PAGES.length} communication routes`);
diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css
index fbe5271..6993d70 100644
--- a/frontend/styles/globals.css
+++ b/frontend/styles/globals.css
@@ -57,6 +57,13 @@
--sports-warning: #d97706;
--sports-danger: #dc2626;
+ /* Loyalty Platform module accents — engagement teal */
+ --loyalty-accent: #0d9488;
+ --loyalty-accent-soft: rgba(13, 148, 136, 0.12);
+ --loyalty-success: #059669;
+ --loyalty-warning: #d97706;
+ --loyalty-danger: #dc2626;
+
/* Beauty module accents — warm luxury palette */
--beauty-accent: #b76e79;
--beauty-accent-soft: rgba(183, 110, 121, 0.14);
@@ -65,6 +72,21 @@
--beauty-success: #059669;
--beauty-warning: #d97706;
--beauty-danger: #dc2626;
+
+ /* Delivery module accents — logistics teal/blue */
+ --delivery-accent: #0891b2;
+ --delivery-accent-soft: rgba(8, 145, 178, 0.12);
+ --delivery-map: #0e7490;
+ --delivery-success: #059669;
+ --delivery-warning: #d97706;
+ --delivery-danger: #dc2626;
+
+ /* Communication module accents — indigo messaging */
+ --communication-accent: #6366f1;
+ --communication-accent-soft: rgba(99, 102, 241, 0.12);
+ --communication-success: #059669;
+ --communication-warning: #d97706;
+ --communication-danger: #dc2626;
}
.dark {
@@ -118,6 +140,25 @@
--sports-success: #34d399;
--sports-warning: #fbbf24;
--sports-danger: #f87171;
+
+ --loyalty-accent: #2dd4bf;
+ --loyalty-accent-soft: rgba(45, 212, 191, 0.15);
+ --loyalty-success: #34d399;
+ --loyalty-warning: #fbbf24;
+ --loyalty-danger: #f87171;
+
+ --delivery-accent: #22d3ee;
+ --delivery-accent-soft: rgba(34, 211, 238, 0.16);
+ --delivery-map: #67e8f9;
+ --delivery-success: #34d399;
+ --delivery-warning: #fbbf24;
+ --delivery-danger: #f87171;
+
+ --communication-accent: #818cf8;
+ --communication-accent-soft: rgba(129, 140, 248, 0.16);
+ --communication-success: #34d399;
+ --communication-warning: #fbbf24;
+ --communication-danger: #f87171;
}
body {