From 174ecfa8fb30ec9964954236fc98a94b2cbf08fb Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 12:45:57 +0330 Subject: [PATCH] feat(admin): subscription purchase flow under settings shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a doctor/clinic settings sub-navigation shell (SettingsLayout) with "خرید اشتراک" as its first section, plus a mobile settings-list page. Rebuild the plan-selection page with a per-plan billing-period toggle, single price, most-popular badge and a current-plan status banner. Add a payment-success page that reads the gateway return params (?payment_uuid&status), shows the transaction receipt and the newly active plan, and redirects to the plans page with a toast on any non-success status. Frontend only — reuses the existing /api/v1/subscription/* and /api/v1/subscription-payment endpoints; no backend or API-doc changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/admin/App.tsx | 4 + .../components/layout/SettingsLayout.test.tsx | 47 ++ .../components/layout/SettingsLayout.tsx | 140 ++++ .../admin/pages/PaymentSuccessPage.test.tsx | 60 ++ assets/admin/pages/PaymentSuccessPage.tsx | 163 +++++ assets/admin/pages/SettingsMenuPage.test.tsx | 22 + assets/admin/pages/SettingsMenuPage.tsx | 61 ++ assets/admin/pages/SubscriptionPage.test.tsx | 77 +++ assets/admin/pages/SubscriptionPage.tsx | 596 +++++++----------- 9 files changed, 800 insertions(+), 370 deletions(-) create mode 100644 assets/admin/components/layout/SettingsLayout.test.tsx create mode 100644 assets/admin/components/layout/SettingsLayout.tsx create mode 100644 assets/admin/pages/PaymentSuccessPage.test.tsx create mode 100644 assets/admin/pages/PaymentSuccessPage.tsx create mode 100644 assets/admin/pages/SettingsMenuPage.test.tsx create mode 100644 assets/admin/pages/SettingsMenuPage.tsx create mode 100644 assets/admin/pages/SubscriptionPage.test.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 04093dd5..f1e05199 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -49,6 +49,8 @@ import ClinicServicesPage from './pages/ClinicServicesPage'; import SmsWalletPage from './pages/SmsWalletPage'; import MySecretariesPage from './pages/MySecretariesPage'; import AdminSubscriptionPage from './pages/AdminSubscriptionPage'; +import SettingsMenuPage from './pages/SettingsMenuPage'; +import PaymentSuccessPage from './pages/PaymentSuccessPage'; import PwaInstallBanner from './components/ui/PwaInstallBanner'; // ── Guards ────────────────────────────────────────────────────────────────── @@ -192,7 +194,9 @@ export default function App() { {/* فاز ۲ — دکتر / کلینیک */} } /> + } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.test.tsx b/assets/admin/components/layout/SettingsLayout.test.tsx new file mode 100644 index 00000000..56a85a3c --- /dev/null +++ b/assets/admin/components/layout/SettingsLayout.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; +import SettingsLayout, { SETTINGS_MENU } from './SettingsLayout'; + +describe('SettingsLayout', () => { + it('renders every settings menu item and the section content', () => { + renderWithProviders( + +
محتوای اشتراک
+
, + ); + + for (const item of SETTINGS_MENU) { + expect(screen.getByText(item.label)).toBeInTheDocument(); + } + expect(screen.getByText('محتوای اشتراک')).toBeInTheDocument(); + }); + + it('marks the active item with aria-current=page', () => { + renderWithProviders( +
, + ); + const active = screen.getByText('خرید اشتراک').closest('a'); + expect(active).toHaveAttribute('aria-current', 'page'); + expect(active).toHaveAttribute('href', '/admin/subscription'); + }); + + it('renders not-yet-implemented items as disabled placeholders', () => { + renderWithProviders( +
, + ); + // "حساب کاربری" has no route → disabled button with "به‌زودی" + const account = screen.getByText('حساب کاربری').closest('button'); + expect(account).toBeDisabled(); + expect(screen.getAllByText('به‌زودی').length).toBeGreaterThan(0); + }); + + it('filters the menu by the search query', () => { + renderWithProviders( +
, + ); + fireEvent.change(screen.getByLabelText('جستجو در تنظیمات'), { target: { value: 'اشتراک' } }); + expect(screen.getByText('خرید اشتراک')).toBeInTheDocument(); + expect(screen.queryByText('خدمات')).not.toBeInTheDocument(); + }); +}); diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..4df6ef84 --- /dev/null +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -0,0 +1,140 @@ +import React, { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, + WrenchScrewdriverIcon, BanknotesIcon, UsersIcon, ShieldCheckIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, MagnifyingGlassIcon, +} from '@heroicons/react/24/outline'; + +// ── Settings menu configuration ───────────────────────────────────────────── +// Single source of truth for the settings sub-navigation (desktop shell + +// mobile list). `to` = an existing admin route; items without `to` are not yet +// implemented and render as disabled placeholders ("به‌زودی"). +export type SettingsMenuItem = { + key: string; + label: string; + icon: React.ElementType; + to?: string; +}; + +export const SETTINGS_MENU: SettingsMenuItem[] = [ + { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' }, + { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon }, + { key: 'clinic', label: 'مدیریت مطب', icon: BuildingOffice2Icon }, + { key: 'services', label: 'خدمات', icon: WrenchScrewdriverIcon, to: '/admin/clinic-services' }, + { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' }, + { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, + { key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing' }, + { key: 'tags', label: 'برچسب‌ها', icon: TagIcon }, + { key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet' }, + { key: 'account', label: 'حساب کاربری', icon: UserCircleIcon }, +]; + +// ── Shared item styling ────────────────────────────────────────────────────── +function itemStyle(active: boolean, disabled: boolean): React.CSSProperties { + return { + display: 'flex', alignItems: 'center', gap: 10, + width: '100%', padding: '11px 14px', borderRadius: 'var(--r-sm)', + fontSize: 14, fontWeight: active ? 700 : 500, textAlign: 'right', + fontFamily: 'inherit', border: 'none', cursor: disabled ? 'not-allowed' : 'pointer', + background: active ? 'var(--accent)' : 'transparent', + color: active ? '#fff' : disabled ? 'var(--text-3)' : 'var(--text-2)', + transition: 'background .14s, color .14s', + }; +} + +function MenuRow({ item, active }: { item: SettingsMenuItem; active: boolean }) { + const Icon = item.icon; + const disabled = !item.to; + const inner = ( + <> + + {item.label} + {disabled && به‌زودی} + + ); + + if (disabled) { + return ; + } + return ( + { if (!active) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }} + onMouseLeave={(e) => { if (!active) (e.currentTarget as HTMLElement).style.background = 'transparent'; }} + > + {inner} + + ); +} + +/** + * SettingsLayout — presentational shell for the doctor/clinic settings area. + * Renders a right-hand settings sub-navigation menu (desktop) beside the page + * content. On mobile the menu is hidden (the standalone settings list page owns + * navigation) and the content spans full width. + * + * @param active key of the currently-open settings section (highlighted) + * @param children the section content (e.g. subscription plans) + */ +export default function SettingsLayout({ active, children }: { active: string; children: React.ReactNode }) { + const [query, setQuery] = useState(''); + const items = useMemo( + () => SETTINGS_MENU.filter((i) => i.label.includes(query.trim())), + [query], + ); + + return ( +
+
+ {/* Settings sub-nav — desktop only */} + + + {/* Section content */} +
{children}
+
+
+ ); +} diff --git a/assets/admin/pages/PaymentSuccessPage.test.tsx b/assets/admin/pages/PaymentSuccessPage.test.tsx new file mode 100644 index 00000000..4e0ca0e4 --- /dev/null +++ b/assets/admin/pages/PaymentSuccessPage.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +const navSpy = vi.fn(); +vi.mock('react-router-dom', async (orig) => ({ + ...(await orig()), + useNavigate: () => navSpy, +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import { toast } from 'sonner'; +import PaymentSuccessPage from './PaymentSuccessPage'; + +const get = api.get as ReturnType; + +beforeEach(() => { + get.mockReset(); + navSpy.mockReset(); + (toast.error as ReturnType).mockReset(); + get.mockImplementation((url: string) => { + if (url.includes('/payment/')) return Promise.resolve({ success: true, data: { + uuid: 'pay-1', order_id: 'ORD1', amount_rials: 9000000, status: 'success', + gateway: 'mellat', reference_id: '987654', created_at: 1700000000, + } }); + if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: { + subscription: { + plan: { name: 'professional', level: 2, max_secretaries: 10, features: { patient_records: true, sms_panel: true } }, + period: { label: 'یک ساله', duration_months: 12 }, + is_trial: false, expires_at: 1710000000, days_remaining: 300, + }, + used_trial: true, effective_plan: null, + } }); + return Promise.resolve({ success: true, data: null }); + }); +}); + +describe('PaymentSuccessPage', () => { + it('shows the receipt and purchased plan on success', async () => { + renderWithProviders(, { route: '/admin/subscription/success?payment_uuid=pay-1&status=success' }); + + expect(await screen.findByText('پرداخت شما با موفقیت انجام شد!')).toBeInTheDocument(); + expect(await screen.findByText('987654')).toBeInTheDocument(); // شماره تراکنش (reference_id) + expect(await screen.findByText('پلن حرفه‌ای')).toBeInTheDocument(); // purchased plan + expect(await screen.findByText('پرونده بیمار')).toBeInTheDocument(); // feature label + expect(navSpy).not.toHaveBeenCalled(); + }); + + it('redirects to the plans page with an error toast on non-success status', () => { + renderWithProviders(, { route: '/admin/subscription/success?payment_uuid=pay-1&status=failed' }); + + expect(navSpy).toHaveBeenCalledWith('/admin/subscription', { replace: true }); + expect(toast.error).toHaveBeenCalled(); + }); +}); diff --git a/assets/admin/pages/PaymentSuccessPage.tsx b/assets/admin/pages/PaymentSuccessPage.tsx new file mode 100644 index 00000000..81cd9cb6 --- /dev/null +++ b/assets/admin/pages/PaymentSuccessPage.tsx @@ -0,0 +1,163 @@ +import React, { useEffect } from 'react'; +import { useSearchParams, useNavigate, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { CheckBadgeIcon, SparklesIcon, CalendarDaysIcon, CreditCardIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { MySubscriptionData } from '../types'; +import { formatRial, formatDate } from '../lib/utils'; +import { PLAN_FEATURE_LABELS, planMetaOf } from './SubscriptionPage'; + +interface PaymentDetails { + uuid: string; + order_id: string; + amount_rials: number; + status: string; + gateway: string; + reference_id: string | null; + created_at: number; +} + +/** + * PaymentSuccessPage — landing shown when the payment gateway redirects back + * after a subscription purchase (frontend_address + ?payment_uuid&status). + * On success it shows the transaction receipt and the newly-active plan; any + * non-success status redirects back to the plans page with an error toast. + */ +export default function PaymentSuccessPage() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const paymentUuid = params.get('payment_uuid') ?? ''; + const status = params.get('status') ?? ''; + const isSuccess = status === 'success'; + + useEffect(() => { + if (!isSuccess) { + toast.error('پرداخت انجام نشد یا لغو شد'); + navigate('/admin/subscription', { replace: true }); + } + }, [isSuccess, navigate]); + + const { data: payData, isLoading: payLoading } = useQuery>({ + queryKey: ['payment', paymentUuid], + queryFn: () => api.get(`/api/v1/payment/${paymentUuid}`), + enabled: isSuccess && paymentUuid !== '', + }); + + const { data: myData } = useQuery>({ + queryKey: ['subscription-my'], + queryFn: () => api.get('/api/v1/subscription/my'), + enabled: isSuccess, + }); + + if (!isSuccess) return null; + + const payment = payData?.data ?? null; + const sub = myData?.data?.subscription ?? null; + const meta = sub ? planMetaOf(sub.plan.name) : null; + const months = sub?.period?.duration_months ?? 0; + + return ( +
+ + {/* ── Success header ── */} +
+
+ +
+

+ پرداخت شما با موفقیت انجام شد! +

+
+ + {/* ── Transaction receipt ── */} +
+ + + +
+ + {/* ── Purchased plan ── */} +

+ پلن خریداری شده شما: +

+ + {sub && meta && ( +
+
+ + پلن {meta.label} + +
+
{meta.desc}
+ +
+ {payment && months > 0 && ( + + + هزینه ماهیانه: {formatRial(Math.round(payment.amount_rials / months))} + + )} + {sub.expires_at && ( + + + تاریخ انقضا: {formatDate(sub.expires_at)} + + )} +
+ +
امکانات
+
    + {Object.entries(sub.plan.features).filter(([, v]) => v).map(([key]) => ( +
  • + + + + {PLAN_FEATURE_LABELS[key] ?? key} +
  • + ))} +
+
+ )} + + {/* ── Back ── */} +
+ + + صفحه اشتراک‌ها + +
+
+ ); +} + +function Receipt({ label, value, loading, center, left }: { label: string; value: string; loading?: boolean; center?: boolean; left?: boolean }) { + return ( +
+
{label}
+ {loading + ?
+ :
{value}
} +
+ ); +} diff --git a/assets/admin/pages/SettingsMenuPage.test.tsx b/assets/admin/pages/SettingsMenuPage.test.tsx new file mode 100644 index 00000000..688c0011 --- /dev/null +++ b/assets/admin/pages/SettingsMenuPage.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import SettingsMenuPage from './SettingsMenuPage'; +import { SETTINGS_MENU } from '../components/layout/SettingsLayout'; + +describe('SettingsMenuPage', () => { + it('lists every settings section', () => { + renderWithProviders(); + for (const item of SETTINGS_MENU) { + expect(screen.getByText(item.label)).toBeInTheDocument(); + } + }); + + it('links implemented sections and disables the rest', () => { + renderWithProviders(); + // implemented → anchor with href + expect(screen.getByText('خرید اشتراک').closest('a')).toHaveAttribute('href', '/admin/subscription'); + // not implemented → disabled button + expect(screen.getByText('مدیریت پزشک').closest('button')).toBeDisabled(); + }); +}); diff --git a/assets/admin/pages/SettingsMenuPage.tsx b/assets/admin/pages/SettingsMenuPage.tsx new file mode 100644 index 00000000..78c59f9b --- /dev/null +++ b/assets/admin/pages/SettingsMenuPage.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronLeftIcon } from '@heroicons/react/24/outline'; +import { SETTINGS_MENU } from '../components/layout/SettingsLayout'; + +/** + * SettingsMenuPage — the settings landing list for doctor/clinic users. + * A full-width tappable list of settings sections (mobile-first, matches the + * Figma mobile design). Implemented sections link to their route; the rest + * render as disabled rows labelled "به‌زودی". On desktop the same list is shown; + * each section itself renders the desktop shell via SettingsLayout. + */ +export default function SettingsMenuPage() { + return ( +
+

تنظیمات

+ +
+ {SETTINGS_MENU.map((item, idx) => { + const Icon = item.icon; + const disabled = !item.to; + const rowStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 12, + padding: '16px 18px', fontSize: 15, fontFamily: 'inherit', + borderTop: idx === 0 ? 'none' : '1px solid var(--border)', + color: disabled ? 'var(--text-3)' : 'var(--text)', + cursor: disabled ? 'not-allowed' : 'pointer', + background: 'transparent', width: '100%', textAlign: 'right', + }; + const inner = ( + <> + + {item.label} + {disabled + ? به‌زودی + : } + + ); + return disabled ? ( + + ) : ( + { (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }} + onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; }} + > + {inner} + + ); + })} +
+
+ ); +} diff --git a/assets/admin/pages/SubscriptionPage.test.tsx b/assets/admin/pages/SubscriptionPage.test.tsx new file mode 100644 index 00000000..e6a53f6c --- /dev/null +++ b/assets/admin/pages/SubscriptionPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, within } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; +import { formatRial } from '../lib/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import SubscriptionPage from './SubscriptionPage'; + +const get = api.get as ReturnType; + +const PLANS = [ + { uuid: 'p-free', name: 'free', level: 0, max_secretaries: 1, + features: { patient_records: false, services: false, sms_panel: false }, active: true, periods: [] }, + { uuid: 'p-basic', name: 'basic', level: 1, max_secretaries: 3, + features: { patient_records: true, services: true, sms_panel: false }, active: true, + periods: [ + { uuid: 'per-1m', label: 'یک ماهه', duration_months: 1, price_rials: 1000000, is_trial: false }, + { uuid: 'per-12m', label: 'یک ساله', duration_months: 12, price_rials: 9000000, is_trial: false }, + ] }, + { uuid: 'p-pro', name: 'professional', level: 2, max_secretaries: 10, + features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] }, +]; + +function mockApi(overrides: Partial> = {}) { + get.mockImplementation((url: string) => { + if (url.includes('/subscription/plans')) return Promise.resolve({ success: true, data: PLANS }); + if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: overrides.my ?? { + subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 }, + used_trial: true, effective_plan: null, + } }); + if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, appointment_fee_rials: 0, gateways: [] } }); + return Promise.resolve({ success: true, data: null }); + }); +} + +beforeEach(() => { get.mockReset(); mockApi(); }); + +describe('SubscriptionPage', () => { + it('renders the three plans with the most-popular badge on the basic plan', async () => { + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText('پلن پایه')).toBeInTheDocument(); + expect(screen.getByText('پلن رایگان')).toBeInTheDocument(); + expect(screen.getByText('پلن حرفه‌ای')).toBeInTheDocument(); + expect(screen.getByText('محبوب‌ترین')).toBeInTheDocument(); + }); + + it('shows the current-plan banner with days remaining', async () => { + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText(/پلن فعلی شما/)).toBeInTheDocument(); + expect(screen.getByText(/روز تا پایان اشتراک/)).toBeInTheDocument(); + }); + + it('the period toggle switches the displayed price', async () => { + renderWithProviders(, { route: '/admin/subscription' }); + const basicCard = (await screen.findByText('پلن پایه')).closest('div')!.parentElement!.parentElement!; + const card = within(basicCard); + // default = longest period (یک ساله) + expect(card.getByText(formatRial(9000000))).toBeInTheDocument(); + // switch to monthly + fireEvent.click(card.getByRole('tab', { name: 'یک ماهه' })); + expect(card.getByText(formatRial(1000000))).toBeInTheDocument(); + }); + + it('clicking the buy button opens the payment modal with the selected amount', async () => { + renderWithProviders(, { route: '/admin/subscription' }); + // basic is the current plan → button reads "تمدید اشتراک" + fireEvent.click(await screen.findByText('تمدید اشتراک')); + expect(await screen.findByText('پرداخت اشتراک')).toBeInTheDocument(); + expect(screen.getByText('درگاه آزمایشی فعال است')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index c315a706..78d76845 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -1,47 +1,38 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; import { - CheckIcon, XMarkIcon, SparklesIcon, RocketLaunchIcon, - ShieldCheckIcon, CreditCardIcon, ClockIcon, ArrowPathIcon, + CheckIcon, XMarkIcon, SparklesIcon, UserIcon, + CreditCardIcon, ClockIcon, ArrowPathIcon, GiftIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '../types'; import { usePaymentConfig } from '../hooks/usePaymentConfig'; -import { formatDate, formatRial, formatNumber } from '../lib/utils'; +import { formatRial, formatNumber } from '../lib/utils'; import Modal from '../components/ui/Modal'; +import SettingsLayout from '../components/layout/SettingsLayout'; // ── Constants ───────────────────────────────────────────────────────────── -const PLAN_FEATURE_LABELS: Record = { +export const PLAN_FEATURE_LABELS: Record = { patient_records: 'پرونده بیمار', services: 'مدیریت سرویس‌ها', sms_panel: 'پنل پیامک', }; -const PLAN_META: Record = { - free: { - label: 'رایگان', desc: 'برای شروع کار با سیستم', - color: 'var(--text-2)', bg: 'var(--surface-2)', border: 'var(--border)', - icon: ShieldCheckIcon, - }, - basic: { - label: 'پایه', desc: 'برای مطب‌های کوچک و متوسط', - color: 'var(--info)', bg: 'var(--info-bg)', border: 'color-mix(in oklch, var(--info) 40%, transparent)', - icon: RocketLaunchIcon, - }, - professional: { - label: 'حرفه‌ای', desc: 'برای کلینیک‌های بزرگ', - color: 'var(--violet)', bg: 'var(--violet-bg)', border: 'color-mix(in oklch, var(--violet) 40%, transparent)', - icon: SparklesIcon, - }, +/** The plan highlighted as "most popular" in the UI (no backend flag exists). */ +const POPULAR_PLAN_NAME = 'basic'; + +const PLAN_META: Record = { + free: { label: 'رایگان', desc: 'مناسب برای شروع کار', tint: 'var(--surface-2)' }, + basic: { label: 'پایه', desc: 'مناسب برای مطب‌های کوچک و متوسط', tint: 'var(--primary-soft)' }, + professional: { label: 'حرفه‌ای', desc: 'مناسب برای کلینیک‌های بزرگ', tint: 'var(--violet-bg)' }, }; +export const planMetaOf = (name: string) => PLAN_META[name] ?? PLAN_META.free; + // ── Main Page ───────────────────────────────────────────────────────────── export default function SubscriptionPage() { @@ -67,10 +58,11 @@ export default function SubscriptionPage() { } }, [gateways, selectedGateway]); - const plans = plansData?.data ?? []; - const myRaw = myData?.data; - const my = myRaw?.subscription ?? null; - const usedTrial = myRaw?.used_trial ?? false; + // Highest plan first so, under RTL, the top-tier plan sits on the right (Figma order). + const plans = [...(plansData?.data ?? [])].sort((a, b) => b.level - a.level); + const myRaw = myData?.data; + const my = myRaw?.subscription ?? null; + const usedTrial = myRaw?.used_trial ?? false; const trialMutation = useMutation({ mutationFn: () => api.post('/api/v1/subscription/trial', {}), @@ -83,9 +75,10 @@ export default function SubscriptionPage() { const purchaseMutation = useMutation({ mutationFn: ({ period_uuid, gateway, amount_rials }: { period_uuid: string; gateway: string; amount_rials: number }) => - api.post<{ data: { payment_url: string } }>('/api/v1/subscription-payment', { + api.post<{ data: { pay_url?: string; redirect_url?: string; payment_url?: string } }>('/api/v1/subscription-payment', { period_uuid, gateway, amount_rials, - frontend_address: `${window.location.origin}/admin/subscription`, + // Gateway returns here (with ?payment_uuid&status); the success page reads them. + frontend_address: `${window.location.origin}/admin/subscription/success`, }), onSuccess: (res: any) => { const url = res?.data?.pay_url ?? res?.data?.redirect_url ?? res?.data?.payment_url; @@ -95,162 +88,30 @@ export default function SubscriptionPage() { onError: (err: any) => toast.error(err.message), }); - const daysTotal = my?.expires_at && my?.starts_at ? Math.round((my.expires_at - my.starts_at) / 86400) : null; - const daysLeft = my?.days_remaining ?? 0; - const daysProgress = daysTotal ? Math.max(0, Math.min(100, ((daysTotal - daysLeft) / daysTotal) * 100)) : null; - const planMeta = my ? (PLAN_META[my.plan.name] ?? PLAN_META.free) : null; - const isExpiringSoon = daysLeft > 0 && daysLeft <= 7; - return ( -
- + {/* ── Header ── */} -
-

پنل اشتراکی

+
+

انتخاب پلن اشتراک

- پنل خود را انتخاب کنید و از امکانات بیشتر بهره‌مند شوید + پلن اشتراک مناسب خود را انتخاب نمایید:

- {/* ── وضعیت فعلی ── */} - {!myLoading && my && ( -
- {/* decorative circle */} -
- -
- {/* icon */} -
- {planMeta && } -
- - {/* info */} -
-
- - پنل {planMeta!.label} - - {my.is_trial && ( - - تریال - - )} - {isExpiringSoon && ( - - در حال انقضا - - )} -
- - {my.expires_at ? ( -
- - انقضا {formatDate(my.expires_at)} - - ({formatNumber(daysLeft)} روز مانده) - -
- ) : ( -
بدون تاریخ انقضا
- )} - - {/* progress bar */} - {daysProgress !== null && ( -
-
-
-
- - {Math.round(daysProgress)}% گذشته - -
- )} -
- - {/* trial CTA */} - {!usedTrial && ( - - )} -
-
- )} - - {/* ── بنر تریال (اگر هنوز استفاده نشده و اشتراکی ندارد) ── */} - {!myLoading && !my && !usedTrial && ( -
- -
-
- یک ماه تریال رایگان دارید! -
-
- پنل پایه را به مدت یک ماه رایگان امتحان کنید -
-
- -
- )} + {/* ── وضعیت پلن فعلی ── */} + {!myLoading && } {/* ── کارت‌های پلن ── */} {plansLoading ? ( -
+
{[1, 2, 3].map((i) => ( -
+
))}
) : ( -
+
{plans.map((plan) => ( )} - {/* ── فوتر راهنما ── */} -
- 🔒 پرداخت امن از طریق درگاه‌های معتبر - 🔄 تمدید از تاریخ انقضای قبلی محاسبه می‌شود - 📦 داده‌ها پس از انقضا حفظ می‌شوند -
- - {/* ── Modal پرداخت ── */} + {/* ── Modal پرداخت (انتخاب درگاه) ── */} { setPurchaseTarget(null); setSelectedGateway('mellat'); }} + onClose={() => { setPurchaseTarget(null); setSelectedGateway(gateways[0]?.name ?? ''); }} title="پرداخت اشتراک" size="sm" > {purchaseTarget && (
- {/* خلاصه خرید */}
دوره انتخابی
- {PLAN_META[purchaseTarget.planName]?.label ?? purchaseTarget.planName} — {purchaseTarget.period.label} + {planMetaOf(purchaseTarget.planName).label} — {purchaseTarget.period.label}
{purchaseTarget.period.duration_months} ماه @@ -311,14 +158,10 @@ export default function SubscriptionPage() {
- {/* انتخاب درگاه */} {isTestMode ? (
⚠️
@@ -326,44 +169,41 @@ export default function SubscriptionPage() {
پول واقعی کسر نخواهد شد — این تراکنش آزمایشی است
+ ) : gateways.length === 0 ? ( +
+ در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید. +
) : ( - gateways.length === 0 ? ( -
- در حال حاضر هیچ درگاه پرداخت فعالی وجود ندارد. لطفاً با پشتیبانی تماس بگیرید. +
+
+ انتخاب درگاه پرداخت
- ) : ( -
-
- انتخاب درگاه پرداخت -
-
- {gateways.map((gw) => ( - - ))} -
+
+ {gateways.map((gw) => ( + + ))}
- ) +
)} - {/* دکمه‌ها */}
)} + + ); +} + +// ── Current plan banner ───────────────────────────────────────────────────── + +function CurrentPlanBanner({ my }: { my: MySubscriptionData['subscription'] }) { + if (!my) return null; + const meta = planMetaOf(my.plan.name); + const daysLeft = my.days_remaining ?? 0; + const expiring = daysLeft > 0 && daysLeft <= 7; + + return ( +
+
+
+ پلن فعلی شما: {meta.label} + {my.is_trial && تریال} +
+ {daysLeft > 0 ? ( +
+ + {formatNumber(daysLeft)} روز تا پایان اشتراک +
+ ) : ( +
اشتراک فعالی ندارید
+ )} +
+ + مشاهده فاکتور +
); } @@ -394,14 +266,8 @@ export default function SubscriptionPage() { // ── PlanCard ────────────────────────────────────────────────────────────── function PlanCard({ - plan, - currentPlanName, - currentPlanLevel, - currentPlanActive, - usedTrial, - onPurchase, - onTrial, - trialPending, + plan, currentPlanName, currentPlanLevel, currentPlanActive, + usedTrial, onPurchase, onTrial, trialPending, }: { plan: SubscriptionPlan; currentPlanName: string; @@ -412,80 +278,109 @@ function PlanCard({ onTrial: () => void; trialPending: boolean; }) { - const meta = PLAN_META[plan.name] ?? PLAN_META.free; + const meta = planMetaOf(plan.name); const isCurrent = plan.name === currentPlanName; + const isPopular = plan.name === POPULAR_PLAN_NAME; const isDowngrade = currentPlanActive && (plan.level ?? 0) < currentPlanLevel; - const paidPeriods = plan.periods.filter((p) => !p.is_trial); + const paidPeriods = useMemo( + () => [...plan.periods].filter((p) => !p.is_trial).sort((a, b) => a.duration_months - b.duration_months), + [plan.periods], + ); const trialPeriod = plan.periods.find((p) => p.is_trial); - const Icon = meta.icon; + + // Default to the longest (best-value) period, matching the Figma default. + const [selectedUuid, setSelectedUuid] = useState(''); + useEffect(() => { + if (paidPeriods.length > 0 && !paidPeriods.some((p) => p.uuid === selectedUuid)) { + setSelectedUuid(paidPeriods[paidPeriods.length - 1].uuid); + } + }, [paidPeriods, selectedUuid]); + const selectedPeriod = paidPeriods.find((p) => p.uuid === selectedUuid) ?? paidPeriods[0]; + + const accent = isPopular ? 'var(--primary)' : 'var(--border-2)'; return (
{ (e.currentTarget as HTMLElement).style.boxShadow = 'var(--shadow)'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-3px)'; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = ''; (e.currentTarget as HTMLElement).style.transform = ''; }} - > - {/* توپ تزئینی */} -
- - {/* سربرگ کارت */} -
- {isCurrent && ( - - پنل فعلی - - )} - -
+ {/* بج محبوب‌ترین */} + {isPopular && ( + - -
+ + محبوب‌ترین + + )} -
- {meta.label} -
-
- {meta.desc} + {/* سربرگ */} +
+
+ پلن {meta.label}
+
{meta.desc}
- {plan.max_secretaries} + + {formatNumber(plan.max_secretaries)} منشی
- {/* قابلیت‌ها */} -
0 ? '1px solid var(--border)' : 'none', flex: 1 }}> -
- امکانات +
+ {/* توگل دوره */} + {paidPeriods.length > 1 && ( +
+ {paidPeriods.map((p) => { + const on = p.uuid === selectedPeriod?.uuid; + return ( + + ); + })} +
+ )} + + {/* قیمت */} +
+ {selectedPeriod ? ( +
+ قیمت: + + {formatRial(selectedPeriod.price_rials)} + +
+ ) : ( +
رایگان
+ )}
-
    + + {/* امکانات */} +
      {Object.entries(plan.features).map(([key, enabled]) => (
    • {enabled ? - : - } + : } {PLAN_FEATURE_LABELS[key] ?? key} @@ -508,91 +402,53 @@ function PlanCard({ {/* تریال */} {trialPeriod && !usedTrial && plan.level > 0 && !isDowngrade && (
      - 🎁 + - تریال {trialPeriod.duration_months} ماهه رایگان + تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان
      )}
- {/* دوره‌های پرداختی */} - {paidPeriods.length > 0 && ( -
- {isDowngrade && ( -
- 🔒 - تا پایان اشتراک فعلی قابل انتخاب نیست -
- )} - {paidPeriods.map((period) => ( -
-
-
{period.label}
-
{period.duration_months} ماه
-
-
- - {formatRial(period.price_rials)} - - -
-
- ))} -
- )} - - {/* پنل رایگان — بدون دوره */} - {paidPeriods.length === 0 && plan.level === 0 && ( -
- {isCurrent ? '✓ شما اکنون در این پنل هستید' : 'رایگان — بدون هزینه'} -
- )} + {/* دکمه خرید */} +
+ {isDowngrade ? ( + + ) : selectedPeriod ? ( + + ) : ( + + )} +
); }