mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
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:
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. 访问",
|
||||
|
||||
Reference in New Issue
Block a user