@fayz-ai/saas 0.8.0 → 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.
- package/dist/access/context.d.ts +47 -0
- package/dist/access/context.d.ts.map +1 -0
- package/dist/access/headless.d.ts +21 -0
- package/dist/access/headless.d.ts.map +1 -0
- package/dist/access/index.d.ts +12 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/limits-registry.d.ts +33 -0
- package/dist/access/limits-registry.d.ts.map +1 -0
- package/dist/access/module-nav.d.ts +15 -0
- package/dist/access/module-nav.d.ts.map +1 -0
- package/dist/access/resolver.d.ts +2 -0
- package/dist/access/resolver.d.ts.map +1 -0
- package/dist/access/types.d.ts +43 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/upgrade-modal-store.d.ts +23 -0
- package/dist/access/upgrade-modal-store.d.ts.map +1 -0
- package/dist/app/AdminShell.d.ts +3 -0
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +3 -1
- package/dist/app/admin-app.d.ts.map +1 -1
- package/dist/app/config.d.ts +47 -2
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/core-entities.d.ts +25 -0
- package/dist/app/core-entities.d.ts.map +1 -0
- package/dist/app/derive-contract.d.ts +8 -0
- package/dist/app/derive-contract.d.ts.map +1 -0
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/archetype-lookup.d.ts.map +1 -1
- package/dist/billing/index.cjs +6 -2
- package/dist/billing/index.d.ts +2 -0
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/plan-badge.d.ts +17 -0
- package/dist/billing/plan-badge.d.ts.map +1 -0
- package/dist/billing/store.d.ts +6 -1
- package/dist/billing/store.d.ts.map +1 -1
- package/dist/{chunk-3M25GRRU.js → chunk-I3DY47N5.js} +3 -4
- package/dist/chunk-I3DY47N5.js.map +1 -0
- package/dist/chunk-NNFGMUF2.cjs +39 -0
- package/dist/chunk-NNFGMUF2.cjs.map +1 -0
- package/dist/{chunk-4XTQ4V5L.cjs → chunk-SAZLW54Y.cjs} +16 -12
- package/dist/chunk-SAZLW54Y.cjs.map +1 -0
- package/dist/{chunk-IPWCHJPE.cjs → chunk-VKYZACG5.cjs} +5 -6
- package/dist/chunk-VKYZACG5.cjs.map +1 -0
- package/dist/chunk-VN5NTK26.js +36 -0
- package/dist/chunk-VN5NTK26.js.map +1 -0
- package/dist/{chunk-XNWCGJYH.js → chunk-VTEP2AQX.js} +15 -11
- package/dist/chunk-VTEP2AQX.js.map +1 -0
- package/dist/components/shared/ContactPicker.d.ts +92 -0
- package/dist/components/shared/ContactPicker.d.ts.map +1 -0
- package/dist/components/shared/PersonLink.d.ts +12 -1
- package/dist/components/shared/PersonLink.d.ts.map +1 -1
- package/dist/components/shared/create-person.d.ts +20 -0
- package/dist/components/shared/create-person.d.ts.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/crud/CrudFormPage.d.ts.map +1 -1
- package/dist/crud/CrudListView.d.ts.map +1 -1
- package/dist/crud/CrudPage.d.ts.map +1 -1
- package/dist/crud/archetypes/AddressesTab.d.ts +21 -0
- package/dist/crud/archetypes/AddressesTab.d.ts.map +1 -0
- package/dist/crud/archetypes/PersonDetailTabs.d.ts +2 -1
- package/dist/crud/archetypes/PersonDetailTabs.d.ts.map +1 -1
- package/dist/crud/createCrudPage.d.ts.map +1 -1
- package/dist/crud/index.d.ts +1 -0
- package/dist/crud/index.d.ts.map +1 -1
- package/dist/index.cjs +4300 -722
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +17 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4080 -542
- package/dist/index.js.map +1 -1
- package/dist/org/index.cjs +11 -11
- package/dist/org/index.js +2 -2
- package/dist/org/store.d.ts.map +1 -1
- package/dist/permissions/context.d.ts +2 -0
- package/dist/permissions/context.d.ts.map +1 -1
- package/dist/permissions/index.cjs +7 -7
- package/dist/permissions/index.js +1 -1
- package/dist/shell/components/auth/ProtectedRoute.d.ts.map +1 -1
- package/dist/shell/components/billing/SoftLimitBanner.d.ts +5 -0
- package/dist/shell/components/billing/SoftLimitBanner.d.ts.map +1 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts +5 -0
- package/dist/shell/components/billing/SubscriptionPage.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts +3 -0
- package/dist/shell/components/billing/UpgradeModal.d.ts.map +1 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts +10 -0
- package/dist/shell/components/billing/UpgradePrompt.d.ts.map +1 -0
- package/dist/shell/components/billing/access-contract.d.ts +18 -0
- package/dist/shell/components/billing/access-contract.d.ts.map +1 -0
- package/dist/shell/components/billing/gates.d.ts +14 -0
- package/dist/shell/components/billing/gates.d.ts.map +1 -0
- package/dist/shell/components/billing/index.d.ts +9 -2
- package/dist/shell/components/billing/index.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatFab.d.ts +3 -1
- package/dist/shell/components/chat/ChatFab.d.ts.map +1 -1
- package/dist/shell/components/chat/ChatPanel.d.ts +3 -1
- package/dist/shell/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/shell/components/chat/ConfirmActionCard.d.ts +13 -0
- package/dist/shell/components/chat/ConfirmActionCard.d.ts.map +1 -0
- package/dist/shell/components/chat/markdown.d.ts +5 -0
- package/dist/shell/components/chat/markdown.d.ts.map +1 -0
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TenantOnboarding.d.ts.map +1 -1
- package/dist/shell/components/organization/index.d.ts +0 -1
- package/dist/shell/components/organization/index.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedCompanySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedFieldRulesSettings.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedSecuritySettings.d.ts.map +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts +1 -1
- package/dist/shell/components/settings/LocationsCrudPage.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +23 -2
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/hooks/index.d.ts +3 -2
- package/dist/shell/hooks/index.d.ts.map +1 -1
- package/dist/shell/hooks/useAITools.d.ts +2 -0
- package/dist/shell/hooks/useAITools.d.ts.map +1 -1
- package/dist/shell/hooks/useChat.d.ts +22 -0
- package/dist/shell/hooks/useChat.d.ts.map +1 -1
- package/dist/shell/hooks/useSystemPermission.d.ts +3 -0
- package/dist/shell/hooks/useSystemPermission.d.ts.map +1 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts +12 -0
- package/dist/shell/hooks/useTenantPluginSettings.d.ts.map +1 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts +119 -0
- package/dist/shell/lib/ai-tool-handlers.d.ts.map +1 -0
- package/dist/shell/lib/core-ai-tools.d.ts +14 -0
- package/dist/shell/lib/core-ai-tools.d.ts.map +1 -1
- package/dist/shell/lib/create-client-orders-provider.d.ts.map +1 -1
- package/dist/shell/lib/fayz-agent.d.ts +23 -0
- package/dist/shell/lib/fayz-agent.d.ts.map +1 -0
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/stores/chat.store.d.ts +69 -0
- package/dist/shell/stores/chat.store.d.ts.map +1 -1
- package/dist/shell/types/billing.d.ts +7 -0
- package/dist/shell/types/billing.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +1 -1
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/dist/shell/types/plugins.d.ts +2 -45
- package/dist/shell/types/plugins.d.ts.map +1 -1
- package/dist/stores/createCrudStore.d.ts.map +1 -1
- package/package.json +15 -12
- package/src/access/context.tsx +228 -0
- package/src/access/headless.ts +71 -0
- package/src/access/index.ts +27 -0
- package/src/access/limits-registry.ts +86 -0
- package/src/access/module-nav.test.ts +78 -0
- package/src/access/module-nav.ts +82 -0
- package/src/access/resolver.test.ts +143 -0
- package/src/access/resolver.ts +5 -0
- package/src/access/types.ts +47 -0
- package/src/access/upgrade-modal-store.ts +30 -0
- package/src/app/AdminShell.tsx +451 -68
- package/src/app/admin-app.tsx +46 -9
- package/src/app/config.ts +54 -1
- package/src/app/core-entities.ts +110 -0
- package/src/app/derive-contract.test.ts +155 -0
- package/src/app/derive-contract.ts +214 -0
- package/src/app/scaffold.tsx +4 -0
- package/src/archetype-lookup.ts +8 -2
- package/src/billing/index.ts +2 -0
- package/src/billing/plan-badge.ts +32 -0
- package/src/billing/store.ts +8 -1
- package/src/components/shared/ContactPicker.tsx +394 -0
- package/src/components/shared/PersonLink.tsx +11 -3
- package/src/components/shared/create-person.ts +74 -0
- package/src/crud/CrudDetailPage.tsx +8 -1
- package/src/crud/CrudFormPage.tsx +124 -6
- package/src/crud/CrudListView.tsx +16 -1
- package/src/crud/CrudPage.tsx +27 -5
- package/src/crud/DeleteConfirmDialog.tsx +1 -1
- package/src/crud/archetypes/AddressesTab.tsx +387 -0
- package/src/crud/archetypes/PersonDetailTabs.tsx +5 -0
- package/src/crud/createCrudPage.tsx +2 -0
- package/src/crud/index.ts +6 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +49 -1
- package/src/org/store.ts +9 -1
- package/src/permissions/context.tsx +29 -15
- package/src/shell/components/auth/ProtectedRoute.tsx +12 -2
- package/src/shell/components/billing/SoftLimitBanner.tsx +81 -0
- package/src/shell/components/billing/SubscriptionPage.tsx +372 -0
- package/src/shell/components/billing/UpgradeModal.tsx +102 -0
- package/src/shell/components/billing/UpgradePrompt.tsx +229 -0
- package/src/shell/components/billing/access-contract.ts +64 -0
- package/src/shell/components/billing/gates.tsx +62 -0
- package/src/shell/components/billing/index.ts +18 -2
- package/src/shell/components/chat/ChatFab.tsx +4 -2
- package/src/shell/components/chat/ChatPanel.tsx +174 -23
- package/src/shell/components/chat/ConfirmActionCard.tsx +57 -0
- package/src/shell/components/chat/markdown.tsx +102 -0
- package/src/shell/components/organization/TeamTab.tsx +21 -4
- package/src/shell/components/organization/TenantOnboarding.tsx +3 -1
- package/src/shell/components/organization/index.ts +0 -1
- package/src/shell/components/settings/ConnectedCompanySettings.tsx +4 -1
- package/src/shell/components/settings/ConnectedFieldRulesSettings.tsx +3 -1
- package/src/shell/components/settings/ConnectedSecuritySettings.tsx +3 -1
- package/src/shell/components/settings/LocationsCrudPage.ts +6 -1
- package/src/shell/components/settings/SettingsPage.tsx +63 -21
- package/src/shell/hooks/index.ts +3 -2
- package/src/shell/hooks/useAITools.ts +27 -23
- package/src/shell/hooks/useChat.ts +381 -16
- package/src/shell/hooks/useSystemPermission.ts +23 -0
- package/src/shell/hooks/useTenantPluginSettings.ts +127 -0
- package/src/shell/lib/ai-tool-handlers.ts +910 -0
- package/src/shell/lib/core-ai-tools.ts +234 -2
- package/src/shell/lib/create-client-orders-provider.ts +24 -2
- package/src/shell/lib/fayz-agent.ts +56 -0
- package/src/shell/lib/i18n.ts +71 -0
- package/src/shell/stores/chat.store.ts +118 -1
- package/src/shell/styles.css +30 -0
- package/src/shell/types/billing.ts +8 -0
- package/src/shell/types/crud.ts +1 -1
- package/src/shell/types/plugins.ts +13 -50
- package/src/stores/createCrudStore.ts +9 -0
- package/dist/chunk-3M25GRRU.js.map +0 -1
- package/dist/chunk-4XTQ4V5L.cjs.map +0 -1
- package/dist/chunk-5GYQK5IW.js +0 -21
- package/dist/chunk-5GYQK5IW.js.map +0 -1
- package/dist/chunk-IPWCHJPE.cjs.map +0 -1
- package/dist/chunk-KYID2MR6.cjs +0 -23
- package/dist/chunk-KYID2MR6.cjs.map +0 -1
- package/dist/chunk-XNWCGJYH.js.map +0 -1
- package/dist/shell/components/billing/BillingPage.d.ts +0 -7
- package/dist/shell/components/billing/BillingPage.d.ts.map +0 -1
- package/dist/shell/components/billing/PaywallGate.d.ts +0 -11
- package/dist/shell/components/billing/PaywallGate.d.ts.map +0 -1
- package/dist/shell/components/organization/PermissionGate.d.ts +0 -11
- package/dist/shell/components/organization/PermissionGate.d.ts.map +0 -1
- package/dist/shell/hooks/useBilling.d.ts +0 -14
- package/dist/shell/hooks/useBilling.d.ts.map +0 -1
- package/dist/shell/hooks/usePermission.d.ts +0 -16
- package/dist/shell/hooks/usePermission.d.ts.map +0 -1
- package/src/shell/components/billing/BillingPage.tsx +0 -56
- package/src/shell/components/billing/PaywallGate.tsx +0 -80
- package/src/shell/components/organization/PermissionGate.tsx +0 -16
- package/src/shell/hooks/useBilling.ts +0 -151
- package/src/shell/hooks/usePermission.ts +0 -101
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { AlertTriangle, X } from 'lucide-react'
|
|
3
|
+
import { Button } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '../../hooks/useTranslation'
|
|
5
|
+
import { navigateTo } from '../../../app/routing'
|
|
6
|
+
import { useLimit, type LimitInfo } from './access-contract'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// SoftLimitBanner — discreet, persistent notice at the top of the content when
|
|
10
|
+
// a monitored limit is EXCEEDED (used > max), not merely reached. This is the
|
|
11
|
+
// Notion-style soft-limit path: the public booking flow (storefront
|
|
12
|
+
// createCustomer) never blocks the end client, so a tenant can overshoot its cap
|
|
13
|
+
// — the admin sees this banner nudging an upgrade. Dismissable for the session.
|
|
14
|
+
//
|
|
15
|
+
// `keys` are the limit keys to monitor; the shell derives them from
|
|
16
|
+
// `manifest.billing.softLimitKeys` (documented seam). Each key mounts a probe so
|
|
17
|
+
// hooks stay unconditional even as the list is config-driven.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function LimitProbe({ limitKey, onReport }: { limitKey: string; onReport: (key: string, info: LimitInfo) => void }) {
|
|
21
|
+
const info = useLimit(limitKey)
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
onReport(limitKey, info)
|
|
24
|
+
}, [limitKey, info, onReport])
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SoftLimitBanner({ keys }: { keys: string[] }) {
|
|
29
|
+
const { t } = useTranslation()
|
|
30
|
+
const tr = (key: string, fallback: string, params?: Record<string, string | number>) => {
|
|
31
|
+
let v = t(key)
|
|
32
|
+
if (!v || v === key) v = fallback
|
|
33
|
+
if (params) for (const [k, val] of Object.entries(params)) v = v.replace(`{${k}}`, String(val)).replace(`{{${k}}}`, String(val))
|
|
34
|
+
return v
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [over, setOver] = React.useState<Record<string, boolean>>({})
|
|
38
|
+
const [dismissed, setDismissed] = React.useState<Record<string, boolean>>({})
|
|
39
|
+
|
|
40
|
+
const report = React.useCallback((key: string, info: LimitInfo) => {
|
|
41
|
+
const isOver = !info.unlimited && !info.loading && info.used > info.max
|
|
42
|
+
setOver((prev) => {
|
|
43
|
+
if (isOver === Boolean(prev[key])) return prev
|
|
44
|
+
return { ...prev, [key]: isOver }
|
|
45
|
+
})
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
// Stable key list (avoid remounting probes each render).
|
|
49
|
+
const monitored = React.useMemo(() => Array.from(new Set(keys)), [keys])
|
|
50
|
+
|
|
51
|
+
const activeKey = monitored.find((k) => over[k] && !dismissed[k])
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{monitored.map((k) => (
|
|
56
|
+
<LimitProbe key={k} limitKey={k} onReport={report} />
|
|
57
|
+
))}
|
|
58
|
+
{activeKey && (
|
|
59
|
+
<div className="mb-3 flex flex-wrap items-center gap-3 rounded-lg border border-amber-400/50 bg-amber-50/70 px-4 py-2.5 text-sm dark:bg-amber-950/25">
|
|
60
|
+
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
61
|
+
<span className="min-w-0 flex-1 text-foreground">
|
|
62
|
+
{tr('banner.overLimit', "You've exceeded the {label} limit of your plan — upgrade to keep growing.", {
|
|
63
|
+
label: tr(`limit.label.${activeKey}`, activeKey),
|
|
64
|
+
})}
|
|
65
|
+
</span>
|
|
66
|
+
<Button size="sm" onClick={() => navigateTo('/settings/subscription')}>
|
|
67
|
+
{tr('upgrade.cta', 'View plans')}
|
|
68
|
+
</Button>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
aria-label={tr('common.close', 'Close')}
|
|
72
|
+
className="rounded p-1 text-muted-foreground transition-colors hover:bg-amber-400/20 hover:text-foreground"
|
|
73
|
+
onClick={() => setDismissed((prev) => ({ ...prev, [activeKey]: true }))}
|
|
74
|
+
>
|
|
75
|
+
<X className="h-4 w-4" />
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -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
|
+
}
|