Files
clinicpro/.claude/prompt/subscription-gate-ui.md
T

15 KiB
Raw Blame History

اعمال محدودیت‌های اشتراک در پنل ادمین (Frontend Gate)

زمینه

سیستم اشتراک در backend کاملاً پیاده است:

  • SubscriptionService::hasFeature(entityType, entityId, feature) — بررسی دسترسی به قابلیت
  • SubscriptionService::getSecretaryLimit(...) — حداکثر منشی مجاز
  • SubscriptionPlan::features — یک آرایه JSON مثل { "patient_records": true, "services": true, "sms_panel": true }
  • backend روی API های PatientController و ClinicServiceController گیت دارد (ERR_SUBSCRIPTION_REQUIRED)

مشکل: Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل.

هدف: وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:

  1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
  2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
  3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند

مشکل / هدف

قابلیت‌های محدودشده توسط اشتراک

Feature Key صفحه/منو نقش مرتبط
patient_records /admin/my-patients doctor, clinic, secretary
services /admin/clinic-services doctor, clinic
sms_panel /admin/sms-wallet doctor, clinic
max_secretaries /admin/my-secretaries (تعداد) doctor, clinic

نکته منشی: منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال می‌شود. endpoint /api/v1/subscription/my برای منشی null برمی‌گرداند (چون resolveEntity در SubscriptionController فقط ROLE_DOCTOR و ROLE_CLINIC را handle می‌کند).


فایل‌های مرتبط

فایل نقش
src/Subscription/Controller/SubscriptionController.php endpoint GET /api/v1/subscription/my — برای doctor و clinic
src/Subscription/Service/SubscriptionService.php hasFeature(), getSecretaryLimit()
assets/admin/stores/authStore.ts state مرکزی auth — باید subscription هم اینجا باشد
assets/admin/components/layout/Sidebar.tsx buildSections() — منوها بر اساس primaryRole
assets/admin/App.tsx RoleRoute — گیت نقش‌ها، باید feature gate هم اضافه شود
assets/admin/pages/MyPatientsPage.tsx نیاز به patient_records
assets/admin/pages/ClinicServicesPage.tsx نیاز به services
assets/admin/pages/SmsWalletPage.tsx نیاز به sms_panel
assets/admin/pages/MySecretariesPage.tsx نیاز به max_secretaries بیش از ۱
assets/admin/pages/SubscriptionPage.tsx صفحه ارتقاء پنل — مقصد CTA ها

وضعیت فعلی

Backend — endpoint اشتراک

// SubscriptionController.php — GET /api/v1/subscription/my
// فقط doctor و clinic را handle می‌کند
private function resolveEntity(User $user): array
{
    if ($user->hasRole('ROLE_DOCTOR')) {
        $doctor = $this->doctorRepo->findByUser($user);
        return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
    }
    if ($user->hasRole('ROLE_CLINIC')) {
        $clinic = $this->clinicRepo->findByUser($user);
        return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
    }
    return ['unknown', null]; // منشی → 403
}

پاسخ:

{
  "success": true,
  "data": {
    "subscription": {
      "uuid": "...",
      "plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 },
      "is_trial": false,
      "expires_at": 1750000000,
      "days_remaining": 45
    },
    "used_trial": false
  }
}

اگر اشتراک فعال نداشته باشد: "subscription": null

Frontend — authStore

// authStore.ts — فعلاً subscription اصلاً در store نیست
interface AuthState {
  primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
  dbUuid: string | null;
  // ... subscription وجود ندارد
}

Frontend — Sidebar

// Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد
if (primaryRole === 'doctor') {
  return [{
    label: 'مدیریت',
    items: [
      { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' },
      { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' },
      { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' },
    ],
  }];
}

Frontend — App.tsx

// فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />

وظایف

۱. Backend — منشی می‌تواند اشتراک entity خود را ببیند

در SubscriptionController::resolveEntity() حالت ROLE_SECRETARY را اضافه کن:

if ($user->hasRole('ROLE_SECRETARY')) {
    $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
    if ($dbUuid) {
        $clinic = $this->clinicRepo->findByUuid($dbUuid);
        if ($clinic) return ['clinic', $clinic->getId()];
        $doctor = $this->doctorRepo->findByUuid($dbUuid);
        if ($doctor) return ['doctor', $doctor->getId()];
    }
}

نیاز به inject کردن UserActiveContextRepository و ClinicRepository و DoctorRepository به SubscriptionController دارد.

۲. Frontend — hook مشترک useSubscription

یک فایل assets/admin/hooks/useSubscription.ts بساز:

import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import type { ApiResponse, MySubscriptionData } from '../types';

export function useSubscription() {
  const primaryRole = useAuthStore((s) => s.primaryRole);
  const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';

  const { data } = useQuery<ApiResponse<MySubscriptionData>>({
    queryKey: ['subscription-my'],
    queryFn: () => api.get('/api/v1/subscription/my'),
    enabled,
    staleTime: 2 * 60 * 1000,
  });

  const sub = data?.data?.subscription ?? null;
  const features: Record<string, boolean> = sub?.plan?.features ?? {};
  const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1;
  const hasPlan = sub !== null;

  return {
    subscription: sub,
    hasFeature: (key: string) => hasPlan && (features[key] ?? false),
    maxSecretaries,
    hasPlan,
    isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
  };
}

نکته: query key ['subscription-my'] همان key ی است که SubscriptionPage هم استفاده می‌کند — cache مشترک، یک بار fetch.

۳. Frontend — Sidebar با قفل feature

در Sidebar.tsx:

import { useSubscription } from '../../hooks/useSubscription';
// ...

export default function Sidebar() {
  const primaryRole = useAuthStore((s) => s.primaryRole);
  const dbUuid = useAuthStore((s) => s.dbUuid);
  const { hasFeature } = useSubscription();
  // ...
}

نوع SectionItem را گسترش بده:

type SectionItem = {
  to: string;
  icon: React.ElementType;
  label: string;
  feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل
};

آیتم‌های محدود را با feature mark کن:

{ to: '/admin/my-patients',    icon: FolderOpenIcon,       label: 'پرونده بیماران', feature: 'patient_records' },
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها',       feature: 'services' },
{ to: '/admin/sms-wallet',     icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک',  feature: 'sms_panel' },

در render هر آیتم:

const isLocked = item.feature ? !hasFeature(item.feature) : false;

<NavLink
  to={isLocked ? '/admin/subscription' : item.to}
  style={isLocked ? { opacity: 0.5 } : {}}
  title={isLocked ? 'نیاز به ارتقاء پنل' : undefined}
>
  <item.icon />
  {item.label}
  {isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />}
</NavLink>

import کن: import { LockClosedIcon } from '@heroicons/react/24/outline';

۴. Frontend — FeatureGate component

یک component کوچک assets/admin/components/ui/FeatureGate.tsx بساز:

import React from 'react';
import { Link } from 'react-router-dom';
import { LockClosedIcon } from '@heroicons/react/24/outline';
import { useSubscription } from '../../hooks/useSubscription';

interface FeatureGateProps {
  feature: string;
  children: React.ReactNode;
}

export default function FeatureGate({ feature, children }: FeatureGateProps) {
  const { hasFeature, hasPlan } = useSubscription();

  if (hasFeature(feature)) return <>{children}</>;

  return (
    <div style={{
      display: 'flex', flexDirection: 'column',
      alignItems: 'center', justifyContent: 'center',
      minHeight: 320, gap: 16, padding: 40, textAlign: 'center',
    }}>
      <div style={{
        width: 64, height: 64, borderRadius: '50%',
        background: 'oklch(0.97 0.01 256)',
        display: 'grid', placeItems: 'center',
      }}>
        <LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} />
      </div>
      <div>
        <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
          این قابلیت در پنل فعلی شما فعال نیست
        </div>
        <div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360 }}>
          {hasPlan
            ? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید'
            : 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'}
        </div>
      </div>
      <Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}>
        {hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'}
      </Link>
    </div>
  );
}

۵. Frontend — استفاده از FeatureGate در صفحات

در MyPatientsPage.tsx محتوای اصلی را wrap کن:

import FeatureGate from '../components/ui/FeatureGate';
// ...
return (
  <FeatureGate feature="patient_records">
    {/* کد فعلی صفحه */}
  </FeatureGate>
);

در ClinicServicesPage.tsx:

return (
  <FeatureGate feature="services">
    {/* کد فعلی */}
  </FeatureGate>
);

در SmsWalletPage.tsx:

return (
  <FeatureGate feature="sms_panel">
    {/* کد فعلی */}
  </FeatureGate>
);

۶. Frontend — محدودیت تعداد منشی در MySecretariesPage

در MySecretariesPage.tsx وقتی کاربر می‌خواهد منشی جدید اضافه کند:

import { useSubscription } from '../hooks/useSubscription';
// ...
const { maxSecretaries } = useSubscription();
// تعداد فعلی منشیان از data موجود
const activeCount = secretaries.filter(s => s.is_active).length;
const isAtLimit = activeCount >= maxSecretaries;

// روی دکمه «افزودن منشی»:
<button
  className="btn primary sm"
  onClick={() => setAddOpen(true)}
  disabled={isAtLimit}
  title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined}
>
  افزودن منشی
</button>
{isAtLimit && (
  <div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
    برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
  </div>
)}

نکات مهم

  • admin نقش اشتراک ندارد: useSubscription فقط برای doctor/clinic/secretary فعال است. در Sidebar ادمین هیچ feature gate نداشته باشد.
  • secretary اشتراک entity خود را می‌بیند: بعد از task 1، /api/v1/subscription/my برای secretary هم کار می‌کند. اگر db_uuid هنوز set نشده (قبل از انتخاب context)، subscription: null برمی‌گردد — useSubscription به‌درستی hasPlan: false برمی‌گرداند.
  • Cache مشترک: SubscriptionPage هم از queryKey: ['subscription-my'] استفاده می‌کند — تغییر ندهید تا cache reuse شود.
  • پلن free: در DB ممکن است اصلاً subscription نداشته باشند (subscription: null). این حالت را با hasPlan: false handle کن. فرض نکن که "free" یک plan object است.
  • LockClosedIcon را import کن از @heroicons/react/24/outline — باید در heroicons وجود داشته باشد.
  • migration لازم نیست — فقط backend method و frontend تغییر می‌کند.
  • مستندات: بعد از تغییر SubscriptionController، فایل docs/api/subscription.md باید به‌روز شود تا GET /api/v1/subscription/my برای secretary هم مستند شود.

ترتیب اجرا

  1. Backend: SubscriptionController::resolveEntity() — حالت secretary
  2. تست route: ddev exec php bin/console cache:clear
  3. Frontend: assets/admin/hooks/useSubscription.ts — hook جدید
  4. Frontend: assets/admin/components/ui/FeatureGate.tsx — component جدید
  5. Frontend: Sidebar.tsx — اضافه کردن feature به آیتم‌ها و render قفل
  6. Frontend: MyPatientsPage.tsx، ClinicServicesPage.tsx، SmsWalletPage.tsx — wrap با FeatureGate
  7. Frontend: MySecretariesPage.tsx — محدودیت تعداد منشی
  8. Build: ddev exec yarn dev
  9. TypeScript: ddev exec npx tsc --noEmit
  10. مستندات: docs/api/subscription.md