From b179db9efdbf314c76cc2e208aad3417416d00b0 Mon Sep 17 00:00:00 2001 From: saltbo Date: Sat, 25 Apr 2026 08:46:05 -0400 Subject: [PATCH] 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> --- shared/constants.ts | 9 - shared/feature-registry.ts | 182 +++++++++++++++++++++ shared/types/licensing.ts | 4 +- src/components/billing/ComparisonTable.tsx | 134 ++++++++------- src/i18n/locales/en.json | 33 +++- src/i18n/locales/zh.json | 33 +++- 6 files changed, 297 insertions(+), 98 deletions(-) create mode 100644 shared/feature-registry.ts diff --git a/shared/constants.ts b/shared/constants.ts index 8b347e6f..fa30266b 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -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). diff --git a/shared/feature-registry.ts b/shared/feature-registry.ts new file mode 100644 index 00000000..aece7fdf --- /dev/null +++ b/shared/feature-registry.ts @@ -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 } + +// --------------------------------------------------------------------------- +// 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 = { + 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) diff --git a/shared/types/licensing.ts b/shared/types/licensing.ts index 762dff0f..a824985c 100644 --- a/shared/types/licensing.ts +++ b/shared/types/licensing.ts @@ -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 diff --git a/src/components/billing/ComparisonTable.tsx b/src/components/billing/ComparisonTable.tsx index aeeac29f..36273981 100644 --- a/src/components/billing/ComparisonTable.tsx +++ b/src/components/billing/ComparisonTable.tsx @@ -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 {value} +function FeatureCell({ + value, + comingSoon, + t, +}: { + value: CellValue + comingSoon?: boolean + t: (key: string, params?: Record) => string +}) { + if (comingSoon) { + return ( + + + {t('settings.billing.comparison.comingSoon')} + + ) + } + if (typeof value === 'object') { + return {t(value.i18nKey, value.params)} } return value ? ( @@ -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 ( @@ -88,20 +66,36 @@ export function ComparisonTable() { - {rows.map((row) => ( - - {row.label} - -
- -
-
- -
- -
-
-
+ {grouped.map(({ category, features }) => ( + <> + + + {t(CATEGORY_I18N[category])} + + + {features.map((feature) => ( + + {t(feature.i18nKey)} + +
+ +
+
+ +
+ +
+
+
+ ))} + ))}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b466751e..9213ba79 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 289fc4b7..a59bbd5c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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. 访问",