@fayz-ai/saas 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/access/context.d.ts +47 -0
  2. package/dist/access/context.d.ts.map +1 -0
  3. package/dist/access/headless.d.ts +21 -0
  4. package/dist/access/headless.d.ts.map +1 -0
  5. package/dist/access/index.d.ts +12 -0
  6. package/dist/access/index.d.ts.map +1 -0
  7. package/dist/access/limits-registry.d.ts +33 -0
  8. package/dist/access/limits-registry.d.ts.map +1 -0
  9. package/dist/access/module-nav.d.ts +15 -0
  10. package/dist/access/module-nav.d.ts.map +1 -0
  11. package/dist/access/resolver.d.ts +2 -0
  12. package/dist/access/resolver.d.ts.map +1 -0
  13. package/dist/access/types.d.ts +43 -0
  14. package/dist/access/types.d.ts.map +1 -0
  15. package/dist/access/upgrade-modal-store.d.ts +23 -0
  16. package/dist/access/upgrade-modal-store.d.ts.map +1 -0
  17. package/dist/app/AdminShell.d.ts +3 -0
  18. package/dist/app/AdminShell.d.ts.map +1 -1
  19. package/dist/app/admin-app.d.ts +3 -1
  20. package/dist/app/admin-app.d.ts.map +1 -1
  21. package/dist/app/config.d.ts +47 -2
  22. package/dist/app/config.d.ts.map +1 -1
  23. package/dist/app/core-entities.d.ts +25 -0
  24. package/dist/app/core-entities.d.ts.map +1 -0
  25. package/dist/app/derive-contract.d.ts +8 -0
  26. package/dist/app/derive-contract.d.ts.map +1 -0
  27. package/dist/app/scaffold.d.ts.map +1 -1
  28. package/dist/archetype-lookup.d.ts.map +1 -1
  29. package/dist/billing/index.cjs +6 -2
  30. package/dist/billing/index.d.ts +2 -0
  31. package/dist/billing/index.d.ts.map +1 -1
  32. package/dist/billing/index.js +1 -1
  33. package/dist/billing/plan-badge.d.ts +17 -0
  34. package/dist/billing/plan-badge.d.ts.map +1 -0
  35. package/dist/billing/store.d.ts +6 -1
  36. package/dist/billing/store.d.ts.map +1 -1
  37. package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
  38. package/dist/chunk-I3DY47N5.js.map +1 -0
  39. package/dist/chunk-NNFGMUF2.cjs +39 -0
  40. package/dist/chunk-NNFGMUF2.cjs.map +1 -0
  41. package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
  42. package/dist/chunk-SAZLW54Y.cjs.map +1 -0
  43. package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
  44. package/dist/chunk-VKYZACG5.cjs.map +1 -0
  45. package/dist/chunk-VN5NTK26.js +36 -0
  46. package/dist/chunk-VN5NTK26.js.map +1 -0
  47. package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
  48. package/dist/chunk-VTEP2AQX.js.map +1 -0
  49. package/dist/components/shared/ContactPicker.d.ts +92 -0
  50. package/dist/components/shared/ContactPicker.d.ts.map +1 -0
  51. package/dist/components/shared/PersonLink.d.ts +12 -1
  52. package/dist/components/shared/PersonLink.d.ts.map +1 -1
  53. package/dist/components/shared/create-person.d.ts +20 -0
  54. package/dist/components/shared/create-person.d.ts.map +1 -0
  55. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  56. package/dist/crud/CrudFormPage.d.ts.map +1 -1
  57. package/dist/crud/CrudListView.d.ts.map +1 -1
  58. package/dist/crud/CrudPage.d.ts.map +1 -1
  59. package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
  60. package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
  61. package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
  62. package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
  63. package/dist/crud/createCrudPage.d.ts.map +1 -1
  64. package/dist/crud/index.d.ts +1 -0
  65. package/dist/crud/index.d.ts.map +1 -1
  66. package/dist/index.cjs +4281 -716
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.ts +17 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4067 -542
  71. package/dist/index.js.map +1 -1
  72. package/dist/org/index.cjs +11 -11
  73. package/dist/org/index.js +2 -2
  74. package/dist/org/store.d.ts.map +1 -1
  75. package/dist/permissions/context.d.ts +2 -0
  76. package/dist/permissions/context.d.ts.map +1 -1
  77. package/dist/permissions/index.cjs +7 -7
  78. package/dist/permissions/index.js +1 -1
  79. package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
  80. package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
  81. package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
  82. package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
  83. package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
  84. package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
  85. package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
  86. package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
  87. package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
  88. package/dist/shell/components/billing/access-contract.d.ts +18 -0
  89. package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
  90. package/dist/shell/components/billing/gates.d.ts +14 -0
  91. package/dist/shell/components/billing/gates.d.ts.map +1 -0
  92. package/dist/shell/components/billing/index.d.ts +9 -2
  93. package/dist/shell/components/billing/index.d.ts.map +1 -1
  94. package/dist/shell/components/chat/ChatFab.d.ts +3 -1
  95. package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
  96. package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
  97. package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
  98. package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
  99. package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
  100. package/dist/shell/components/chat/markdown.d.ts +5 -0
  101. package/dist/shell/components/chat/markdown.d.ts.map +1 -0
  102. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  103. package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
  104. package/dist/shell/components/organization/index.d.ts +0 -1
  105. package/dist/shell/components/organization/index.d.ts.map +1 -1
  106. package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
  107. package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
  108. package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
  109. package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
  110. package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
  111. package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
  112. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  113. package/dist/shell/hooks/index.d.ts +3 -2
  114. package/dist/shell/hooks/index.d.ts.map +1 -1
  115. package/dist/shell/hooks/useAITools.d.ts +2 -0
  116. package/dist/shell/hooks/useAITools.d.ts.map +1 -1
  117. package/dist/shell/hooks/useChat.d.ts +22 -0
  118. package/dist/shell/hooks/useChat.d.ts.map +1 -1
  119. package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
  120. package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
  121. package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
  122. package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
  123. package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
  124. package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
  125. package/dist/shell/lib/core-ai-tools.d.ts +14 -0
  126. package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
  127. package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
  128. package/dist/shell/lib/fayz-agent.d.ts +23 -0
  129. package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
  130. package/dist/shell/lib/i18n.d.ts.map +1 -1
  131. package/dist/shell/stores/chat.store.d.ts +69 -0
  132. package/dist/shell/stores/chat.store.d.ts.map +1 -1
  133. package/dist/shell/types/billing.d.ts +7 -0
  134. package/dist/shell/types/billing.d.ts.map +1 -1
  135. package/dist/shell/types/plugins.d.ts +2 -45
  136. package/dist/shell/types/plugins.d.ts.map +1 -1
  137. package/dist/stores/createCrudStore.d.ts.map +1 -1
  138. package/package.json +14 -11
  139. package/src/access/context.tsx +228 -0
  140. package/src/access/headless.ts +71 -0
  141. package/src/access/index.ts +27 -0
  142. package/src/access/limits-registry.ts +86 -0
  143. package/src/access/module-nav.test.ts +78 -0
  144. package/src/access/module-nav.ts +82 -0
  145. package/src/access/resolver.test.ts +143 -0
  146. package/src/access/resolver.ts +5 -0
  147. package/src/access/types.ts +47 -0
  148. package/src/access/upgrade-modal-store.ts +30 -0
  149. package/src/app/AdminShell.tsx +451 -68
  150. package/src/app/admin-app.tsx +46 -9
  151. package/src/app/config.ts +54 -1
  152. package/src/app/core-entities.ts +110 -0
  153. package/src/app/derive-contract.test.ts +155 -0
  154. package/src/app/derive-contract.ts +214 -0
  155. package/src/app/scaffold.tsx +4 -0
  156. package/src/archetype-lookup.ts +8 -2
  157. package/src/billing/index.ts +2 -0
  158. package/src/billing/plan-badge.ts +32 -0
  159. package/src/billing/store.ts +8 -1
  160. package/src/components/shared/ContactPicker.tsx +394 -0
  161. package/src/components/shared/PersonLink.tsx +11 -3
  162. package/src/components/shared/create-person.ts +74 -0
  163. package/src/crud/CrudDetailPage.tsx +5 -1
  164. package/src/crud/CrudFormPage.tsx +113 -6
  165. package/src/crud/CrudListView.tsx +16 -1
  166. package/src/crud/CrudPage.tsx +27 -5
  167. package/src/crud/DeleteConfirmDialog.tsx +1 -1
  168. package/src/crud/archetypes/AddressesTab.tsx +387 -0
  169. package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
  170. package/src/crud/createCrudPage.tsx +2 -0
  171. package/src/crud/index.ts +6 -0
  172. package/src/env.d.ts +7 -0
  173. package/src/index.ts +49 -1
  174. package/src/org/store.ts +9 -1
  175. package/src/permissions/context.tsx +29 -15
  176. package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
  177. package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
  178. package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
  179. package/src/shell/components/billing/UpgradeModal.tsx +102 -0
  180. package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
  181. package/src/shell/components/billing/access-contract.ts +64 -0
  182. package/src/shell/components/billing/gates.tsx +62 -0
  183. package/src/shell/components/billing/index.ts +18 -2
  184. package/src/shell/components/chat/ChatFab.tsx +4 -2
  185. package/src/shell/components/chat/ChatPanel.tsx +174 -23
  186. package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
  187. package/src/shell/components/chat/markdown.tsx +102 -0
  188. package/src/shell/components/organization/TeamTab.tsx +21 -4
  189. package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
  190. package/src/shell/components/organization/index.ts +0 -1
  191. package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
  192. package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
  193. package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
  194. package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
  195. package/src/shell/components/settings/SettingsPage.tsx +63 -21
  196. package/src/shell/hooks/index.ts +3 -2
  197. package/src/shell/hooks/useAITools.ts +27 -23
  198. package/src/shell/hooks/useChat.ts +381 -16
  199. package/src/shell/hooks/useSystemPermission.ts +23 -0
  200. package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
  201. package/src/shell/lib/ai-tool-handlers.ts +910 -0
  202. package/src/shell/lib/core-ai-tools.ts +234 -2
  203. package/src/shell/lib/create-client-orders-provider.ts +24 -2
  204. package/src/shell/lib/fayz-agent.ts +56 -0
  205. package/src/shell/lib/i18n.ts +71 -0
  206. package/src/shell/stores/chat.store.ts +118 -1
  207. package/src/shell/styles.css +30 -0
  208. package/src/shell/types/billing.ts +8 -0
  209. package/src/shell/types/plugins.ts +13 -50
  210. package/src/stores/createCrudStore.ts +9 -0
  211. package/dist/chunk-3M25GRRU.js.map +0 -1
  212. package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
  213. package/dist/chunk-5GYQK5IW.js +0 -21
  214. package/dist/chunk-5GYQK5IW.js.map +0 -1
  215. package/dist/chunk-IPWCHJPE.cjs.map +0 -1
  216. package/dist/chunk-KYID2MR6.cjs +0 -23
  217. package/dist/chunk-KYID2MR6.cjs.map +0 -1
  218. package/dist/chunk-XNWCGJYH.js.map +0 -1
  219. package/dist/shell/components/billing/BillingPage.d.ts +0 -7
  220. package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
  221. package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
  222. package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
  223. package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
  224. package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
  225. package/dist/shell/hooks/useBilling.d.ts +0 -14
  226. package/dist/shell/hooks/useBilling.d.ts.map +0 -1
  227. package/dist/shell/hooks/usePermission.d.ts +0 -16
  228. package/dist/shell/hooks/usePermission.d.ts.map +0 -1
  229. package/src/shell/components/billing/BillingPage.tsx +0 -56
  230. package/src/shell/components/billing/PaywallGate.tsx +0 -80
  231. package/src/shell/components/organization/PermissionGate.tsx +0 -16
  232. package/src/shell/hooks/useBilling.ts +0 -151
  233. package/src/shell/hooks/usePermission.ts +0 -101
