feat: port purchase-subscription page from tauri to admin dashboard
Rebuild /admin/subscription to pixel-match clinic-pro-tauri's setting/purchase-subscription while keeping the real subscription/payment API: - New PurchaseSubscriptionSidebar (orange-accent settings sidebar, routes preserved) - New subscriptionIcons (SVGs copied verbatim from tauri source) - Rewrite SubscriptionPage: 522px plan cards (gradient blur, popular badge, secretaries pill, period toggle, dashed trial box), 365x104 current-plan card; wired to /subscription/plans, /subscription/my, /subscription/trial and the existing payment-gateway modal (no backend change) - Expand tests: healthy/expiring/expired/no-sub, empty-plans, period toggle, buy modal Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
dir="ltr"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
alignSelf: 'start',
|
||||
position: 'sticky',
|
||||
top: 'calc(var(--topbar-h) + 16px)',
|
||||
}}
|
||||
className="w-full md:w-[240px] lg:w-[280px] md:py-4"
|
||||
>
|
||||
{/* Mobile/Tablet: horizontal scrollable tab strip */}
|
||||
<div
|
||||
className="flex md:hidden"
|
||||
style={{
|
||||
flexDirection: 'row-reverse', overflowX: 'auto', gap: 8,
|
||||
padding: '12px 8px', whiteSpace: 'nowrap', scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
const inner = (
|
||||
<span style={{
|
||||
flexShrink: 0, borderRadius: 12, padding: '8px 16px', minWidth: 'auto',
|
||||
display: 'inline-block', fontSize: 14, fontWeight: isActive ? 700 : 500,
|
||||
background: isActive ? '#f17732' : 'var(--surface-2)',
|
||||
color: isActive ? '#FFFFFF' : 'var(--text-2)', cursor: item.to ? 'pointer' : 'not-allowed',
|
||||
}}>{item.label}</span>
|
||||
);
|
||||
return item.to
|
||||
? <Link key={item.key} to={item.to}>{inner}</Link>
|
||||
: <span key={item.key}>{inner}</span>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Title + Search + vertical list */}
|
||||
<div className="hidden md:block">
|
||||
<div style={{
|
||||
fontSize: 16, fontWeight: 800, color: 'var(--text-2)', textAlign: 'left',
|
||||
marginBottom: 10, padding: '0 16px',
|
||||
}}>
|
||||
تنظیمات
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px', marginBottom: 16 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', flexDirection: 'row-reverse', gap: 8,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)', padding: '8px 12px',
|
||||
}}>
|
||||
<SearchHeaderP />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav style={{ padding: '0 16px' }}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
const rowStyle: React.CSSProperties = {
|
||||
borderRadius: 12, height: 44, marginBottom: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||
padding: '0 14px', fontSize: 16, fontWeight: isActive ? 700 : 500,
|
||||
lineHeight: 1, textAlign: 'left',
|
||||
background: isActive ? '#f17732' : 'transparent',
|
||||
color: isActive ? '#FFFFFF' : 'var(--text-2)',
|
||||
cursor: item.to ? 'pointer' : 'not-allowed',
|
||||
transition: 'background .14s',
|
||||
};
|
||||
const inner = <span style={{ flex: 1 }}>{item.label}</span>;
|
||||
if (!item.to) {
|
||||
return <div key={item.key} style={{ ...rowStyle, opacity: 0.55 }}>{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
style={rowStyle}
|
||||
onMouseEnter={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<div style={{ padding: '12px 4px', fontSize: 13, color: 'var(--text-3)', textAlign: 'center' }}>
|
||||
موردی یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,13 +27,15 @@ const PLANS = [
|
||||
features: { patient_records: true, services: true, sms_panel: true }, active: true, periods: [] },
|
||||
];
|
||||
|
||||
function mockApi(overrides: Partial<Record<string, any>> = {}) {
|
||||
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(<SubscriptionPage />, { 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(<SubscriptionPage />, { 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(<SubscriptionPage />, { 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(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
expect(await screen.findByText('اشتراک ندارید')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the period toggle switches the displayed price', async () => {
|
||||
renderWithProviders(<SubscriptionPage />, { 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(<SubscriptionPage />, { 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(<SubscriptionPage />, { route: '/admin/subscription' });
|
||||
expect(await screen.findByText('در حال حاضر پلنی برای نمایش وجود ندارد.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
patient_records: 'پرونده بیمار',
|
||||
services: 'مدیریت سرویسها',
|
||||
sms_panel: 'پنل پیامک',
|
||||
};
|
||||
|
||||
/** Feature labels used on the plan cards — wording copied from clinic-pro-tauri. */
|
||||
const CARD_FEATURE_LABELS: Record<string, string> = {
|
||||
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<string, { label: string; desc: string; tint: string }> =
|
||||
professional: { label: 'حرفهای', desc: 'مناسب برای کلینیکهای بزرگ', tint: 'var(--violet-bg)' },
|
||||
};
|
||||
|
||||
/** Gradient blur behind each plan card — matches the tauri source per plan tier. */
|
||||
const PLAN_GRADIENT: Record<string, string> = {
|
||||
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 (
|
||||
<SettingsLayout active="subscription">
|
||||
{/* ── Header ── */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h1 className="section-title" style={{ marginBottom: 4 }}>انتخاب پلن اشتراک</h1>
|
||||
<p style={{ color: 'var(--text-3)', fontSize: 14, margin: 0 }}>
|
||||
پلن اشتراک مناسب خود را انتخاب نمایید:
|
||||
</p>
|
||||
<div className="fade-in" dir="rtl" style={{ maxWidth: 1180, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'stretch', gap: 0 }} className="flex-col md:flex-row">
|
||||
<PurchaseSubscriptionSidebar active="subscription" />
|
||||
|
||||
<div
|
||||
style={{ flex: 1, background: 'var(--surface)', minWidth: 0 }}
|
||||
className="px-4 py-4 md:px-8 md:py-6 md:mr-2 rounded-[var(--r-lg)]"
|
||||
>
|
||||
<div dir="ltr" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 32, paddingTop: 8 }}>
|
||||
{/* ── Header: current plan (left) + title (right) ── */}
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
{!myLoading && <CurrentPlanCard my={my} onRenew={renewCurrentPlan} />}
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--text)' }}>انتخاب پلن اشتراک</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-3)', marginTop: 4, textAlign: 'left' }}>
|
||||
پلن اشتراک مناسب خود را انتخاب نمایید:
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards ── */}
|
||||
{plansLoading ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="skeleton" style={{ width: 300, height: 522, borderRadius: 10 }} />
|
||||
))}
|
||||
</div>
|
||||
) : plans.length === 0 ? (
|
||||
<div style={{ padding: '48px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
|
||||
در حال حاضر پلنی برای نمایش وجود ندارد.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 16, justifyContent: 'flex-end', width: '100%' }}>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.uuid}
|
||||
plan={plan}
|
||||
currentPlanName={my?.plan.name ?? 'free'}
|
||||
currentPlanLevel={my?.plan.level ?? 0}
|
||||
currentPlanActive={!!my && (my.days_remaining ?? 0) > 0}
|
||||
usedTrial={usedTrial}
|
||||
onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })}
|
||||
onTrial={() => trialMutation.mutate()}
|
||||
trialPending={trialMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── وضعیت پلن فعلی ── */}
|
||||
{!myLoading && <CurrentPlanBanner my={my} />}
|
||||
|
||||
{/* ── کارتهای پلن ── */}
|
||||
{plansLoading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card" style={{ height: 420 }}>
|
||||
<div className="skeleton" style={{ height: '100%', borderRadius: 'var(--r)' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.uuid}
|
||||
plan={plan}
|
||||
currentPlanName={my?.plan.name ?? 'free'}
|
||||
currentPlanLevel={my?.plan.level ?? 0}
|
||||
currentPlanActive={!!my && (my.days_remaining ?? 0) > 0}
|
||||
usedTrial={usedTrial}
|
||||
onPurchase={(period) => setPurchaseTarget({ period, planName: plan.name })}
|
||||
onTrial={() => trialMutation.mutate()}
|
||||
trialPending={trialMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Modal پرداخت (انتخاب درگاه) ── */}
|
||||
{/* ── Payment modal (gateway selection) ── */}
|
||||
<Modal
|
||||
open={!!purchaseTarget}
|
||||
onClose={() => { setPurchaseTarget(null); setSelectedGateway(gateways[0]?.name ?? ''); }}
|
||||
@@ -147,7 +184,7 @@ export default function SubscriptionPage() {
|
||||
{planMetaOf(purchaseTarget.planName).label} — {purchaseTarget.period.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 1 }}>
|
||||
{purchaseTarget.period.duration_months} ماه
|
||||
{formatNumber(purchaseTarget.period.duration_months)} ماه
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
@@ -224,46 +261,66 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-lg)', padding: '14px 18px', marginBottom: 20,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)', marginBottom: 2 }}>
|
||||
پلن فعلی شما: <b style={{ color: 'var(--text)' }}>{meta.label}</b>
|
||||
{my.is_trial && <span className="badge amber" style={{ marginRight: 8, fontSize: 11 }}><span className="bdot" />تریال</span>}
|
||||
</div>
|
||||
{daysLeft > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: expiring ? 'var(--danger)' : 'var(--success)', fontWeight: 600 }}>
|
||||
<ClockIcon style={{ width: 14, flexShrink: 0 }} />
|
||||
{formatNumber(daysLeft)} روز تا پایان اشتراک
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-3)' }}>اشتراک فعالی ندارید</div>
|
||||
)}
|
||||
</div>
|
||||
<Link to="/admin/my-financial" className="btn ghost sm" style={{ flexShrink: 0 }}>
|
||||
مشاهده فاکتور
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
dir="ltr"
|
||||
style={{
|
||||
width: 365, maxWidth: '100%', minHeight: 104,
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
borderRadius: 16, display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
padding: '0 16px',
|
||||
}}
|
||||
>
|
||||
{my ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>پلن فعلی شما:</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{planMetaOf(my.plan.name).label}</span>
|
||||
</div>
|
||||
{daysLeft < 3 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<InfoCircleRedFilled color="#d22d32" />
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
|
||||
{daysLeft <= 0 ? 'اشتراک شما به پایان رسیده است' : `${formatNumber(daysLeft)} روز تا پایان اشتراک`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRenew}
|
||||
className="btn ghost sm"
|
||||
style={{ fontWeight: 600, color: 'var(--primary)', minWidth: 0, padding: '4px 12px' }}
|
||||
>
|
||||
تمدید اشتراک
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>پلن فعلی شما:</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>اشتراک ندارید</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-3)' }}>
|
||||
برای استفاده از امکانات یک پلن تهیه کنید
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string>('');
|
||||
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 (
|
||||
<div style={{
|
||||
background: 'var(--surface)',
|
||||
border: `${isPopular || isCurrent ? '2px' : '1.5px'} solid ${isPopular ? 'var(--primary)' : isCurrent ? 'var(--primary)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--r-lg)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
{/* بج محبوبترین */}
|
||||
{isPopular && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 14, left: 14, zIndex: 1,
|
||||
background: 'var(--primary)', color: '#fff',
|
||||
fontSize: 11, fontWeight: 700, padding: '4px 10px',
|
||||
borderRadius: 999, display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
<SparklesIcon style={{ width: 13, height: 13 }} />
|
||||
محبوبترین
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
data-testid={`plan-card-${plan.name}`}
|
||||
style={{
|
||||
width: 300, maxWidth: '100%', height: 522, borderRadius: 10,
|
||||
border: isSelected ? '2px solid #494cb3' : '2px solid var(--border)',
|
||||
background: 'var(--surface)', display: 'flex', flexDirection: 'column',
|
||||
padding: 16, position: 'relative', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Gradient blur backdrop */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -80, left: 0, width: 550, height: 160,
|
||||
background: PLAN_GRADIENT[plan.name] ?? PLAN_GRADIENT.free,
|
||||
opacity: 0.8, filter: 'blur(80px)', zIndex: 0, pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* سربرگ */}
|
||||
<div style={{ padding: '20px 20px 16px', background: meta.tint }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 20, color: 'var(--text)', marginBottom: 4 }}>
|
||||
پلن {meta.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 12 }}>{meta.desc}</div>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 999, padding: '4px 12px', fontSize: 12.5,
|
||||
}}>
|
||||
<UserIcon style={{ width: 14, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontWeight: 700, color: 'var(--text)' }}>{formatNumber(plan.max_secretaries)}</span>
|
||||
<span style={{ color: 'var(--text-3)' }}>منشی</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14, flex: 1 }}>
|
||||
{/* توگل دوره */}
|
||||
{paidPeriods.length > 1 && (
|
||||
<div role="tablist" aria-label="دوره اشتراک" style={{
|
||||
display: 'grid', gridTemplateColumns: `repeat(${paidPeriods.length}, 1fr)`, gap: 4,
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)', padding: 4,
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
{/* Popular badge (top-right corner) */}
|
||||
{isPopular && (
|
||||
<div style={{
|
||||
position: 'absolute', width: 'fit-content', height: 32, top: -18, right: -20,
|
||||
background: '#3d4395', color: '#fff', padding: '0 16px', borderRadius: '0 10px 0 10px',
|
||||
fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, zIndex: 1,
|
||||
}}>
|
||||
{paidPeriods.map((p) => {
|
||||
const on = p.uuid === selectedPeriod?.uuid;
|
||||
return (
|
||||
<button
|
||||
key={p.uuid} role="tab" aria-selected={on}
|
||||
onClick={() => setSelectedUuid(p.uuid)}
|
||||
style={{
|
||||
padding: '7px 6px', borderRadius: 'var(--r-xs)', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 12.5, fontWeight: on ? 700 : 500, transition: '.14s',
|
||||
background: on ? 'var(--primary)' : 'transparent',
|
||||
color: on ? '#fff' : 'var(--text-2)',
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
محبوبترین
|
||||
<SparkleIcon size={15} color="#FDD835" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* قیمت */}
|
||||
<div>
|
||||
{selectedPeriod ? (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>قیمت:</span>
|
||||
<span style={{ fontWeight: 800, fontSize: 20, color: 'var(--text)' }}>
|
||||
{formatRial(selectedPeriod.price_rials)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: 'var(--text)', textAlign: 'left' }}>
|
||||
پلن {meta.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-2)', fontWeight: 500, textAlign: 'left', lineHeight: 1.6 }}>
|
||||
{meta.desc}
|
||||
</div>
|
||||
|
||||
{/* Secretaries pill */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{
|
||||
minWidth: 86, height: 32, borderRadius: 29, background: 'var(--surface-3)',
|
||||
color: 'var(--text)', display: 'flex', gap: 5, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 14 }}>منشی</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{formatNumber(plan.max_secretaries)}</span>
|
||||
</div>
|
||||
<PlanCardPerson size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', minHeight: 48 }}>
|
||||
{paidPeriods.length > 0 && (
|
||||
<div role="tablist" aria-label="دوره اشتراک" style={{
|
||||
width: 263, maxWidth: '100%', height: 48, border: '1px solid var(--border)',
|
||||
borderRadius: 8, background: 'var(--surface)', display: 'flex', alignItems: 'center', overflow: 'hidden',
|
||||
}}>
|
||||
{paidPeriods.map((p, idx) => {
|
||||
const on = p.uuid === selectedPeriod?.uuid;
|
||||
return (
|
||||
<React.Fragment key={p.uuid}>
|
||||
<button
|
||||
role="tab" aria-selected={on}
|
||||
onClick={() => setSelectedUuid(p.uuid)}
|
||||
style={{
|
||||
flex: 1, height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: on ? 'var(--primary-soft)' : 'transparent', cursor: 'pointer',
|
||||
border: 'none', fontFamily: 'inherit',
|
||||
fontSize: 14, fontWeight: 500, color: on ? 'var(--primary)' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
{idx < paidPeriods.length - 1 && (
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border)' }} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div style={{ minHeight: 22 }}>
|
||||
{!isFree && selectedPeriod && (
|
||||
<div style={{
|
||||
marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4,
|
||||
justifyContent: 'flex-end', width: '100%',
|
||||
}}>
|
||||
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>
|
||||
{formatRial(selectedPeriod.price_rials)}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, color: 'var(--text)', fontWeight: 500 }}>: قیمت</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)' }} />
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{Object.entries(plan.features).map(([key, enabled]) => {
|
||||
const label = CARD_FEATURE_LABELS[key] ?? PLAN_FEATURE_LABELS[key] ?? key;
|
||||
return (
|
||||
<div key={key} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', padding: '9.6px 0', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, color: enabled ? 'var(--text)' : 'var(--text-3)', fontWeight: 500, textAlign: 'left' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ position: 'relative', display: 'inline-flex', width: 22, height: 22 }}>
|
||||
{enabled
|
||||
? <CheckCircleIcon style={{ width: 22, height: 22, color: '#3c9a4f' }} />
|
||||
: <CloseCircleFilled size={22} color="#d7d7d7" />}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontWeight: 800, fontSize: 18, color: 'var(--success)' }}>رایگان</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* امکانات */}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{Object.entries(plan.features).map(([key, enabled]) => (
|
||||
<li key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13.5 }}>
|
||||
<span style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||
display: 'grid', placeItems: 'center',
|
||||
background: enabled ? 'var(--success-bg)' : 'var(--surface-3)',
|
||||
}}>
|
||||
{enabled
|
||||
? <CheckIcon style={{ width: 12, color: 'var(--success)' }} />
|
||||
: <XMarkIcon style={{ width: 11, color: 'var(--text-3)' }} />}
|
||||
</span>
|
||||
<span style={{ color: enabled ? 'var(--text)' : 'var(--text-3)' }}>
|
||||
{PLAN_FEATURE_LABELS[key] ?? key}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* تریال */}
|
||||
{trialPeriod && !usedTrial && plan.level > 0 && !isDowngrade && (
|
||||
{/* Trial box (dashed) */}
|
||||
{!isFree && trialPeriod && !usedTrial && !isDowngrade && (
|
||||
<div style={{ position: 'relative', width: 268, maxWidth: '100%', height: 59, margin: '0 auto 16px' }}>
|
||||
<div style={{
|
||||
padding: '10px 12px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--accent-bg)',
|
||||
border: '1px dashed color-mix(in oklch, var(--accent) 45%, transparent)',
|
||||
fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8,
|
||||
position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
height: '100%', padding: '0 16px',
|
||||
}}>
|
||||
<GiftIcon style={{ width: 16, color: 'var(--accent)', flexShrink: 0 }} />
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
تریال <b>{formatNumber(trialPeriod.duration_months)} ماهه</b> رایگان
|
||||
</span>
|
||||
<button
|
||||
onClick={onTrial} disabled={trialPending}
|
||||
style={{
|
||||
marginRight: 'auto', background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--accent)', fontWeight: 700, fontSize: 12.5, fontFamily: 'inherit',
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', fontSize: 16, fontWeight: 500, color: '#5559ce' }}
|
||||
>
|
||||
{trialPending
|
||||
? <ArrowPathIcon style={{ width: 14, animation: 'spin 1s linear infinite' }} />
|
||||
: 'فعال سازی'}
|
||||
{trialPending ? 'در حال فعالسازی...' : 'فعال سازی'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-2)' }}>
|
||||
تریال {formatNumber(trialPeriod.duration_months)} ماهه رایگان
|
||||
</span>
|
||||
<GiftIcon />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none' }} viewBox="0 0 268 59" preserveAspectRatio="none">
|
||||
<rect x="0.5" y="0.5" width="267" height="58" rx="6" fill="transparent" stroke="#636ed0" strokeWidth="1" strokeDasharray="6 4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* دکمه خرید */}
|
||||
<div style={{ padding: '0 20px 20px' }}>
|
||||
{isDowngrade ? (
|
||||
<button className="btn" style={{ width: '100%', height: 44, opacity: 0.6, cursor: 'not-allowed' }} disabled>
|
||||
تا پایان اشتراک فعلی قابل انتخاب نیست
|
||||
</button>
|
||||
) : selectedPeriod ? (
|
||||
<button
|
||||
className={isPopular ? 'btn primary' : 'btn'}
|
||||
style={{
|
||||
width: '100%', height: 44, fontWeight: 700,
|
||||
...(isPopular ? {} : { border: '1.5px solid var(--primary)', color: 'var(--primary)', background: 'var(--surface)' }),
|
||||
}}
|
||||
onClick={() => onPurchase(selectedPeriod)}
|
||||
>
|
||||
{isCurrent ? 'تمدید اشتراک' : 'خرید اشتراک'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn" style={{ width: '100%', height: 44, cursor: 'default' }} disabled>
|
||||
{isCurrent ? 'پلن فعلی شما' : 'رایگان'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid var(--border)', margin: '0 8px 16px' }} />
|
||||
|
||||
{/* Buy button */}
|
||||
{isFree ? (
|
||||
<button className="btn" style={{ marginTop: 'auto', width: '100%', height: 48, cursor: 'default' }} disabled>
|
||||
{isCurrent ? 'پلن فعلی شما' : 'رایگان'}
|
||||
</button>
|
||||
) : isDowngrade ? (
|
||||
<button className="btn" style={{ marginTop: 'auto', width: '100%', height: 48, opacity: 0.6, cursor: 'not-allowed' }} disabled>
|
||||
تا پایان اشتراک فعلی قابل انتخاب نیست
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => selectedPeriod && onPurchase(selectedPeriod)}
|
||||
disabled={!selectedPeriod}
|
||||
style={{
|
||||
marginTop: 'auto', width: '100%', height: 48, borderRadius: 4, border: 'none',
|
||||
background: 'var(--primary)', color: '#fff', fontWeight: 700, fontSize: 16,
|
||||
fontFamily: 'inherit', cursor: selectedPeriod ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{isCurrent ? 'تمدید اشتراک' : 'خرید اشتراک'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 15 15" fill="none">
|
||||
<path
|
||||
d="M13.4059 6.94375L11.5309 6.00625L9.85586 5.16875L9.83711 5.15L8.99961 3.475L8.06211 1.6C7.84961 1.175 7.15586 1.175 6.94336 1.6L6.00586 3.475L5.16836 5.15L5.14961 5.16875L3.47461 6.00625L1.59961 6.94375C1.38711 7.05 1.25586 7.26875 1.25586 7.5C1.25586 7.73125 1.38711 7.95 1.59961 8.05625L3.47461 8.99375L5.14961 9.83125L5.16836 9.85L6.00586 11.525L6.94336 13.4C7.04961 13.6125 7.26836 13.7437 7.49961 13.7437C7.73086 13.7437 7.94961 13.6125 8.05586 13.4L8.99336 11.525L9.83086 9.85L9.84961 9.83125L11.5246 8.99375L13.3996 8.05625C13.6121 7.95 13.7434 7.73125 13.7434 7.5C13.7434 7.26875 13.6121 7.05 13.3996 6.94375H13.4059ZM12.1871 0.9375L11.5996 2.225L10.3121 2.8125L11.5996 3.4L12.1871 4.6875L12.7746 3.4L14.0621 2.8125L12.7746 2.225L12.1871 0.9375Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanCardPerson({ size = 20, color = '#2F2F2F' }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M9 9C11.0711 9 12.75 7.32107 12.75 5.25C12.75 3.17893 11.0711 1.5 9 1.5C6.92893 1.5 5.25 3.17893 5.25 5.25C5.25 7.32107 6.92893 9 9 9Z"
|
||||
stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.4416 16.5C15.4416 13.5975 12.5541 11.25 8.99914 11.25C5.44414 11.25 2.55664 13.5975 2.55664 16.5"
|
||||
stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseCircleFilled({ size = 20, color = '#D7D7D7' }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M9.99902 2.16699C14.3144 2.16699 17.8328 5.68462 17.833 10C17.833 14.3155 14.3145 17.834 9.99902 17.834C5.68365 17.8338 2.16602 14.3154 2.16602 10C2.16619 5.68473 5.68376 2.16717 9.99902 2.16699ZM13.1533 6.84668C12.7164 6.40977 11.9994 6.40982 11.5625 6.84668L9.99902 8.40918L8.43652 6.84668C7.99959 6.40975 7.28263 6.40975 6.8457 6.84668C6.40877 7.28361 6.40877 8.00057 6.8457 8.4375L8.4082 10L6.8457 11.5635C6.40884 12.0004 6.4088 12.7174 6.8457 13.1543C7.06929 13.3778 7.35703 13.4833 7.64062 13.4834C7.92438 13.4834 8.21282 13.378 8.43652 13.1543L9.99902 11.5908L11.5625 13.1543C11.7861 13.3778 12.0738 13.4833 12.3574 13.4834C12.6412 13.4834 12.9296 13.378 13.1533 13.1543C13.5902 12.7174 13.59 12.0004 13.1533 11.5635L11.5898 10L13.1533 8.4375C13.5901 8.00062 13.5901 7.2836 13.1533 6.84668Z"
|
||||
fill={color} stroke={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GiftIcon({ size = 24, color = '#F17732' }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 12V18C20 20.21 18.21 22 16 22H8C5.79 22 4 20.21 4 18V12C4 11.45 4.45 11 5 11H6.97C7.52 11 7.97 11.45 7.97 12V15.14C7.97 15.88 8.38 16.56 9.03 16.91C9.32 17.07 9.64 17.15 9.97 17.15C10.35 17.15 10.73 17.04 11.06 16.82L12.01 16.2L12.89 16.79C13.5 17.2 14.28 17.25 14.93 16.9C15.59 16.55 16 15.88 16 15.13V12C16 11.45 16.45 11 17 11H19C19.55 11 20 11.45 20 12Z" fill={color} />
|
||||
<path d="M21.5 7V8C21.5 9.1 20.97 10 19.5 10H4.5C2.97 10 2.5 9.1 2.5 8V7C2.5 5.9 2.97 5 4.5 5H19.5C20.97 5 21.5 5.9 21.5 7Z" fill={color} />
|
||||
<path d="M11.6388 5.00043H6.11881C5.77881 4.63043 5.78881 4.06043 6.14881 3.70043L7.56881 2.28043C7.93881 1.91043 8.54881 1.91043 8.91881 2.28043L11.6388 5.00043Z" fill={color} />
|
||||
<path d="M17.8716 5.00043H12.3516L15.0716 2.28043C15.4416 1.91043 16.0516 1.91043 16.4216 2.28043L17.8416 3.70043C18.2016 4.06043 18.2116 4.63043 17.8716 5.00043Z" fill={color} />
|
||||
<path d="M13.9714 11C14.5214 11 14.9714 11.45 14.9714 12V15.13C14.9714 15.93 14.0814 16.41 13.4214 15.96L12.5214 15.36C12.1914 15.14 11.7614 15.14 11.4214 15.36L10.4814 15.98C9.82141 16.42 8.94141 15.94 8.94141 15.15V12C8.94141 11.45 9.39141 11 9.94141 11H13.9714Z" fill={color} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoCircleRedFilled({ size = 20, color = '#D32F2F' }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M9.99902 2.16699C14.3144 2.16699 17.8328 5.68462 17.833 10C17.833 14.3155 14.3145 17.834 9.99902 17.834C5.68365 17.8338 2.16602 14.3154 2.16602 10C2.16619 5.68473 5.68376 2.16717 9.99902 2.16699ZM10.5088 12.1055C10.1857 11.9709 9.81331 11.9709 9.49023 12.1055C9.3364 12.1696 9.197 12.2588 9.07324 12.3701L9.05371 12.3877L9.03613 12.4072C8.92481 12.5309 8.83561 12.6705 8.77148 12.8242C8.70851 12.9754 8.66602 13.1498 8.66602 13.334C8.66606 13.5127 8.70667 13.6812 8.7666 13.8291L8.76562 13.8301C8.83536 14.0113 8.93351 14.1457 9.03613 14.2598L9.05371 14.2793L9.07324 14.2969C9.197 14.4083 9.33639 14.4974 9.49023 14.5615C9.64129 14.6245 9.81496 14.6669 9.99902 14.667C10.1832 14.667 10.3576 14.6245 10.5088 14.5615C10.6625 14.4974 10.8021 14.4082 10.9258 14.2969L10.9453 14.2793L10.9629 14.2598C11.0631 14.1484 11.1572 14.0168 11.2266 13.8418L11.2275 13.8428C11.2905 13.6917 11.333 13.518 11.333 13.334C11.333 13.1498 11.2905 12.9754 11.2275 12.8242C11.1634 12.6704 11.0742 12.5309 10.9629 12.4072L10.9453 12.3877L10.9258 12.3701L10.8301 12.291C10.7315 12.2159 10.6242 12.1536 10.5088 12.1055ZM9.99902 5.54199C9.38136 5.54217 8.87402 6.04929 8.87402 6.66699V10.834C8.8742 11.4515 9.38147 11.9588 9.99902 11.959C10.6167 11.959 11.1238 11.4516 11.124 10.834V6.66699C11.124 6.04918 10.6168 5.54199 9.99902 5.54199Z"
|
||||
fill={color} stroke={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchHeaderP({ color = '#7E7E7E' }: { color?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M22 22L20 20" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user