feat: centralize Pro feature definitions into a single registry

- Create shared/feature-registry.ts as the single source of truth
- Define 16 features across 6 categories with typed CellValue per plan
- Derive ProFeature union type from as-const registry (type-safe)
- Add 4 Coming Soon Pro features (audit log, webhooks, SSO, analytics)
- Rewrite ComparisonTable to render from registry with category grouping
- Merge 'Up to 3 Teams' + 'Teams Unlimited' into one quota-diff row
- Add i18n keys for all features and categories (en + zh)
- Remove orphaned ProFeatures const from shared/constants.ts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
saltbo
2026-04-25 08:46:05 -04:00
co-authored by Copilot
parent b40b2bf46b
commit b179db9efd
6 changed files with 297 additions and 98 deletions
-9
View File
@@ -43,15 +43,6 @@ export const SignupMode = {
export type SignupMode = (typeof SignupMode)[keyof typeof SignupMode]
export const ProFeatures = {
WHITE_LABEL: 'white_label',
OPEN_REGISTRATION: 'open_registration',
TEAMS_UNLIMITED: 'teams_unlimited',
TEAM_QUOTAS: 'team_quotas',
} as const
export type ProFeatures = (typeof ProFeatures)[keyof typeof ProFeatures]
export const ZPAN_CLOUD_URL_DEFAULT = 'https://cloud.zpan.space'
// Community plan allows up to this many organizations per user (including personal workspace).
+182
View File
@@ -0,0 +1,182 @@
import { COMMUNITY_TEAM_LIMIT } from './constants'
// ---------------------------------------------------------------------------
// Cell values for the comparison table
// ---------------------------------------------------------------------------
/** A cell in the comparison table: boolean (✅/❌) or a structured i18n label. */
export type CellValue = boolean | { i18nKey: string; params?: Record<string, unknown> }
// ---------------------------------------------------------------------------
// Feature categories
// ---------------------------------------------------------------------------
export const FEATURE_CATEGORIES = ['storage', 'auth', 'teams', 'sharing', 'operator', 'deployment'] as const
export type FeatureCategory = (typeof FEATURE_CATEGORIES)[number]
/** i18n key for each category header. */
export const CATEGORY_I18N: Record<FeatureCategory, string> = {
storage: 'features.category.storage',
auth: 'features.category.auth',
teams: 'features.category.teams',
sharing: 'features.category.sharing',
operator: 'features.category.operator',
deployment: 'features.category.deployment',
}
// ---------------------------------------------------------------------------
// Feature definition
// ---------------------------------------------------------------------------
export interface FeatureDefinition {
/** i18n key for the feature name shown in the comparison table. */
i18nKey: string
/** Which section this feature belongs to. */
category: FeatureCategory
/** What Community plan gets — true (included), false (not included), or structured value. */
community: CellValue
/** What Pro plan gets — true (included), false (not included), or structured value. */
pro: CellValue
/**
* If present, this feature is a real entitlement gate enforced at runtime.
* The value must match a key in the PRO_GATE_KEYS tuple.
*/
gateKey?: string
/** Feature is planned but not yet implemented; shown with a "Coming Soon" badge. */
comingSoon?: boolean
}
// ---------------------------------------------------------------------------
// Registry — single source of truth
// ---------------------------------------------------------------------------
export const FEATURE_REGISTRY = [
// ── Storage & Files ─────────────────────────────────────────────────
{
i18nKey: 'features.coreFileManagement',
category: 'storage',
community: true,
pro: true,
},
// ── Auth & Access ───────────────────────────────────────────────────
{
i18nKey: 'features.socialLoginOidc',
category: 'auth',
community: true,
pro: true,
},
{
i18nKey: 'features.inviteCodes',
category: 'auth',
community: true,
pro: true,
},
{
i18nKey: 'features.openRegistration',
category: 'auth',
community: false,
pro: true,
gateKey: 'open_registration',
},
{
i18nKey: 'features.multiIdpSso',
category: 'auth',
community: false,
pro: true,
comingSoon: true,
},
{
i18nKey: 'features.ldapScim',
category: 'auth',
community: false,
pro: true,
comingSoon: true,
},
// ── Teams ───────────────────────────────────────────────────────────
{
i18nKey: 'features.teamWorkspaces',
category: 'teams',
community: { i18nKey: 'features.teamWorkspaces.limit', params: { count: COMMUNITY_TEAM_LIMIT } },
pro: { i18nKey: 'features.teamWorkspaces.unlimited' },
gateKey: 'teams_unlimited',
},
{
i18nKey: 'features.teamQuotas',
category: 'teams',
community: false,
pro: true,
gateKey: 'team_quotas',
},
// ── Sharing & Image Hosting ─────────────────────────────────────────
{
i18nKey: 'features.shareLinks',
category: 'sharing',
community: true,
pro: true,
},
{
i18nKey: 'features.imageHosting',
category: 'sharing',
community: true,
pro: true,
},
// ── Operator Tools ──────────────────────────────────────────────────
{
i18nKey: 'features.whiteLabel',
category: 'operator',
community: false,
pro: true,
gateKey: 'white_label',
},
{
i18nKey: 'features.auditLog',
category: 'operator',
community: false,
pro: true,
comingSoon: true,
},
{
i18nKey: 'features.webhooks',
category: 'operator',
community: false,
pro: true,
comingSoon: true,
},
{
i18nKey: 'features.analytics',
category: 'operator',
community: false,
pro: true,
comingSoon: true,
},
// ── Deployment ──────────────────────────────────────────────────────
{
i18nKey: 'features.allDeployments',
category: 'deployment',
community: true,
pro: true,
},
] as const satisfies readonly FeatureDefinition[]
// ---------------------------------------------------------------------------
// Derived types — ProFeature union from gateKey literals
// ---------------------------------------------------------------------------
type GatedFeature = Extract<(typeof FEATURE_REGISTRY)[number], { gateKey: string }>
/**
* Union of all active entitlement gate keys.
* Automatically stays in sync with the registry.
*/
export type ProFeature = GatedFeature['gateKey']
/** Runtime array of all active gate keys (for validation / iteration). */
export const PRO_GATE_KEYS = FEATURE_REGISTRY.filter(
(f): f is (typeof FEATURE_REGISTRY)[number] & { gateKey: string } => 'gateKey' in f && f.gateKey != null,
).map((f) => f.gateKey as ProFeature)
+3 -1
View File
@@ -1,4 +1,6 @@
export type ProFeature = 'white_label' | 'open_registration' | 'teams_unlimited' | 'team_quotas'
import type { ProFeature } from '../feature-registry'
export type { ProFeature } from '../feature-registry'
export interface LicenseEntitlement {
account_id: string
+64 -70
View File
@@ -1,17 +1,34 @@
import { Check, Minus } from 'lucide-react'
import {
CATEGORY_I18N,
type CellValue,
FEATURE_CATEGORIES,
FEATURE_REGISTRY,
type FeatureCategory,
} from '@shared/feature-registry'
import { Check, Clock, Minus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
interface FeatureRow {
label: string
community: boolean | string
pro: boolean | string
}
function FeatureCell({ value }: { value: boolean | string }) {
if (typeof value === 'string') {
return <span className="text-sm text-muted-foreground">{value}</span>
function FeatureCell({
value,
comingSoon,
t,
}: {
value: CellValue
comingSoon?: boolean
t: (key: string, params?: Record<string, unknown>) => string
}) {
if (comingSoon) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
{t('settings.billing.comparison.comingSoon')}
</span>
)
}
if (typeof value === 'object') {
return <span className="text-sm text-muted-foreground">{t(value.i18nKey, value.params)}</span>
}
return value ? (
<Check className="size-4 text-primary" aria-label="Included" />
@@ -23,53 +40,14 @@ function FeatureCell({ value }: { value: boolean | string }) {
export function ComparisonTable() {
const { t } = useTranslation()
const rows: FeatureRow[] = [
{
label: t('settings.billing.comparison.coreFeatures'),
community: true,
pro: true,
const grouped = FEATURE_CATEGORIES.reduce(
(acc, cat) => {
const features = FEATURE_REGISTRY.filter((f) => f.category === cat)
if (features.length > 0) acc.push({ category: cat, features })
return acc
},
{
label: t('settings.billing.comparison.singleIdp'),
community: true,
pro: true,
},
{
label: t('settings.billing.comparison.inviteCodes'),
community: true,
pro: true,
},
{
label: t('settings.billing.comparison.allDeployments'),
community: true,
pro: true,
},
{
label: t('settings.billing.comparison.upTo3Teams'),
community: true,
pro: true,
},
{
label: t('settings.billing.comparison.teamsUnlimited'),
community: false,
pro: true,
},
{
label: t('settings.billing.comparison.openRegistration'),
community: false,
pro: true,
},
{
label: t('settings.billing.comparison.teamQuotas'),
community: false,
pro: true,
},
{
label: t('settings.billing.comparison.whiteLabel'),
community: false,
pro: true,
},
]
[] as { category: FeatureCategory; features: (typeof FEATURE_REGISTRY)[number][] }[],
)
return (
<Card>
@@ -88,20 +66,36 @@ export function ComparisonTable() {
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.label}>
<TableCell className="pl-6 font-medium">{row.label}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<FeatureCell value={row.community} />
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<FeatureCell value={row.pro} />
</div>
</TableCell>
</TableRow>
{grouped.map(({ category, features }) => (
<>
<TableRow key={`cat-${category}`}>
<TableCell
colSpan={3}
className="bg-muted/50 pl-6 font-semibold text-xs uppercase tracking-wide text-muted-foreground"
>
{t(CATEGORY_I18N[category])}
</TableCell>
</TableRow>
{features.map((feature) => (
<TableRow key={feature.i18nKey}>
<TableCell className="pl-8 font-medium">{t(feature.i18nKey)}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<FeatureCell value={feature.community} t={t} />
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<FeatureCell
value={feature.pro}
comingSoon={'comingSoon' in feature && feature.comingSoon}
t={t}
/>
</div>
</TableCell>
</TableRow>
))}
</>
))}
</TableBody>
</Table>
+24 -9
View File
@@ -676,15 +676,30 @@
"settings.billing.comparison.feature": "Feature",
"settings.billing.comparison.community": "Community",
"settings.billing.comparison.pro": "Pro",
"settings.billing.comparison.whiteLabel": "White-label branding",
"settings.billing.comparison.openRegistration": "Open registration",
"settings.billing.comparison.teamsUnlimited": "Unlimited teams",
"settings.billing.comparison.teamQuotas": "Per-team storage quotas",
"settings.billing.comparison.coreFeatures": "All core features (sharing, image hosting, S3 storage)",
"settings.billing.comparison.upTo3Teams": "Up to 3 teams",
"settings.billing.comparison.singleIdp": "Social login & OIDC (single IdP)",
"settings.billing.comparison.inviteCodes": "Invite codes",
"settings.billing.comparison.allDeployments": "All 7 deployment targets",
"settings.billing.comparison.comingSoon": "Coming Soon",
"features.category.storage": "Storage & Files",
"features.category.auth": "Auth & Access",
"features.category.teams": "Teams",
"features.category.sharing": "Sharing & Image Hosting",
"features.category.operator": "Operator Tools",
"features.category.deployment": "Deployment",
"features.coreFileManagement": "File management (upload, download, browse, search)",
"features.socialLoginOidc": "Social login & OIDC (single IdP)",
"features.inviteCodes": "Invite codes",
"features.openRegistration": "Open registration + anti-abuse",
"features.multiIdpSso": "Multi-IdP SSO + SAML",
"features.ldapScim": "LDAP / SCIM provisioning",
"features.teamWorkspaces": "Team workspaces",
"features.teamWorkspaces.limit": "≤ {{count}}",
"features.teamWorkspaces.unlimited": "Unlimited",
"features.teamQuotas": "Per-team storage quotas",
"features.shareLinks": "Share links (password, expiration, download limits)",
"features.imageHosting": "Image hosting + PicGo / uPic / ShareX + custom domain",
"features.whiteLabel": "White-label branding",
"features.auditLog": "Audit log",
"features.webhooks": "Webhook notifications",
"features.analytics": "Analytics dashboard",
"features.allDeployments": "All 7 deployment targets",
"settings.billing.pairing.title": "Connect to ZPan Cloud",
"settings.billing.pairing.description": "Follow the steps below to bind this instance to your ZPan Cloud account.",
"settings.billing.pairing.step1": "1. Visit",
+24 -9
View File
@@ -676,15 +676,30 @@
"settings.billing.comparison.feature": "功能",
"settings.billing.comparison.community": "社区版",
"settings.billing.comparison.pro": "专业版",
"settings.billing.comparison.whiteLabel": "白标品牌",
"settings.billing.comparison.openRegistration": "开放注册",
"settings.billing.comparison.teamsUnlimited": "无限团队",
"settings.billing.comparison.teamQuotas": "按团队存储配额",
"settings.billing.comparison.coreFeatures": "全部核心功能(分享、图床、S3 存储)",
"settings.billing.comparison.upTo3Teams": "最多 3 个团队",
"settings.billing.comparison.singleIdp": "社交登录 & OIDC(单 IdP)",
"settings.billing.comparison.inviteCodes": "邀请码",
"settings.billing.comparison.allDeployments": "全部 7 种部署方式",
"settings.billing.comparison.comingSoon": "即将推出",
"features.category.storage": "存储与文件",
"features.category.auth": "认证与访问",
"features.category.teams": "团队",
"features.category.sharing": "分享与图床",
"features.category.operator": "运营工具",
"features.category.deployment": "部署",
"features.coreFileManagement": "文件管理(上传、下载、浏览、搜索)",
"features.socialLoginOidc": "社交登录 & OIDC(单 IdP)",
"features.inviteCodes": "邀请码",
"features.openRegistration": "开放注册 + 反滥用",
"features.multiIdpSso": "多 IdP SSO + SAML",
"features.ldapScim": "LDAP / SCIM 用户同步",
"features.teamWorkspaces": "团队工作区",
"features.teamWorkspaces.limit": "≤ {{count}} 个",
"features.teamWorkspaces.unlimited": "无限",
"features.teamQuotas": "按团队存储配额",
"features.shareLinks": "分享链接(密码、有效期、下载限制)",
"features.imageHosting": "图床 + PicGo / uPic / ShareX + 自定义域名",
"features.whiteLabel": "白标品牌",
"features.auditLog": "审计日志",
"features.webhooks": "Webhook 通知",
"features.analytics": "数据分析面板",
"features.allDeployments": "全部 7 种部署方式",
"settings.billing.pairing.title": "连接 ZPan Cloud",
"settings.billing.pairing.description": "按照以下步骤将此实例绑定到您的 ZPan Cloud 账户。",
"settings.billing.pairing.step1": "1. 访问",