@@ -0,0 +1,372 @@
1
+ import * as React from 'react'
2
+ import { Crown, Check, Minus, Sparkles } from 'lucide-react'
3
+ import { Button, Badge, Card, CardContent, CardHeader, ConfirmDialog, toast } from '@fayz-ai/ui'
4
+ import type { Plan } from '@fayz-ai/core'
5
+ import { cn } from '../../lib/cn'
6
+ import { useTranslation } from '../../hooks/useTranslation'
7
+ import { useBillingStore } from '../../stores/billing.store'
8
+ import { resolvePlanBadge } from '../../../billing/plan-badge'
9
+ import { useTenantOptional, useOrgAdapterOptional } from '../../../org/context'
10
+ import { useOrganizationStore } from '../../../org/store'
11
+ import { usePermissionsStore } from '../../../permissions'
12
+ import { getPlanEntitlements } from './access-contract'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // SubscriptionPage — the shell's self-serve subscription surface. Present as the
16
+ // /settings/subscription tab whenever `config.billing` is configured. Reads the
17
+ // plans seeded into the billing store from config, resolves the current plan
18
+ // from `currentOrg.plan`, and changes plan via the checkout seam (Stripe/Pix) or,
19
+ // when no seam is configured, an optimistic `adapter.updateOrg(orgId, { plan })`.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function formatPrice(plan: Plan, tr: (k: string, f: string) => string): string {
23
+ if (plan.price <= 0) return tr('billing.free', 'Free')
24
+ try {
25
+ const isInteger = Number.isInteger(plan.price)
26
+ return new Intl.NumberFormat(undefined, {
27
+ style: 'currency',
28
+ currency: plan.currency || 'USD',
29
+ minimumFractionDigits: isInteger ? 0 : 2,
30
+ }).format(plan.price)
31
+ } catch {
32
+ return `${plan.currency || ''} ${plan.price}`.trim()
33
+ }
34
+ }
35
+
36
+ function intervalSuffix(plan: Plan, tr: (k: string, f: string) => string): string {
37
+ if (plan.price <= 0) return ''
38
+ return plan.interval === 'year'
39
+ ? tr('billing.perYear', '/yr')
40
+ : tr('billing.perMonth', '/mo')
41
+ }
42
+
43
+ export function SubscriptionPage({ className }: { className?: string }) {
44
+ const { t } = useTranslation()
45
+ const tr = (key: string, fallback: string) => {
46
+ const v = t(key)
47
+ return !v || v === key ? fallback : v
48
+ }
49
+
50
+ const plans = useBillingStore((s) => s.plans)
51
+ const checkout = useBillingStore((s) => s.checkout)
52
+ const tenant = useTenantOptional()
53
+ const adapter = useOrgAdapterOptional()
54
+ const setCurrentOrg = useOrganizationStore((s) => s.setCurrentOrg)
55
+ const currentOrg = useOrganizationStore((s) => s.currentOrg)
56
+
57
+ const currentPlanId = currentOrg?.plan || 'free'
58
+ const currentBadge = resolvePlanBadge(currentPlanId, plans)
59
+ const currentPlan = plans.find((p) => p.id === currentPlanId)
60
+
61
+ const [pending, setPending] = React.useState<Plan | null>(null)
62
+ const [busy, setBusy] = React.useState(false)
63
+
64
+ // Feature matrix — union of every plan's features, order-preserved.
65
+ const allFeatures = React.useMemo(() => {
66
+ const seen = new Set<string>()
67
+ const out: string[] = []
68
+ for (const plan of plans) {
69
+ for (const f of plan.features) {
70
+ if (!seen.has(f)) {
71
+ seen.add(f)
72
+ out.push(f)
73
+ }
74
+ }
75
+ }
76
+ return out
77
+ }, [plans])
78
+
79
+ // Real entitlements matrix — derived from plan.entitlements when any plan
80
+ // declares them. Feature rows show ✓/— per plan; limit rows show "Up to N" /
81
+ // "Unlimited". Falls back to the marketing-bullet matrix (allFeatures) below
82
+ // when no plan carries entitlements.
83
+ const featureCatalog = usePermissionsStore((s) => s.features)
84
+ const entitlementsMatrix = React.useMemo(() => {
85
+ const hasAny = plans.some((p) => getPlanEntitlements(p))
86
+ if (!hasAny) return null
87
+
88
+ const featureIds: string[] = []
89
+ const featureSeen = new Set<string>()
90
+ const limitKeys: string[] = []
91
+ const limitSeen = new Set<string>()
92
+ for (const plan of plans) {
93
+ const ent = getPlanEntitlements(plan)
94
+ for (const id of Object.keys(ent?.features ?? {})) {
95
+ if (!featureSeen.has(id)) { featureSeen.add(id); featureIds.push(id) }
96
+ }
97
+ for (const key of Object.keys(ent?.limits ?? {})) {
98
+ if (!limitSeen.has(key)) { limitSeen.add(key); limitKeys.push(key) }
99
+ }
100
+ }
101
+ if (featureIds.length === 0 && limitKeys.length === 0) return null
102
+
103
+ const labelForFeature = (id: string) => featureCatalog.find((f) => f.id === id)?.label ?? id
104
+ const labelForLimit = (key: string) => {
105
+ const v = t(`limit.label.${key}`)
106
+ return !v || v === `limit.label.${key}` ? key : v
107
+ }
108
+ return { featureIds, limitKeys, labelForFeature, labelForLimit }
109
+ }, [plans, featureCatalog, t])
110
+
111
+ async function applyChange(plan: Plan) {
112
+ setBusy(true)
113
+ try {
114
+ // Seam: a configured gateway (Stripe/Pix) owns the transition; the webhook
115
+ // is the source of truth for the persisted plan, so we don't mutate here.
116
+ if (checkout) {
117
+ await checkout(plan.id)
118
+ setPending(null)
119
+ return
120
+ }
121
+ // No gateway: optimistic org update. Persist + swap the store so the plan
122
+ // badge and CTAs re-render immediately.
123
+ if (adapter && currentOrg) {
124
+ const updated = await adapter.updateOrg(currentOrg.id, { plan: plan.id })
125
+ setCurrentOrg(updated)
126
+ } else if (currentOrg) {
127
+ setCurrentOrg({ ...currentOrg, plan: plan.id })
128
+ }
129
+ toast.success(
130
+ tr('billing.planChanged', 'Plan updated').replace('{{plan}}', plan.name),
131
+ )
132
+ setPending(null)
133
+ } catch {
134
+ toast.error(tr('billing.planChangeFailed', "Couldn't change your plan. Please try again."))
135
+ } finally {
136
+ setBusy(false)
137
+ }
138
+ }
139
+
140
+ if (plans.length === 0) {
141
+ return (
142
+ <div className={className ?? 'mx-auto max-w-5xl'}>
143
+ <p className="text-sm text-muted-foreground">
144
+ {tr('billing.noPlans', 'No plans are configured.')}
145
+ </p>
146
+ </div>
147
+ )
148
+ }
149
+
150
+ return (
151
+ <div className={className ?? 'mx-auto max-w-5xl space-y-8'}>
152
+ {/* Header */}
153
+ <div>
154
+ <h1 className="text-2xl font-bold tracking-tight">
155
+ {tr('billing.subscription.title', 'Subscription')}
156
+ </h1>
157
+ <p className="text-sm text-muted-foreground">
158
+ {tr('billing.subscription.subtitle', 'Manage your plan and unlock more features.')}
159
+ </p>
160
+ </div>
161
+
162
+ {/* Current plan highlight */}
163
+ <Card
164
+ className={cn(
165
+ 'relative overflow-hidden',
166
+ currentBadge.paid && 'border-amber-400/60 bg-gradient-to-br from-amber-50/60 to-transparent dark:from-amber-950/20',
167
+ )}
168
+ >
169
+ <CardContent className="flex flex-wrap items-center justify-between gap-4 p-6">
170
+ <div className="flex items-center gap-4">
171
+ <div
172
+ className={cn(
173
+ 'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
174
+ currentBadge.paid ? 'bg-amber-400/20 text-amber-600 dark:text-amber-400' : 'bg-muted text-muted-foreground',
175
+ )}
176
+ >
177
+ {currentBadge.paid ? <Crown className="h-6 w-6" /> : <Sparkles className="h-6 w-6" />}
178
+ </div>
179
+ <div>
180
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
181
+ {tr('billing.currentPlan', 'Current Plan')}
182
+ </p>
183
+ <div className="flex items-center gap-2">
184
+ <span className="text-xl font-bold">{currentBadge.label}</span>
185
+ {currentBadge.paid && (
186
+ // Paid marker only — a literal "Pro" here was misleading next
187
+ // to non-Pro paid tiers ("Starter / Pro", QA finding).
188
+ <Badge
189
+ aria-label={tr('billing.paidPlan', 'Paid plan')}
190
+ className="border-amber-400/40 bg-amber-400/15 text-amber-700 dark:text-amber-300"
191
+ >
192
+ <Crown className="h-3 w-3" />
193
+ </Badge>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ {currentPlan && (
199
+ <div className="text-right">
200
+ <span className="text-2xl font-bold">{formatPrice(currentPlan, tr)}</span>
201
+ <span className="text-sm text-muted-foreground">{intervalSuffix(currentPlan, tr)}</span>
202
+ </div>
203
+ )}
204
+ </CardContent>
205
+ </Card>
206
+
207
+ {/* Plan cards */}
208
+ <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
209
+ {plans.map((plan) => {
210
+ const isCurrent = plan.id === currentPlanId
211
+ const paid = plan.price > 0
212
+ return (
213
+ <Card
214
+ key={plan.id}
215
+ className={cn(
216
+ 'relative flex flex-col',
217
+ plan.highlighted && 'border-primary shadow-md',
218
+ isCurrent && 'ring-2 ring-primary/40',
219
+ )}
220
+ >
221
+ {plan.highlighted && (
222
+ <div className="absolute -top-3 left-1/2 -translate-x-1/2">
223
+ <Badge>
224
+ <Crown className="mr-1 h-3 w-3" />
225
+ {tr('billing.mostPopular', 'Most Popular')}
226
+ </Badge>
227
+ </div>
228
+ )}
229
+ <CardHeader className="pb-0">
230
+ <div className="flex items-center gap-2">
231
+ <h3 className="text-xl font-semibold">{plan.name}</h3>
232
+ {paid && <Crown className="h-4 w-4 text-amber-500" />}
233
+ </div>
234
+ {plan.description && (
235
+ <p className="text-sm text-muted-foreground">{plan.description}</p>
236
+ )}
237
+ </CardHeader>
238
+ <CardContent className="flex flex-1 flex-col pt-4">
239
+ <div className="mb-6">
240
+ <span className="text-4xl font-bold">{formatPrice(plan, tr)}</span>
241
+ <span className="text-muted-foreground">{intervalSuffix(plan, tr)}</span>
242
+ </div>
243
+ <ul className="mb-6 flex-1 space-y-3">
244
+ {plan.features.map((feature) => (
245
+ <li key={feature} className="flex items-start gap-2 text-sm">
246
+ <Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
247
+ <span>{feature}</span>
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ {isCurrent ? (
252
+ <Button variant="outline" className="w-full" disabled>
253
+ {tr('billing.currentPlan', 'Current Plan')}
254
+ </Button>
255
+ ) : (
256
+ <Button
257
+ className="w-full"
258
+ variant={plan.highlighted ? 'default' : 'outline'}
259
+ onClick={() => setPending(plan)}
260
+ >
261
+ {tr('billing.changeToPlan', 'Switch to this plan')}
262
+ </Button>
263
+ )}
264
+ </CardContent>
265
+ </Card>
266
+ )
267
+ })}
268
+ </div>
269
+
270
+ {/* Feature matrix */}
271
+ {(entitlementsMatrix || allFeatures.length > 0) && (
272
+ <div className="space-y-3">
273
+ <h2 className="text-lg font-semibold">
274
+ {tr('billing.featureMatrix', 'Feature comparison')}
275
+ </h2>
276
+ <div className="overflow-x-auto rounded-lg border">
277
+ <table className="w-full border-collapse text-sm">
278
+ <thead>
279
+ <tr className="border-b bg-muted/40">
280
+ <th className="px-4 py-3 text-left font-medium">
281
+ {tr('billing.features', 'Features')}
282
+ </th>
283
+ {plans.map((plan) => (
284
+ <th key={plan.id} className="px-4 py-3 text-center font-medium">
285
+ <span className="inline-flex items-center gap-1">
286
+ {plan.price > 0 && <Crown className="h-3.5 w-3.5 text-amber-500" />}
287
+ {plan.name}
288
+ </span>
289
+ </th>
290
+ ))}
291
+ </tr>
292
+ </thead>
293
+ <tbody>
294
+ {entitlementsMatrix ? (
295
+ <>
296
+ {entitlementsMatrix.featureIds.map((id, i) => (
297
+ <tr key={`f:${id}`} className={cn(i % 2 === 1 && 'bg-muted/20')}>
298
+ <td className="px-4 py-2.5 text-left">{entitlementsMatrix.labelForFeature(id)}</td>
299
+ {plans.map((plan) => {
300
+ const entitled = getPlanEntitlements(plan)?.features?.[id] !== false
301
+ return (
302
+ <td key={plan.id} className="px-4 py-2.5 text-center">
303
+ {entitled ? (
304
+ <Check className="mx-auto h-4 w-4 text-primary" />
305
+ ) : (
306
+ <Minus className="mx-auto h-4 w-4 text-muted-foreground/40" />
307
+ )}
308
+ </td>
309
+ )
310
+ })}
311
+ </tr>
312
+ ))}
313
+ {entitlementsMatrix.limitKeys.map((key, i) => (
314
+ <tr key={`l:${key}`} className={cn((entitlementsMatrix.featureIds.length + i) % 2 === 1 && 'bg-muted/20')}>
315
+ <td className="px-4 py-2.5 text-left">{entitlementsMatrix.labelForLimit(key)}</td>
316
+ {plans.map((plan) => {
317
+ const cap = getPlanEntitlements(plan)?.limits?.[key]
318
+ const cell =
319
+ cap === undefined
320
+ ? '—'
321
+ : cap < 0
322
+ ? tr('billing.unlimited', 'Unlimited')
323
+ : tr('billing.upTo', 'Up to {count}').replace('{count}', String(cap)).replace('{{count}}', String(cap))
324
+ return (
325
+ <td key={plan.id} className="px-4 py-2.5 text-center text-muted-foreground">
326
+ {cell}
327
+ </td>
328
+ )
329
+ })}
330
+ </tr>
331
+ ))}
332
+ </>
333
+ ) : (
334
+ allFeatures.map((feature, i) => (
335
+ <tr key={feature} className={cn(i % 2 === 1 && 'bg-muted/20')}>
336
+ <td className="px-4 py-2.5 text-left">{feature}</td>
337
+ {plans.map((plan) => (
338
+ <td key={plan.id} className="px-4 py-2.5 text-center">
339
+ {plan.features.includes(feature) ? (
340
+ <Check className="mx-auto h-4 w-4 text-primary" />
341
+ ) : (
342
+ <Minus className="mx-auto h-4 w-4 text-muted-foreground/40" />
343
+ )}
344
+ </td>
345
+ ))}
346
+ </tr>
347
+ ))
348
+ )}
349
+ </tbody>
350
+ </table>
351
+ </div>
352
+ </div>
353
+ )}
354
+
355
+ {/* Change-plan confirm */}
356
+ <ConfirmDialog
357
+ open={pending !== null}
358
+ onConfirm={() => pending && void applyChange(pending)}
359
+ onCancel={() => setPending(null)}
360
+ title={tr('billing.changePlanConfirmTitle', 'Change plan?')}
361
+ description={
362
+ pending
363
+ ? tr('billing.changePlanConfirmBody', "You're switching to the {{plan}} plan.").replace('{{plan}}', pending.name)
364
+ : undefined
365
+ }
366
+ confirmLabel={tr('billing.changePlanConfirm', 'Switch plan')}
367
+ cancelLabel={tr('common.cancel', 'Cancel')}
368
+ loading={busy}
369
+ />
370
+ </div>
371
+ )
372
+ }
@@ -0,0 +1,102 @@
1
+ import * as React from 'react'
2
+ import { Crown } from 'lucide-react'
3
+ import { Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, Button } from '@fayz-ai/ui'
4
+ import { useTranslation } from '../../hooks/useTranslation'
5
+ import { useBillingStore } from '../../stores/billing.store'
6
+ import { usePermissionsStore } from '../../../permissions'
7
+ import { useOrganizationStore } from '../../../org/store'
8
+ import { navigateTo } from '../../../app/routing'
9
+ import { useUpgradeModalStore, useLimit, featureDisplayName } from './access-contract'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // UpgradeModal — the single global upgrade dialog. Mounted once in the shell
13
+ // (see AdminShell). Opened imperatively via useUpgradeModalStore().open({...})
14
+ // by LimitGate / useLimitGuard (limitKey context) or feature gates (feature
15
+ // context). Radix dialog WITH a ModalTitle for a11y (regression guard: B25).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Inner body — only mounted while a context is present, so useLimit(limitKey)
19
+ * runs against a real key (hooks stay unconditional inside this component). */
20
+ function UpgradeModalBody({ limitKey, feature }: { limitKey?: string; feature?: string }) {
21
+ const { t } = useTranslation()
22
+ const tr = (key: string, fallback: string, params?: Record<string, string | number>) => {
23
+ let v = t(key)
24
+ if (!v || v === key) v = fallback
25
+ if (params) for (const [k, val] of Object.entries(params)) v = v.replace(`{${k}}`, String(val)).replace(`{{${k}}}`, String(val))
26
+ return v
27
+ }
28
+
29
+ const close = useUpgradeModalStore((s) => s.close)
30
+ const plans = useBillingStore((s) => s.plans)
31
+ const features = usePermissionsStore((s) => s.features)
32
+ const currentOrg = useOrganizationStore((s) => s.currentOrg)
33
+ const currentPlan = plans.find((p) => p.id === (currentOrg?.plan || 'free'))
34
+ const planName = currentPlan?.name ?? tr('billing.free', 'Free')
35
+
36
+ const limit = useLimit(limitKey ?? '')
37
+
38
+ const featureLabel = feature ? featureDisplayName(features.find((f) => f.id === feature)?.label ?? feature) : null
39
+ const limitLabel = limitKey
40
+ ? (tr(`limit.label.${limitKey}`, limitKey))
41
+ : null
42
+
43
+ const title = featureLabel && !limitKey
44
+ ? tr('upgrade.unlock', 'Unlock {feature}', { feature: featureLabel })
45
+ : tr('upgrade.title', 'Premium feature')
46
+ const description = limitKey
47
+ ? tr('limit.reached', "You've reached the {label} limit of the {plan} plan.", { label: limitLabel ?? limitKey, plan: planName })
48
+ : featureLabel
49
+ ? tr('upgrade.descriptionFeature', 'The {feature} feature is available on a higher plan. Upgrade to unlock it.', { feature: featureLabel })
50
+ : tr('upgrade.description', 'This feature is available on a higher plan. Upgrade to unlock it.')
51
+
52
+ const goToPlans = () => {
53
+ close()
54
+ navigateTo('/settings/subscription')
55
+ }
56
+
57
+ return (
58
+ <ModalContent size="md">
59
+ <ModalHeader>
60
+ <span className="mb-2 flex h-11 w-11 items-center justify-center rounded-xl bg-amber-400/15 text-amber-500">
61
+ <Crown className="h-5 w-5" />
62
+ </span>
63
+ <ModalTitle>{title}</ModalTitle>
64
+ <ModalDescription>{description}</ModalDescription>
65
+ </ModalHeader>
66
+
67
+ {limitKey && !limit.unlimited && (
68
+ <ModalBody>
69
+ <div className="rounded-lg border bg-muted/30 px-4 py-3">
70
+ <div className="flex items-center justify-between text-sm">
71
+ <span className="text-muted-foreground">{limitLabel}</span>
72
+ <span className="font-semibold text-foreground">
73
+ {limit.used} / {limit.max}
74
+ </span>
75
+ </div>
76
+ </div>
77
+ </ModalBody>
78
+ )}
79
+
80
+ <ModalFooter>
81
+ <Button variant="outline" onClick={close}>
82
+ {tr('common.cancel', 'Cancel')}
83
+ </Button>
84
+ <Button onClick={goToPlans}>
85
+ <Crown className="mr-1.5 h-4 w-4" />
86
+ {tr('upgrade.cta', 'View plans')}
87
+ </Button>
88
+ </ModalFooter>
89
+ </ModalContent>
90
+ )
91
+ }
92
+
93
+ export function UpgradeModal() {
94
+ const current = useUpgradeModalStore((s) => s.current)
95
+ const close = useUpgradeModalStore((s) => s.close)
96
+
97
+ return (
98
+ <Modal open={current !== null} onOpenChange={(open) => { if (!open) close() }}>
99
+ {current !== null && <UpgradeModalBody limitKey={current.limitKey} feature={current.feature} />}
100
+ </Modal>
101
+ )
102
+ }