diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx new file mode 100644 index 00000000..3c5243d8 --- /dev/null +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -0,0 +1,128 @@ +import React, { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuthStore } from '../../stores/authStore'; +import { menuForRole } from './SettingsLayout'; +import { SearchHeaderP } from '../../pages/subscriptionIcons'; + +/** + * PurchaseSubscriptionSidebar — pixel-faithful port of clinic-pro-tauri's + * `PurchaseSubscriptionSidebar.jsx` (title + search + tab list, orange active + * pill #f17732). Unlike the Tauri original it does not switch content inline; + * each item is a real admin route (from SETTINGS_MENU / menuForRole), so the + * clinicpro routing architecture is preserved. `active` marks the open section. + */ +export default function PurchaseSubscriptionSidebar({ active }: { active: string }) { + const primaryRole = useAuthStore((s) => s.primaryRole); + const [query, setQuery] = useState(''); + const items = useMemo( + () => menuForRole(primaryRole).filter((i) => i.label.includes(query.trim())), + [primaryRole, query], + ); + + return ( +
+ {/* Mobile/Tablet: horizontal scrollable tab strip */} +
+ {items.map((item) => { + const isActive = item.key === active; + const inner = ( + {item.label} + ); + return item.to + ? {inner} + : {inner}; + })} +
+ + {/* Desktop: Title + Search + vertical list */} +
+
+ تنظیمات +
+ +
+
+ + setQuery(e.target.value)} + placeholder="جستجو در تنظیمات" + aria-label="جستجو در تنظیمات" + style={{ + border: 'none', outline: 'none', background: 'transparent', + fontFamily: 'inherit', fontSize: 13, color: 'var(--text)', width: '100%', + textAlign: 'right', direction: 'rtl', + }} + /> +
+
+ + +
+
+ ); +} diff --git a/assets/admin/pages/SubscriptionPage.test.tsx b/assets/admin/pages/SubscriptionPage.test.tsx index e6a53f6c..1c776021 100644 --- a/assets/admin/pages/SubscriptionPage.test.tsx +++ b/assets/admin/pages/SubscriptionPage.test.tsx @@ -27,13 +27,15 @@ const PLANS = [ features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] }, ]; -function mockApi(overrides: Partial> = {}) { +const DEFAULT_MY = { + subscription: { plan: { name: 'basic', level: 1, max_secretaries: 3, features: {} }, is_trial: false, days_remaining: 25 }, + used_trial: true, effective_plan: null, +}; + +function mockApi({ my = DEFAULT_MY, plans = PLANS }: { my?: any; plans?: any[] } = {}) { 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('/subscription/plans')) return Promise.resolve({ success: true, data: plans }); + if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: my }); 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 }); }); @@ -50,16 +52,35 @@ describe('SubscriptionPage', () => { expect(screen.getByText('محبوب‌ترین')).toBeInTheDocument(); }); - it('shows the current-plan banner with days remaining', async () => { + it('shows the current-plan card without an expiry warning when the plan is healthy', async () => { renderWithProviders(, { route: '/admin/subscription' }); - expect(await screen.findByText(/پلن فعلی شما/)).toBeInTheDocument(); - expect(screen.getByText(/روز تا پایان اشتراک/)).toBeInTheDocument(); + expect(await screen.findByText('پلن فعلی شما:')).toBeInTheDocument(); + // 25 days remaining → no expiry warning (source shows it only when < 3 days) + expect(screen.queryByText(/روز تا پایان اشتراک/)).not.toBeInTheDocument(); + }); + + it('warns when the subscription is expiring within 3 days', async () => { + mockApi({ my: { ...DEFAULT_MY, subscription: { ...DEFAULT_MY.subscription, days_remaining: 2 } } }); + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText(/روز تا پایان اشتراک/)).toBeInTheDocument(); + }); + + it('shows the expired message when no days remain', async () => { + mockApi({ my: { ...DEFAULT_MY, subscription: { ...DEFAULT_MY.subscription, days_remaining: 0 } } }); + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText('اشتراک شما به پایان رسیده است')).toBeInTheDocument(); + }); + + it('shows "no subscription" when the user has none', async () => { + mockApi({ my: { subscription: null, used_trial: false, effective_plan: null } }); + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText('اشتراک ندارید')).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); + await screen.findByText('پلن پایه'); + const card = within(screen.getByTestId('plan-card-basic')); // default = longest period (یک ساله) expect(card.getByText(formatRial(9000000))).toBeInTheDocument(); // switch to monthly @@ -67,11 +88,18 @@ describe('SubscriptionPage', () => { expect(card.getByText(formatRial(1000000))).toBeInTheDocument(); }); - it('clicking the buy button opens the payment modal with the selected amount', async () => { + it('clicking the renew 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('تمدید اشتراک')); + // basic is the current plan → its card button reads "تمدید اشتراک" + const card = within(await screen.findByTestId('plan-card-basic')); + fireEvent.click(card.getByText('تمدید اشتراک')); expect(await screen.findByText('پرداخت اشتراک')).toBeInTheDocument(); expect(screen.getByText('درگاه آزمایشی فعال است')).toBeInTheDocument(); }); + + it('renders an empty state when there are no plans', async () => { + mockApi({ plans: [] }); + renderWithProviders(, { route: '/admin/subscription' }); + expect(await screen.findByText('در حال حاضر پلنی برای نمایش وجود ندارد.')).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/SubscriptionPage.tsx b/assets/admin/pages/SubscriptionPage.tsx index 78d76845..9f78b6e0 100644 --- a/assets/admin/pages/SubscriptionPage.tsx +++ b/assets/admin/pages/SubscriptionPage.tsx @@ -1,10 +1,7 @@ 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, UserIcon, - CreditCardIcon, ClockIcon, ArrowPathIcon, GiftIcon, -} from '@heroicons/react/24/outline'; +import { CreditCardIcon } from '@heroicons/react/24/outline'; +import { CheckCircleIcon } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; @@ -12,16 +9,27 @@ import type { SubscriptionPlan, MySubscriptionData, SubscriptionPeriod } from '. import { usePaymentConfig } from '../hooks/usePaymentConfig'; import { formatRial, formatNumber } from '../lib/utils'; import Modal from '../components/ui/Modal'; -import SettingsLayout from '../components/layout/SettingsLayout'; +import PurchaseSubscriptionSidebar from '../components/layout/PurchaseSubscriptionSidebar'; +import { + SparkleIcon, PlanCardPerson, CloseCircleFilled, GiftIcon, InfoCircleRedFilled, +} from './subscriptionIcons'; // ── Constants ───────────────────────────────────────────────────────────── +/** Shared feature labels (also consumed by PaymentSuccessPage). */ export const PLAN_FEATURE_LABELS: Record = { patient_records: 'پرونده بیمار', services: 'مدیریت سرویس‌ها', sms_panel: 'پنل پیامک', }; +/** Feature labels used on the plan cards — wording copied from clinic-pro-tauri. */ +const CARD_FEATURE_LABELS: Record = { + patient_records: 'مدیریت پرونده بیمار', + services: 'مدیریت سرویس ها', + sms_panel: 'پنل پیامک', +}; + /** The plan highlighted as "most popular" in the UI (no backend flag exists). */ const POPULAR_PLAN_NAME = 'basic'; @@ -31,6 +39,13 @@ const PLAN_META: Record = professional: { label: 'حرفه‌ای', desc: 'مناسب برای کلینیک‌های بزرگ', tint: 'var(--violet-bg)' }, }; +/** Gradient blur behind each plan card — matches the tauri source per plan tier. */ +const PLAN_GRADIENT: Record = { + free: 'radial-gradient(circle, #f5ecfd 0%, #e9eaf9 70%, transparent 100%)', + basic: 'radial-gradient(circle, #e2eee5 0%, #f5edfd 60%, transparent 100%)', + professional: 'linear-gradient(180deg, #f9eedd 0%, #e8e9fa 0%, rgba(255,255,255,0) 100%)', +}; + export const planMetaOf = (name: string) => PLAN_META[name] ?? PLAN_META.free; // ── Main Page ───────────────────────────────────────────────────────────── @@ -58,7 +73,7 @@ export default function SubscriptionPage() { } }, [gateways, selectedGateway]); - // Highest plan first so, under RTL, the top-tier plan sits on the right (Figma order). + // Highest plan first so, under RTL, the top-tier plan sits on the right (source order). const plans = [...(plansData?.data ?? [])].sort((a, b) => b.level - a.level); const myRaw = myData?.data; const my = myRaw?.subscription ?? null; @@ -88,47 +103,69 @@ export default function SubscriptionPage() { onError: (err: any) => toast.error(err.message), }); + // Open the payment modal for the current plan's best-value paid period (renew). + const renewCurrentPlan = () => { + const current = plans.find((p) => p.name === my?.plan.name); + const paid = (current?.periods ?? []).filter((p) => !p.is_trial) + .sort((a, b) => a.duration_months - b.duration_months); + const period = paid[paid.length - 1]; + if (period) setPurchaseTarget({ period, planName: current!.name }); + }; + return ( - - {/* ── Header ── */} -
-

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

-

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

+
+
+ + +
+
+ {/* ── Header: current plan (left) + title (right) ── */} +
+ {!myLoading && } +
+
انتخاب پلن اشتراک
+
+ پلن اشتراک مناسب خود را انتخاب نمایید: +
+
+
+ + {/* ── Plan cards ── */} + {plansLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : plans.length === 0 ? ( +
+ در حال حاضر پلنی برای نمایش وجود ندارد. +
+ ) : ( +
+ {plans.map((plan) => ( + 0} + usedTrial={usedTrial} + onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })} + onTrial={() => trialMutation.mutate()} + trialPending={trialMutation.isPending} + /> + ))} +
+ )} +
+
- {/* ── وضعیت پلن فعلی ── */} - {!myLoading && } - - {/* ── کارت‌های پلن ── */} - {plansLoading ? ( -
- {[1, 2, 3].map((i) => ( -
-
-
- ))} -
- ) : ( -
- {plans.map((plan) => ( - 0} - usedTrial={usedTrial} - onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })} - onTrial={() => trialMutation.mutate()} - trialPending={trialMutation.isPending} - /> - ))} -
- )} - - {/* ── Modal پرداخت (انتخاب درگاه) ── */} + {/* ── Payment modal (gateway selection) ── */} { setPurchaseTarget(null); setSelectedGateway(gateways[0]?.name ?? ''); }} @@ -147,7 +184,7 @@ export default function SubscriptionPage() { {planMetaOf(purchaseTarget.planName).label} — {purchaseTarget.period.label}
- {purchaseTarget.period.duration_months} ماه + {formatNumber(purchaseTarget.period.duration_months)} ماه
@@ -224,46 +261,66 @@ export default function SubscriptionPage() {
)} - - ); -} - -// ── 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)} روز تا پایان اشتراک -
- ) : ( -
اشتراک فعالی ندارید
- )} -
- - مشاهده فاکتور -
); } -// ── PlanCard ────────────────────────────────────────────────────────────── +// ── Current plan card (365×104, ported from tauri CurrentPlanCard.jsx) ─────── + +function CurrentPlanCard({ my, onRenew }: { my: MySubscriptionData['subscription']; onRenew: () => void }) { + const daysLeft = my?.days_remaining ?? 0; + return ( +
+ {my ? ( + <> +
+ پلن فعلی شما: + {planMetaOf(my.plan.name).label} +
+ {daysLeft < 3 && ( +
+
+ + + {daysLeft <= 0 ? 'اشتراک شما به پایان رسیده است' : `${formatNumber(daysLeft)} روز تا پایان اشتراک`} + +
+ +
+ )} + + ) : ( + <> +
+ پلن فعلی شما: + اشتراک ندارید +
+
+ + برای استفاده از امکانات یک پلن تهیه کنید + +
+ + )} +
+ ); +} + +// ── PlanCard (522px, ported from tauri PlanCard.jsx) ───────────────────────── function PlanCard({ plan, currentPlanName, currentPlanLevel, currentPlanActive, @@ -281,14 +338,16 @@ function PlanCard({ const meta = planMetaOf(plan.name); const isCurrent = plan.name === currentPlanName; const isPopular = plan.name === POPULAR_PLAN_NAME; + const isFree = plan.level <= 0; const isDowngrade = currentPlanActive && (plan.level ?? 0) < currentPlanLevel; + 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); - // Default to the longest (best-value) period, matching the Figma default. + // Default to the longest (best-value) period, matching the source default. const [selectedUuid, setSelectedUuid] = useState(''); useEffect(() => { if (paidPeriods.length > 0 && !paidPeriods.some((p) => p.uuid === selectedUuid)) { @@ -297,158 +356,182 @@ function PlanCard({ }, [paidPeriods, selectedUuid]); const selectedPeriod = paidPeriods.find((p) => p.uuid === selectedUuid) ?? paidPeriods[0]; - const accent = isPopular ? 'var(--primary)' : 'var(--border-2)'; + const isSelected = isPopular; // the source highlights the popular card with the indigo border return ( -
- {/* بج محبوب‌ترین */} - {isPopular && ( - - - محبوب‌ترین - - )} +
+ {/* Gradient blur backdrop */} +
- {/* سربرگ */} -
-
- پلن {meta.label} -
-
{meta.desc}
-
- - {formatNumber(plan.max_secretaries)} - منشی -
-
- -
- {/* توگل دوره */} - {paidPeriods.length > 1 && ( -
+ {/* Popular badge (top-right corner) */} + {isPopular && ( +
- {paidPeriods.map((p) => { - const on = p.uuid === selectedPeriod?.uuid; - return ( - - ); - })} + محبوب‌ترین +
)} - {/* قیمت */} -
- {selectedPeriod ? ( -
- قیمت: - - {formatRial(selectedPeriod.price_rials)} +
+
+ پلن {meta.label} +
+
+ {meta.desc} +
+ + {/* Secretaries pill */} +
+
+
+ منشی + {formatNumber(plan.max_secretaries)} +
+ +
+
+ + {/* Period toggle */} +
+ {paidPeriods.length > 0 && ( +
+ {paidPeriods.map((p, idx) => { + const on = p.uuid === selectedPeriod?.uuid; + return ( + + + {idx < paidPeriods.length - 1 && ( +
+ )} + + ); + })} +
+ )} +
+ + {/* Price */} +
+ {!isFree && selectedPeriod && ( +
+ + {formatRial(selectedPeriod.price_rials)} + + : قیمت +
+ )} +
+
+
+ +
+ + {/* Features */} +
+ {Object.entries(plan.features).map(([key, enabled]) => { + const label = CARD_FEATURE_LABELS[key] ?? PLAN_FEATURE_LABELS[key] ?? key; + return ( +
+ + {label} + + + {enabled + ? + : }
- ) : ( -
رایگان
- )} -
+ ); + })} +
- {/* امکانات */} -
    - {Object.entries(plan.features).map(([key, enabled]) => ( -
  • - - {enabled - ? - : } - - - {PLAN_FEATURE_LABELS[key] ?? key} - -
  • - ))} -
- - {/* تریال */} - {trialPeriod && !usedTrial && plan.level > 0 && !isDowngrade && ( + {/* Trial box (dashed) */} + {!isFree && trialPeriod && !usedTrial && !isDowngrade && ( +
- - - تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان - +
+ + تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان + + +
- )} -
+ + + +
+ )} - {/* دکمه خرید */} -
- {isDowngrade ? ( - - ) : selectedPeriod ? ( - - ) : ( - - )} -
+
+ + {/* Buy button */} + {isFree ? ( + + ) : isDowngrade ? ( + + ) : ( + + )}
); } diff --git a/assets/admin/pages/subscriptionIcons.tsx b/assets/admin/pages/subscriptionIcons.tsx new file mode 100644 index 00000000..3356d159 --- /dev/null +++ b/assets/admin/pages/subscriptionIcons.tsx @@ -0,0 +1,75 @@ +// Subscription-page icons — SVG markup copied verbatim from the clinic-pro-tauri +// source (`src/assets/icon/*`) so the ported page matches it pixel-for-pixel. +// The rest of the admin uses Heroicons; these bespoke glyphs exist only here. +import React from 'react'; + +type IconProps = { size?: number; color?: string }; + +export function SparkleIcon({ size = 15, color = '#FDD835' }: IconProps) { + return ( + + + + ); +} + +export function PlanCardPerson({ size = 20, color = '#2F2F2F' }: IconProps) { + return ( + + + + + ); +} + +export function CloseCircleFilled({ size = 20, color = '#D7D7D7' }: IconProps) { + return ( + + + + ); +} + +export function GiftIcon({ size = 24, color = '#F17732' }: IconProps) { + return ( + + + + + + + + ); +} + +export function InfoCircleRedFilled({ size = 20, color = '#D32F2F' }: IconProps) { + return ( + + + + ); +} + +export function SearchHeaderP({ color = '#7E7E7E' }: { color?: string }) { + return ( + + + + + ); +}