feat(billing): enhance billing plan management with product and pricing configurations

- Added support for billing plan products and pricing settings, allowing for detailed configuration of subscription plans.
- Introduced new constants and types for managing billing plan overrides, products, and pricing.
- Updated the SystemSettingService to handle new billing-related settings and integrated them into the admin interface.
- Enhanced the BillingPlanService to retrieve and apply product and pricing information for tenant plans.
- Updated UI components to reflect the new billing plan configurations and ensure proper display of pricing details.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-17 01:48:35 +08:00
parent af3b5b9799
commit 99a57f920c
32 changed files with 2703 additions and 257 deletions

View File

@@ -25,6 +25,7 @@
"@afilmory/task-queue": "workspace:*", "@afilmory/task-queue": "workspace:*",
"@afilmory/utils": "workspace:*", "@afilmory/utils": "workspace:*",
"@aws-sdk/client-s3": "3.929.0", "@aws-sdk/client-s3": "3.929.0",
"@creem_io/better-auth": "0.0.8",
"@hono/node-server": "^1.19.6", "@hono/node-server": "^1.19.6",
"@resvg/resvg-js": "2.6.2", "@resvg/resvg-js": "2.6.2",
"better-auth": "1.3.34", "better-auth": "1.3.34",

View File

@@ -1,4 +1,12 @@
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils' import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
import {
BILLING_PLAN_DEFINITIONS,
BILLING_PLAN_IDS,
BILLING_PLAN_OVERRIDES_SETTING_KEY,
BILLING_PLAN_PRICING_SETTING_KEY,
BILLING_PLAN_PRODUCTS_SETTING_KEY,
} from 'core/modules/platform/billing/billing-plan.constants'
import type { BillingPlanId, BillingPlanQuota } from 'core/modules/platform/billing/billing-plan.types'
import { z } from 'zod' import { z } from 'zod'
const nonEmptyString = z.string().trim().min(1) const nonEmptyString = z.string().trim().min(1)
@@ -88,9 +96,74 @@ export const SYSTEM_SETTING_DEFINITIONS = {
defaultValue: null as string | null, defaultValue: null as string | null,
isSensitive: true, isSensitive: true,
}, },
billingPlanOverrides: {
key: BILLING_PLAN_OVERRIDES_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: {},
isSensitive: false,
},
billingPlanProducts: {
key: BILLING_PLAN_PRODUCTS_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: {},
isSensitive: false,
},
billingPlanPricing: {
key: BILLING_PLAN_PRICING_SETTING_KEY,
schema: z.record(z.string(), z.any()),
defaultValue: {},
isSensitive: false,
},
} as const } as const
export type SystemSettingField = keyof typeof SYSTEM_SETTING_DEFINITIONS const BILLING_PLAN_QUOTA_KEYS = [
export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingField]['key'] 'monthlyAssetProcessLimit',
'libraryItemLimit',
'maxUploadSizeMb',
'maxSyncObjectSizeMb',
] as const
export type BillingPlanQuotaFieldKey = (typeof BILLING_PLAN_QUOTA_KEYS)[number]
const BILLING_PLAN_PRICING_KEYS = ['monthlyPrice', 'currency'] as const
export type BillingPlanPricingFieldKey = (typeof BILLING_PLAN_PRICING_KEYS)[number]
const BILLING_PLAN_PAYMENT_KEYS = ['creemProductId'] as const
export type BillingPlanPaymentFieldKey = (typeof BILLING_PLAN_PAYMENT_KEYS)[number]
export type BillingPlanQuotaField = `billingPlan.${BillingPlanId}.quota.${BillingPlanQuotaFieldKey}`
export type BillingPlanPricingField = `billingPlan.${BillingPlanId}.pricing.${BillingPlanPricingFieldKey}`
export type BillingPlanPaymentField = `billingPlan.${BillingPlanId}.payment.${BillingPlanPaymentFieldKey}`
export type BillingPlanSettingField = BillingPlanQuotaField | BillingPlanPricingField | BillingPlanPaymentField
export type SystemSettingDbField = keyof typeof SYSTEM_SETTING_DEFINITIONS
export type SystemSettingField = SystemSettingDbField | BillingPlanSettingField
export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingDbField]['key']
export const BILLING_PLAN_FIELD_DESCRIPTORS = {
quotas: BILLING_PLAN_IDS.flatMap((planId) =>
BILLING_PLAN_QUOTA_KEYS.map((key) => ({
planId,
key,
field: `billingPlan.${planId}.quota.${key}` as BillingPlanQuotaField,
defaultValue: BILLING_PLAN_DEFINITIONS[planId].quotas[key as keyof BillingPlanQuota],
})),
),
pricing: BILLING_PLAN_IDS.flatMap((planId) =>
BILLING_PLAN_PRICING_KEYS.map((key) => ({
planId,
key,
field: `billingPlan.${planId}.pricing.${key}` as BillingPlanPricingField,
})),
),
payment: BILLING_PLAN_IDS.flatMap((planId) =>
BILLING_PLAN_PAYMENT_KEYS.map((key) => ({
planId,
key,
field: `billingPlan.${planId}.payment.${key}` as BillingPlanPaymentField,
})),
),
} as const
export const SYSTEM_SETTING_KEYS = Object.values(SYSTEM_SETTING_DEFINITIONS).map((definition) => definition.key) export const SYSTEM_SETTING_KEYS = Object.values(SYSTEM_SETTING_DEFINITIONS).map((definition) => definition.key)

View File

@@ -2,20 +2,37 @@ import { authUsers } from '@afilmory/db'
import { DbAccessor } from 'core/database/database.provider' import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors' import { BizException, ErrorCode } from 'core/errors'
import type { SocialProvidersConfig } from 'core/modules/platform/auth/auth.config' import type { SocialProvidersConfig } from 'core/modules/platform/auth/auth.config'
import { BILLING_PLAN_OVERRIDES_SETTING_KEY } from 'core/modules/platform/billing/billing-plan.constants' import {
import type { BillingPlanOverrides } from 'core/modules/platform/billing/billing-plan.types' BILLING_PLAN_OVERRIDES_SETTING_KEY,
BILLING_PLAN_PRICING_SETTING_KEY,
BILLING_PLAN_PRODUCTS_SETTING_KEY,
} from 'core/modules/platform/billing/billing-plan.constants'
import type {
BillingPlanId,
BillingPlanOverrides,
BillingPlanPaymentInfo,
BillingPlanPricing,
BillingPlanPricingConfigs,
BillingPlanProductConfigs,
BillingPlanQuota,
} from 'core/modules/platform/billing/billing-plan.types'
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { injectable } from 'tsyringe' import { injectable } from 'tsyringe'
import type {ZodType} from 'zod'; import type { ZodType } from 'zod'
import { z } from 'zod' import { z } from 'zod'
import { SYSTEM_SETTING_DEFINITIONS, SYSTEM_SETTING_KEYS } from './system-setting.constants' import type { SystemSettingDbField } from './system-setting.constants'
import {
BILLING_PLAN_FIELD_DESCRIPTORS,
SYSTEM_SETTING_DEFINITIONS,
SYSTEM_SETTING_KEYS,
} from './system-setting.constants'
import { SystemSettingStore } from './system-setting.store.service' import { SystemSettingStore } from './system-setting.store.service'
import type { import type {
SystemSettingField,
SystemSettingOverview, SystemSettingOverview,
SystemSettings, SystemSettings,
SystemSettingStats, SystemSettingStats,
SystemSettingValueMap,
UpdateSystemSettingsInput, UpdateSystemSettingsInput,
} from './system-setting.types' } from './system-setting.types'
import { SYSTEM_SETTING_UI_SCHEMA } from './system-setting.ui-schema' import { SYSTEM_SETTING_UI_SCHEMA } from './system-setting.ui-schema'
@@ -41,21 +58,6 @@ export class SystemSettingService {
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.schema, SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.schema,
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue, SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue,
) )
const maxPhotoUploadSizeMb = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.key],
SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.schema,
SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.defaultValue,
)
const maxDataSyncObjectSizeMb = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.key],
SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.schema,
SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.defaultValue,
)
const maxPhotoLibraryItems = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.key],
SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.schema,
SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.defaultValue,
)
const localProviderEnabled = this.parseSetting( const localProviderEnabled = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.key], rawValues[SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.key],
@@ -99,12 +101,24 @@ export class SystemSettingService {
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.schema, SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue, SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
) )
const billingPlanOverrides = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanOverrides.key],
BILLING_PLAN_OVERRIDES_SCHEMA,
{},
) as BillingPlanOverrides
const billingPlanProducts = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanProducts.key],
BILLING_PLAN_PRODUCTS_SCHEMA,
{},
) as BillingPlanProductConfigs
const billingPlanPricing = this.parseSetting(
rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanPricing.key],
BILLING_PLAN_PRICING_SCHEMA,
{},
) as BillingPlanPricingConfigs
return { return {
allowRegistration, allowRegistration,
maxRegistrableUsers, maxRegistrableUsers,
maxPhotoUploadSizeMb,
maxDataSyncObjectSizeMb,
maxPhotoLibraryItems,
localProviderEnabled, localProviderEnabled,
baseDomain, baseDomain,
oauthGatewayUrl, oauthGatewayUrl,
@@ -112,28 +126,34 @@ export class SystemSettingService {
oauthGoogleClientSecret, oauthGoogleClientSecret,
oauthGithubClientId, oauthGithubClientId,
oauthGithubClientSecret, oauthGithubClientSecret,
billingPlanOverrides,
billingPlanProducts,
billingPlanPricing,
} }
} }
async getBillingPlanOverrides(): Promise<BillingPlanOverrides> { async getBillingPlanOverrides(): Promise<BillingPlanOverrides> {
const raw = await this.systemSettingStore.getRaw(BILLING_PLAN_OVERRIDES_SETTING_KEY) const settings = await this.getSettings()
const parsed = BILLING_PLAN_OVERRIDES_SCHEMA.safeParse(raw) return settings.billingPlanOverrides ?? {}
return parsed.success ? (parsed.data as BillingPlanOverrides) : {}
} }
async getStats(): Promise<SystemSettingStats> { async getBillingPlanProducts(): Promise<BillingPlanProductConfigs> {
const settings = await this.getSettings() const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount() return settings.billingPlanProducts ?? {}
return this.buildStats(settings, totalUsers) }
async getBillingPlanPricing(): Promise<BillingPlanPricingConfigs> {
const settings = await this.getSettings()
return settings.billingPlanPricing ?? {}
} }
async getOverview(): Promise<SystemSettingOverview> { async getOverview(): Promise<SystemSettingOverview> {
const values = await this.getSettings() const settings = await this.getSettings()
const totalUsers = await this.getTotalUserCount() const totalUsers = await this.getTotalUserCount()
const stats = this.buildStats(values, totalUsers) const stats = this.buildStats(settings, totalUsers)
return { return {
schema: SYSTEM_SETTING_UI_SCHEMA, schema: SYSTEM_SETTING_UI_SCHEMA,
values, values: this.buildValueMap(settings),
stats, stats,
} }
} }
@@ -144,11 +164,16 @@ export class SystemSettingService {
} }
const current = await this.getSettings() const current = await this.getSettings()
const updates: Array<{ field: SystemSettingField; value: SystemSettings[SystemSettingField] }> = [] const planFieldUpdates = this.extractPlanFieldUpdates(patch)
if (planFieldUpdates.hasUpdates) {
await this.applyPlanFieldUpdates(current, planFieldUpdates)
}
const enqueueUpdate = <K extends SystemSettingField>(field: K, value: SystemSettings[K]) => { const updates: Array<{ field: SystemSettingDbField; value: unknown }> = []
const enqueueUpdate = <K extends SystemSettingDbField>(field: K, value: unknown) => {
updates.push({ field, value }) updates.push({ field, value })
current[field] = value ;(current as unknown as Record<string, unknown>)[field] = value
} }
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) { if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
@@ -175,30 +200,6 @@ export class SystemSettingService {
} }
} }
if (patch.maxPhotoUploadSizeMb !== undefined) {
const normalized =
patch.maxPhotoUploadSizeMb === null ? null : Math.max(1, Math.trunc(patch.maxPhotoUploadSizeMb))
if (normalized !== current.maxPhotoUploadSizeMb) {
enqueueUpdate('maxPhotoUploadSizeMb', normalized)
}
}
if (patch.maxDataSyncObjectSizeMb !== undefined) {
const normalized =
patch.maxDataSyncObjectSizeMb === null ? null : Math.max(1, Math.trunc(patch.maxDataSyncObjectSizeMb))
if (normalized !== current.maxDataSyncObjectSizeMb) {
enqueueUpdate('maxDataSyncObjectSizeMb', normalized)
}
}
if (patch.maxPhotoLibraryItems !== undefined) {
const normalized =
patch.maxPhotoLibraryItems === null ? null : Math.max(0, Math.trunc(patch.maxPhotoLibraryItems))
if (normalized !== current.maxPhotoLibraryItems) {
enqueueUpdate('maxPhotoLibraryItems', normalized)
}
}
if (patch.baseDomain !== undefined) { if (patch.baseDomain !== undefined) {
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase() const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
if (!sanitized) { if (!sanitized) {
@@ -251,7 +252,7 @@ export class SystemSettingService {
const definition = SYSTEM_SETTING_DEFINITIONS[entry.field] const definition = SYSTEM_SETTING_DEFINITIONS[entry.field]
return { return {
key: definition.key, key: definition.key,
value: (entry.value ?? null) as SystemSettings[typeof entry.field] | null, value: (entry.value ?? null) as unknown,
options: { isSensitive: definition.isSensitive ?? false }, options: { isSensitive: definition.isSensitive ?? false },
} }
}), }),
@@ -260,6 +261,174 @@ export class SystemSettingService {
return current return current
} }
private buildValueMap(settings: SystemSettings): SystemSettingValueMap {
const map = {} as SystemSettingValueMap
;(Object.keys(SYSTEM_SETTING_DEFINITIONS) as SystemSettingDbField[]).forEach((field) => {
;(map as Record<string, unknown>)[field] = settings[field]
})
const overrides = settings.billingPlanOverrides ?? {}
BILLING_PLAN_FIELD_DESCRIPTORS.quotas.forEach((descriptor) => {
const planOverrides = overrides[descriptor.planId]
if (planOverrides && descriptor.key in (planOverrides as object)) {
;(map as Record<string, unknown>)[descriptor.field] =
(planOverrides as BillingPlanQuota)[descriptor.key as keyof BillingPlanQuota] ?? null
} else {
;(map as Record<string, unknown>)[descriptor.field] = descriptor.defaultValue ?? null
}
})
const pricing = settings.billingPlanPricing ?? {}
BILLING_PLAN_FIELD_DESCRIPTORS.pricing.forEach((descriptor) => {
const entry = pricing[descriptor.planId]
if (descriptor.key === 'currency') {
;(map as Record<string, unknown>)[descriptor.field] = entry?.currency ?? null
} else if (descriptor.key === 'monthlyPrice') {
;(map as Record<string, unknown>)[descriptor.field] = entry?.monthlyPrice ?? null
}
})
const products = settings.billingPlanProducts ?? {}
BILLING_PLAN_FIELD_DESCRIPTORS.payment.forEach((descriptor) => {
const entry = products[descriptor.planId]
;(map as Record<string, unknown>)[descriptor.field] = entry?.creemProductId ?? null
})
return map
}
private extractPlanFieldUpdates(patch: UpdateSystemSettingsInput): PlanFieldUpdateSummary {
const summary: PlanFieldUpdateSummary = {
hasUpdates: false,
quotas: {},
pricing: {},
products: {},
}
for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.quotas) {
if (!(descriptor.field in patch)) {
continue
}
summary.hasUpdates = true
const raw = patch[descriptor.field]
delete (patch as Record<string, unknown>)[descriptor.field]
const numericValue = raw === null || raw === undefined ? null : typeof raw === 'number' ? raw : Number(raw)
const planPatch = summary.quotas[descriptor.planId] ?? {}
planPatch[descriptor.key as keyof BillingPlanQuota] =
numericValue === null || Number.isNaN(numericValue) ? null : numericValue
summary.quotas[descriptor.planId] = planPatch
}
for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.pricing) {
if (!(descriptor.field in patch)) {
continue
}
summary.hasUpdates = true
const raw = patch[descriptor.field]
delete (patch as Record<string, unknown>)[descriptor.field]
const planPatch = summary.pricing[descriptor.planId] ?? {}
if (descriptor.key === 'currency') {
const normalized =
raw === null || raw === undefined
? null
: typeof raw === 'string'
? this.normalizeNullableString(raw)
: this.normalizeNullableString(String(raw))
planPatch.currency = normalized
} else if (descriptor.key === 'monthlyPrice') {
const numericValue = raw === null || raw === undefined ? null : typeof raw === 'number' ? raw : Number(raw)
planPatch.monthlyPrice = numericValue === null || Number.isNaN(numericValue) ? null : numericValue
}
summary.pricing[descriptor.planId] = planPatch
}
for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.payment) {
if (!(descriptor.field in patch)) {
continue
}
summary.hasUpdates = true
const raw = patch[descriptor.field]
delete (patch as Record<string, unknown>)[descriptor.field]
const normalized =
raw === null || raw === undefined
? null
: typeof raw === 'string'
? this.normalizeNullableString(raw)
: this.normalizeNullableString(String(raw))
summary.products[descriptor.planId] = { creemProductId: normalized }
}
return summary
}
private async applyPlanFieldUpdates(current: SystemSettings, updates: PlanFieldUpdateSummary): Promise<void> {
if (!updates.hasUpdates) {
return
}
if (Object.keys(updates.quotas).length > 0) {
const nextOverrides: BillingPlanOverrides = structuredClone(current.billingPlanOverrides ?? {})
for (const [planId, quotaPatch] of Object.entries(updates.quotas) as Array<[
BillingPlanId,
Partial<BillingPlanQuota>,
]>) {
const existing = { ...nextOverrides[planId] }
for (const [quotaKey, value] of Object.entries(quotaPatch) as Array<[keyof BillingPlanQuota, number | null]>) {
if (value === null || value === undefined || Number.isNaN(value)) {
delete existing[quotaKey]
} else {
existing[quotaKey] = value
}
}
if (Object.keys(existing).length === 0) {
delete nextOverrides[planId]
} else {
nextOverrides[planId] = existing
}
}
await this.systemSettingStore.set(BILLING_PLAN_OVERRIDES_SETTING_KEY, nextOverrides)
current.billingPlanOverrides = nextOverrides
}
if (Object.keys(updates.pricing).length > 0) {
const nextPricing: BillingPlanPricingConfigs = structuredClone(current.billingPlanPricing ?? {})
for (const [planId, pricingPatch] of Object.entries(updates.pricing) as Array<[
BillingPlanId,
Partial<BillingPlanPricing>,
]>) {
const entry: BillingPlanPricing = {
monthlyPrice: pricingPatch.monthlyPrice ?? null,
currency: pricingPatch.currency ?? null,
}
if (entry.monthlyPrice === null && !entry.currency) {
delete nextPricing[planId]
} else {
nextPricing[planId] = entry
}
}
await this.systemSettingStore.set(BILLING_PLAN_PRICING_SETTING_KEY, nextPricing)
current.billingPlanPricing = nextPricing
}
if (Object.keys(updates.products).length > 0) {
const nextProducts: BillingPlanProductConfigs = structuredClone(current.billingPlanProducts ?? {})
for (const [planId, product] of Object.entries(updates.products) as Array<[BillingPlanId, BillingPlanPaymentInfo]>) {
const normalized = this.normalizeNullableString(product.creemProductId)
if (!normalized) {
delete nextProducts[planId]
} else {
nextProducts[planId] = { creemProductId: normalized }
}
}
await this.systemSettingStore.set(BILLING_PLAN_PRODUCTS_SETTING_KEY, nextProducts)
current.billingPlanProducts = nextProducts
}
}
async ensureRegistrationAllowed(): Promise<void> { async ensureRegistrationAllowed(): Promise<void> {
const settings = await this.getSettings() const settings = await this.getSettings()
@@ -381,3 +550,27 @@ const PLAN_OVERRIDE_ENTRY_SCHEMA = z.object({
}) })
const BILLING_PLAN_OVERRIDES_SCHEMA = z.record(z.string(), PLAN_OVERRIDE_ENTRY_SCHEMA).default({}) const BILLING_PLAN_OVERRIDES_SCHEMA = z.record(z.string(), PLAN_OVERRIDE_ENTRY_SCHEMA).default({})
const PLAN_PRODUCT_ENTRY_SCHEMA = z.object({
creemProductId: z.string().trim().min(1).optional(),
})
const BILLING_PLAN_PRODUCTS_SCHEMA = z.record(z.string(), PLAN_PRODUCT_ENTRY_SCHEMA).default({})
const PLAN_PRICING_ENTRY_SCHEMA = z.object({
monthlyPrice: z.number().min(0).nullable().optional(),
currency: z.string().trim().min(1).nullable().optional(),
})
const BILLING_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({})
type PlanQuotaUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanQuota>>>
type PlanPricingUpdateMap = Partial<Record<BillingPlanId, Partial<BillingPlanPricing>>>
type PlanProductUpdateMap = Partial<Record<BillingPlanId, BillingPlanPaymentInfo>>
interface PlanFieldUpdateSummary {
hasUpdates: boolean
quotas: PlanQuotaUpdateMap
pricing: PlanPricingUpdateMap
products: PlanProductUpdateMap
}

View File

@@ -1,13 +1,15 @@
import type {
BillingPlanOverrides,
BillingPlanPricingConfigs,
BillingPlanProductConfigs,
} from 'core/modules/platform/billing/billing-plan.types'
import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants' import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants'
export interface SystemSettings { export interface SystemSettings {
allowRegistration: boolean allowRegistration: boolean
maxRegistrableUsers: number | null maxRegistrableUsers: number | null
maxPhotoUploadSizeMb: number | null
maxDataSyncObjectSizeMb: number | null
maxPhotoLibraryItems: number | null
localProviderEnabled: boolean localProviderEnabled: boolean
baseDomain: string baseDomain: string
oauthGatewayUrl: string | null oauthGatewayUrl: string | null
@@ -15,11 +17,14 @@ export interface SystemSettings {
oauthGoogleClientSecret: string | null oauthGoogleClientSecret: string | null
oauthGithubClientId: string | null oauthGithubClientId: string | null
oauthGithubClientSecret: string | null oauthGithubClientSecret: string | null
billingPlanOverrides: BillingPlanOverrides
billingPlanProducts: BillingPlanProductConfigs
billingPlanPricing: BillingPlanPricingConfigs
} }
export type SystemSettingValueMap = { export type SystemSettingValueMap = {
[K in SystemSettingField]: SystemSettings[K] [K in SystemSettingDbField]: SystemSettings[K]
} } & Partial<Record<BillingPlanSettingField, string | number | boolean | null>>
export interface SystemSettingStats { export interface SystemSettingStats {
totalUsers: number totalUsers: number
@@ -32,6 +37,7 @@ export interface SystemSettingOverview {
stats: SystemSettingStats stats: SystemSettingStats
} }
export type UpdateSystemSettingsInput = Partial<SystemSettings> export type UpdateSystemSettingsInput = Partial<SystemSettings> &
Partial<Record<BillingPlanSettingField, string | number | boolean | null | undefined>>
export { type SystemSettingField } from './system-setting.constants' export { type SystemSettingField } from './system-setting.constants'

View File

@@ -1,9 +1,113 @@
import { BILLING_PLAN_DEFINITIONS, BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type'
import type { SystemSettingField } from './system-setting.constants' import type { SystemSettingField } from './system-setting.constants'
export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.4.0' export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.4.0'
const PLAN_QUOTA_FIELDS = [
{
suffix: 'monthlyAssetProcessLimit',
title: '每月可新增照片 (张)',
description: '达到上限后将阻止新增照片。留空表示回退到默认值或不限。',
placeholder: '例如 300',
},
{
suffix: 'libraryItemLimit',
title: '图库容量限制 (张)',
description: '限制单个租户可管理的照片数量0 表示完全禁止新增。',
placeholder: '例如 500',
},
{
suffix: 'maxUploadSizeMb',
title: '后台上传大小上限 (MB)',
description: '单次上传的最大文件体积,留空表示默认值或无限制。',
placeholder: '例如 20',
},
{
suffix: 'maxSyncObjectSizeMb',
title: '同步素材大小上限 (MB)',
description: 'Data Sync 导入时允许的最大文件尺寸。',
placeholder: '例如 50',
},
] as const
const PLAN_PRICING_FIELDS = [
{
suffix: 'monthlyPrice',
title: '月度定价',
description: '用于展示的价格或 Creem 产品价格,留空保留默认值。',
placeholder: '例如 49',
},
{
suffix: 'currency',
title: '币种',
description: 'ISO 货币代码,如 CNY、USD 等。',
placeholder: 'CNY',
},
] as const
const PLAN_PAYMENT_FIELDS = [
{
suffix: 'creemProductId',
title: 'Creem Product ID',
description: '用于创建结算会话的 Creem 商品 ID。留空表示该计划不会显示升级入口。',
placeholder: 'prod_xxx',
},
] as const
const BILLING_PLAN_GROUPS: ReadonlyArray<UiNode<SystemSettingField>> = BILLING_PLAN_IDS.map((planId) => {
const metadata = BILLING_PLAN_DEFINITIONS[planId]
const quotaFields = PLAN_QUOTA_FIELDS.map((field) => ({
type: 'field' as const,
id: `${planId}-${field.suffix}`,
title: field.title,
description: field.description,
helperText: '留空表示遵循默认或不限,填写数字后将覆盖对应计划。',
key: `billingPlan.${planId}.quota.${field.suffix}` as SystemSettingField,
component: {
type: 'text' as const,
inputType: 'number' as const,
placeholder: field.placeholder,
},
}))
const pricingFields = PLAN_PRICING_FIELDS.map((field) => ({
type: 'field' as const,
id: `${planId}-pricing-${field.suffix}`,
title: field.title,
description: field.description,
helperText: field.suffix === 'monthlyPrice' ? '留空表示暂不展示定价信息。' : '留空表示使用默认币种或不展示。',
key: `billingPlan.${planId}.pricing.${field.suffix}` as SystemSettingField,
component: {
type: 'text' as const,
inputType: field.suffix === 'monthlyPrice' ? ('number' as const) : ('text' as const),
placeholder: field.placeholder,
},
}))
const paymentFields = PLAN_PAYMENT_FIELDS.map((field) => ({
type: 'field' as const,
id: `${planId}-payment-${field.suffix}`,
title: field.title,
description: field.description,
helperText: '为空将隐藏升级入口。',
key: `billingPlan.${planId}.payment.${field.suffix}` as SystemSettingField,
component: {
type: 'text' as const,
placeholder: field.placeholder,
},
}))
return {
type: 'group' as const,
id: `billing-plan-${planId}`,
title: `${metadata.name} (${planId})`,
description: metadata.description,
children: [...quotaFields, ...pricingFields, ...paymentFields],
} satisfies UiNode<SystemSettingField>
})
export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = { export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
version: SYSTEM_SETTING_UI_SCHEMA_VERSION, version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
title: '系统设置', title: '系统设置',
@@ -56,7 +160,7 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
helperText: '设置为 0 时将立即阻止新的用户注册。', helperText: '设置为 0 时将立即阻止新的用户注册。',
key: 'maxRegistrableUsers', key: 'maxRegistrableUsers',
component: { component: {
type: 'text', type: 'text' as const,
inputType: 'number', inputType: 'number',
placeholder: '无限制', placeholder: '无限制',
}, },
@@ -65,51 +169,11 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
}, },
{ {
type: 'section', type: 'section',
id: 'photo-constraints', id: 'billing-plan-settings',
title: '照片库资源限制', title: '订阅计划配置',
description: '统一设置照片上传、同步及照片总量的上限,确保资源消耗在可控范围内。', description: '为每个订阅计划定义资源限制、展示价格以及 Creem 商品映射。',
icon: 'image-up', icon: 'badge-dollar-sign',
children: [ children: BILLING_PLAN_GROUPS,
{
type: 'field',
id: 'photo-upload-max-size',
title: '单张上传大小上限 (MB)',
description: '限制用户通过后台上传的照片文件体积,超出限制将被拒绝。',
helperText: '留空表示不限制,最小值 1 MB。',
key: 'maxPhotoUploadSizeMb',
component: {
type: 'text',
inputType: 'number',
placeholder: '无限制',
},
},
{
type: 'field',
id: 'photo-sync-max-size',
title: 'Data Sync 单文件上限 (MB)',
description: '控制数据同步时允许导入的存储文件大小,避免超大素材拖慢同步。',
helperText: '留空表示不限制,最小值 1 MB。',
key: 'maxDataSyncObjectSizeMb',
component: {
type: 'text',
inputType: 'number',
placeholder: '无限制',
},
},
{
type: 'field',
id: 'photo-library-max-items',
title: '单租户可管理照片数量',
description: '达到上限后用户无法再新增图片,可留空表示不限制。',
helperText: '设置为 0 将阻止任何新增图片。',
key: 'maxPhotoLibraryItems',
component: {
type: 'text',
inputType: 'number',
placeholder: '无限制',
},
},
],
}, },
{ {
type: 'section', type: 'section',

View File

@@ -12,7 +12,6 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } f
import { EventEmitterService } from '@afilmory/framework' import { EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider' import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors' import { BizException, ErrorCode } from 'core/errors'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { runWithBuilderLogRelay } from 'core/modules/infrastructure/data-sync/builder-log-relay' import { runWithBuilderLogRelay } from 'core/modules/infrastructure/data-sync/builder-log-relay'
import type { import type {
DataSyncAction, DataSyncAction,
@@ -94,7 +93,6 @@ export class PhotoAssetService {
private readonly dbAccessor: DbAccessor, private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService, private readonly photoBuilderService: PhotoBuilderService,
private readonly photoStorageService: PhotoStorageService, private readonly photoStorageService: PhotoStorageService,
private readonly systemSettingService: SystemSettingService,
private readonly billingPlanService: BillingPlanService, private readonly billingPlanService: BillingPlanService,
private readonly billingUsageService: BillingUsageService, private readonly billingUsageService: BillingUsageService,
) {} ) {}
@@ -264,9 +262,8 @@ export class PhotoAssetService {
const tenant = requireTenantContext() const tenant = requireTenantContext()
const db = this.dbAccessor.get() const db = this.dbAccessor.get()
const systemSettings = await this.systemSettingService.getSettings()
const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id) const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id)
const uploadSizeLimit = planQuota.maxUploadSizeMb ?? systemSettings.maxPhotoUploadSizeMb const uploadSizeLimit = planQuota.maxUploadSizeMb
this.enforceUploadSizeLimit(inputs, uploadSizeLimit) this.enforceUploadSizeLimit(inputs, uploadSizeLimit)
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
@@ -333,7 +330,7 @@ export class PhotoAssetService {
const pendingPhotoPlans = photoPlans.filter((plan) => !existingPhotoKeySet.has(plan.storageKey)) const pendingPhotoPlans = photoPlans.filter((plan) => !existingPhotoKeySet.has(plan.storageKey))
await this.billingPlanService.ensurePhotoProcessingAllowance(tenant.tenant.id, pendingPhotoPlans.length) await this.billingPlanService.ensurePhotoProcessingAllowance(tenant.tenant.id, pendingPhotoPlans.length)
const libraryLimit = planQuota.libraryItemLimit ?? systemSettings.maxPhotoLibraryItems const libraryLimit = planQuota.libraryItemLimit
await this.ensurePhotoLibraryCapacity(tenant.tenant.id, db, pendingPhotoPlans.length, libraryLimit) await this.ensurePhotoLibraryCapacity(tenant.tenant.id, db, pendingPhotoPlans.length, libraryLimit)
throwIfAborted() throwIfAborted()

View File

@@ -4,7 +4,6 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, ph
import { createLogger, EventEmitterService } from '@afilmory/framework' import { createLogger, EventEmitterService } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider' import { DbAccessor } from 'core/database/database.provider'
import { BizException, ErrorCode } from 'core/errors' import { BizException, ErrorCode } from 'core/errors'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants' import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants'
@@ -78,7 +77,6 @@ export class DataSyncService {
private readonly dbAccessor: DbAccessor, private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService, private readonly photoBuilderService: PhotoBuilderService,
private readonly photoStorageService: PhotoStorageService, private readonly photoStorageService: PhotoStorageService,
private readonly systemSettingService: SystemSettingService,
private readonly billingPlanService: BillingPlanService, private readonly billingPlanService: BillingPlanService,
private readonly billingUsageService: BillingUsageService, private readonly billingUsageService: BillingUsageService,
) {} ) {}
@@ -90,13 +88,12 @@ export class DataSyncService {
async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise<DataSyncResult> { async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise<DataSyncResult> {
const tenant = requireTenantContext() const tenant = requireTenantContext()
const runStartedAt = new Date() const runStartedAt = new Date()
const systemSettings = await this.systemSettingService.getSettings()
const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id) const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id)
const effectiveMaxObjectMb = planQuota.maxSyncObjectSizeMb ?? systemSettings.maxDataSyncObjectSizeMb const effectiveMaxObjectMb = planQuota.maxSyncObjectSizeMb
const syncLimits = { const syncLimits = {
maxObjectBytes: this.convertMbToBytes(effectiveMaxObjectMb), maxObjectBytes: this.convertMbToBytes(effectiveMaxObjectMb),
maxObjectSizeMb: effectiveMaxObjectMb, maxObjectSizeMb: effectiveMaxObjectMb,
libraryLimit: planQuota.libraryItemLimit ?? systemSettings.maxPhotoLibraryItems, libraryLimit: planQuota.libraryItemLimit,
} }
const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options) const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options)
const context = await this.prepareSyncContext(tenant.tenant.id, builderConfig, storageConfig) const context = await this.prepareSyncContext(tenant.tenant.id, builderConfig, storageConfig)

View File

@@ -149,7 +149,11 @@ export class AuthController {
return { return {
user: authContext.user, user: authContext.user,
session: authContext.session, session: authContext.session,
tenant: tenantContext, tenant: {
isPlaceholder: tenantContext.isPlaceholder,
requestedSlug: tenantContext.requestedSlug,
...tenantContext.tenant,
},
} }
} }

View File

@@ -1,8 +1,10 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { authAccounts, authSessions, authUsers, authVerifications, generateId } from '@afilmory/db' import { authAccounts, authSessions, authUsers, authVerifications, generateId } from '@afilmory/db'
import { env } from '@afilmory/env'
import type { OnModuleInit } from '@afilmory/framework' import type { OnModuleInit } from '@afilmory/framework'
import { createLogger, HttpContext } from '@afilmory/framework' import { createLogger, HttpContext } from '@afilmory/framework'
import { creem } from '@creem_io/better-auth'
import { betterAuth } from 'better-auth' import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { APIError, createAuthMiddleware } from 'better-auth/api' import { APIError, createAuthMiddleware } from 'better-auth/api'
@@ -10,6 +12,9 @@ import { admin } from 'better-auth/plugins'
import { DrizzleProvider } from 'core/database/database.provider' import { DrizzleProvider } from 'core/database/database.provider'
import { BizException } from 'core/errors' import { BizException } from 'core/errors'
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants'
import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service'
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import type { Context } from 'hono' import type { Context } from 'hono'
import { injectable } from 'tsyringe' import { injectable } from 'tsyringe'
@@ -33,6 +38,7 @@ export class AuthProvider implements OnModuleInit {
private readonly drizzleProvider: DrizzleProvider, private readonly drizzleProvider: DrizzleProvider,
private readonly systemSettings: SystemSettingService, private readonly systemSettings: SystemSettingService,
private readonly tenantService: TenantService, private readonly tenantService: TenantService,
private readonly billingPlanService: BillingPlanService,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -294,6 +300,70 @@ export class AuthProvider implements OnModuleInit {
defaultRole: 'user', defaultRole: 'user',
defaultBanReason: 'Spamming', defaultBanReason: 'Spamming',
}), }),
creem({
apiKey: env.CREEM_API_KEY,
webhookSecret: env.CREEM_WEBHOOK_SECRET,
persistSubscriptions: true,
schema: {
user: {
modelName: 'auth_user',
fields: {
creemCustomerId: {
type: 'string',
fieldName: 'creem_customer_id',
},
},
},
subscription: {
modelName: 'creem_subscription',
fields: {
productId: {
type: 'string',
fieldName: 'product_id',
},
referenceId: {
type: 'string',
fieldName: 'reference_id',
},
creemCustomerId: {
type: 'string',
fieldName: 'creem_customer_id',
},
creemSubscriptionId: {
type: 'string',
fieldName: 'creem_subscription_id',
},
creemOrderId: {
type: 'string',
fieldName: 'creem_order_id',
},
status: {
type: 'string',
fieldName: 'status',
},
periodStart: {
type: 'string',
fieldName: 'period_start',
},
periodEnd: {
type: 'string',
fieldName: 'period_end',
},
cancelAtPeriodEnd: {
type: 'boolean',
fieldName: 'cancel_at_period_end',
},
},
},
},
testMode: env.NODE_ENV !== 'production',
onGrantAccess: async ({ metadata }) => {
await this.handleCreemGrant(metadata)
},
onRevokeAccess: async ({ metadata }) => {
await this.handleCreemRevoke(metadata)
},
}),
], ],
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
@@ -367,6 +437,61 @@ export class AuthProvider implements OnModuleInit {
return hash.digest('hex') return hash.digest('hex')
} }
private async handleCreemGrant(metadata?: Record<string, unknown>): Promise<void> {
const tenantId = this.extractMetadataValue(metadata, 'tenantId')
const planId = this.extractPlanIdFromMetadata(metadata)
if (!tenantId || !planId) {
logger.warn('[AuthProvider] Creem grant event missing tenantId or planId metadata')
return
}
try {
await this.billingPlanService.updateTenantPlan(tenantId, planId)
logger.info(`[AuthProvider] Tenant ${tenantId} upgraded to ${planId} via Creem`)
} catch (error) {
logger.error(`[AuthProvider] Failed to update tenant ${tenantId} plan from Creem grant`, error)
}
}
private async handleCreemRevoke(metadata?: Record<string, unknown>): Promise<void> {
const tenantId = this.extractMetadataValue(metadata, 'tenantId')
if (!tenantId) {
logger.warn('[AuthProvider] Creem revoke event missing tenantId metadata')
return
}
try {
await this.billingPlanService.updateTenantPlan(tenantId, 'free')
logger.info(`[AuthProvider] Tenant ${tenantId} downgraded to free via Creem revoke`)
} catch (error) {
logger.error(`[AuthProvider] Failed to downgrade tenant ${tenantId} after Creem revoke`, error)
}
}
private extractPlanIdFromMetadata(metadata?: Record<string, unknown>): BillingPlanId | null {
const planId = this.extractMetadataValue(metadata, 'planId')
if (!planId) {
return null
}
if (BILLING_PLAN_IDS.includes(planId as BillingPlanId)) {
return planId as BillingPlanId
}
return null
}
private extractMetadataValue(metadata: Record<string, unknown> | undefined, key: string): string | null {
if (!metadata) {
return null
}
const raw = metadata[key]
if (typeof raw !== 'string') {
return null
}
const trimmed = raw.trim()
return trimmed.length > 0 ? trimmed : null
}
async handler(context: Context): Promise<Response> { async handler(context: Context): Promise<Response> {
const requestPath = typeof context.req.path === 'string' ? context.req.path : new URL(context.req.url).pathname const requestPath = typeof context.req.path === 'string' ? context.req.path : new URL(context.req.url).pathname
if (requestPath.startsWith('/api/auth/error')) { if (requestPath.startsWith('/api/auth/error')) {

View File

@@ -1,7 +1,6 @@
import type { BillingPlanDefinition, BillingPlanId } from './billing-plan.types' import type { BillingPlanDefinition, BillingPlanId } from './billing-plan.types'
export const BILLING_PLAN_IDS: readonly BillingPlanId[] = ['free', 'pro', 'friend'] export const BILLING_PLAN_IDS: readonly BillingPlanId[] = ['free', 'pro', 'friend']
export const PUBLIC_PLAN_IDS: readonly BillingPlanId[] = ['free']
export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefinition> = { export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefinition> = {
free: { free: {
@@ -28,7 +27,7 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
}, },
friend: { friend: {
id: 'friend', id: 'friend',
name: 'Friend (Internal)', name: 'Friend',
description: '内部使用的好友方案,没有任何限制,仅超级管理员可设置。', description: '内部使用的好友方案,没有任何限制,仅超级管理员可设置。',
quotas: { quotas: {
monthlyAssetProcessLimit: null, monthlyAssetProcessLimit: null,
@@ -40,3 +39,5 @@ export const BILLING_PLAN_DEFINITIONS: Record<BillingPlanId, BillingPlanDefiniti
} }
export const BILLING_PLAN_OVERRIDES_SETTING_KEY = 'system.billing.planOverrides' export const BILLING_PLAN_OVERRIDES_SETTING_KEY = 'system.billing.planOverrides'
export const BILLING_PLAN_PRODUCTS_SETTING_KEY = 'system.billing.planProducts'
export const BILLING_PLAN_PRICING_SETTING_KEY = 'system.billing.planPricing'

View File

@@ -7,11 +7,15 @@ import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe' import { injectable } from 'tsyringe'
import { BILLING_USAGE_EVENT } from './billing.constants' import { BILLING_USAGE_EVENT } from './billing.constants'
import { BILLING_PLAN_DEFINITIONS, BILLING_PLAN_IDS, PUBLIC_PLAN_IDS } from './billing-plan.constants' import { BILLING_PLAN_DEFINITIONS, BILLING_PLAN_IDS } from './billing-plan.constants'
import type { import type {
BillingPlanDefinition, BillingPlanDefinition,
BillingPlanId, BillingPlanId,
BillingPlanOverrides, BillingPlanOverrides,
BillingPlanPaymentInfo,
BillingPlanPricing,
BillingPlanPricingConfigs,
BillingPlanProductConfigs,
BillingPlanQuota, BillingPlanQuota,
BillingPlanQuotaOverride, BillingPlanQuotaOverride,
} from './billing-plan.types' } from './billing-plan.types'
@@ -62,29 +66,42 @@ export class BillingPlanService {
} }
async getPlanSummaryForTenant(tenantId: string): Promise<BillingPlanSummary> { async getPlanSummaryForTenant(tenantId: string): Promise<BillingPlanSummary> {
const planId = await this.resolvePlanIdForTenant(tenantId) const [planId, overrides, productConfigs, pricingConfigs] = await Promise.all([
this.resolvePlanIdForTenant(tenantId),
this.getPlanOverrides(),
this.getPlanProducts(),
this.getPlanPricing(),
])
const definition = BILLING_PLAN_DEFINITIONS[planId] const definition = BILLING_PLAN_DEFINITIONS[planId]
const overrides = await this.getPlanOverrides()
const quotas = this.applyOverrides(definition.quotas, overrides[planId]) const quotas = this.applyOverrides(definition.quotas, overrides[planId])
return { return {
planId, planId,
name: definition.name, name: definition.name,
description: definition.description, description: definition.description,
quotas, quotas,
payment: this.buildPaymentInfo(productConfigs[planId]),
pricing: this.buildPricingInfo(pricingConfigs[planId]),
} }
} }
async getPublicPlanSummaries(): Promise<BillingPlanSummary[]> { async getPublicPlanSummaries(): Promise<BillingPlanSummary[]> {
const overrides = await this.getPlanOverrides() const [overrides, productConfigs, pricingConfigs] = await Promise.all([
return PUBLIC_PLAN_IDS.map((id) => { this.getPlanOverrides(),
this.getPlanProducts(),
this.getPlanPricing(),
])
return BILLING_PLAN_IDS.map((id) => {
const definition = BILLING_PLAN_DEFINITIONS[id] const definition = BILLING_PLAN_DEFINITIONS[id]
return { return {
planId: id, planId: id,
name: definition.name, name: definition.name,
description: definition.description, description: definition.description,
quotas: this.applyOverrides(definition.quotas, overrides[id]), quotas: this.applyOverrides(definition.quotas, overrides[id]),
payment: this.buildPaymentInfo(productConfigs[id]),
pricing: this.buildPricingInfo(pricingConfigs[id]),
} }
}) }).filter((plan) => this.shouldExposePlan(plan.planId, plan.payment))
} }
async ensurePhotoProcessingAllowance(tenantId: string, incomingItems: number): Promise<void> { async ensurePhotoProcessingAllowance(tenantId: string, incomingItems: number): Promise<void> {
@@ -129,6 +146,14 @@ export class BillingPlanService {
return await this.systemSettingService.getBillingPlanOverrides() return await this.systemSettingService.getBillingPlanOverrides()
} }
private async getPlanProducts(): Promise<BillingPlanProductConfigs> {
return await this.systemSettingService.getBillingPlanProducts()
}
private async getPlanPricing(): Promise<BillingPlanPricingConfigs> {
return await this.systemSettingService.getBillingPlanPricing()
}
private applyOverrides(base: BillingPlanQuota, override?: BillingPlanQuotaOverride): BillingPlanQuota { private applyOverrides(base: BillingPlanQuota, override?: BillingPlanQuotaOverride): BillingPlanQuota {
if (!override) { if (!override) {
return { ...base } return { ...base }
@@ -144,6 +169,48 @@ export class BillingPlanService {
override.maxSyncObjectSizeMb !== undefined ? override.maxSyncObjectSizeMb : base.maxSyncObjectSizeMb, override.maxSyncObjectSizeMb !== undefined ? override.maxSyncObjectSizeMb : base.maxSyncObjectSizeMb,
} }
} }
private buildPaymentInfo(entry?: BillingPlanPaymentInfo): BillingPlanPaymentInfo | undefined {
if (!entry) {
return undefined
}
const creemProductId = this.normalizeString(entry.creemProductId)
if (!creemProductId) {
return undefined
}
return { creemProductId }
}
private shouldExposePlan(planId: BillingPlanId, payment?: BillingPlanPaymentInfo): boolean {
if (planId === 'free') {
return true
}
return Boolean(payment?.creemProductId)
}
private buildPricingInfo(entry?: BillingPlanPricing): BillingPlanPricing | undefined {
if (!entry) {
return undefined
}
const hasPrice = entry.monthlyPrice !== null && !Number.isNaN(entry.monthlyPrice ?? undefined)
const hasCurrency = !!entry.currency
if (!hasPrice && !hasCurrency) {
return undefined
}
return {
monthlyPrice: hasPrice ? entry.monthlyPrice : null,
currency: entry.currency ?? null,
}
}
private normalizeString(value?: string | null): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
} }
export interface BillingPlanSummary { export interface BillingPlanSummary {
@@ -151,4 +218,6 @@ export interface BillingPlanSummary {
name: string name: string
description: string description: string
quotas: BillingPlanQuota quotas: BillingPlanQuota
pricing?: BillingPlanPricing
payment?: BillingPlanPaymentInfo
} }

View File

@@ -17,3 +17,16 @@ export interface BillingPlanDefinition {
export type BillingPlanQuotaOverride = Partial<BillingPlanQuota> export type BillingPlanQuotaOverride = Partial<BillingPlanQuota>
export type BillingPlanOverrides = Record<BillingPlanId | string, BillingPlanQuotaOverride> export type BillingPlanOverrides = Record<BillingPlanId | string, BillingPlanQuotaOverride>
export interface BillingPlanPaymentInfo {
creemProductId?: string | null
}
export type BillingPlanProductConfigs = Record<BillingPlanId | string, BillingPlanPaymentInfo | undefined>
export interface BillingPlanPricing {
monthlyPrice: number | null
currency: string | null
}
export type BillingPlanPricingConfigs = Record<BillingPlanId | string, BillingPlanPricing | undefined>

View File

@@ -9,7 +9,7 @@ import type { BillingUsageOverview } from './billing-usage.service'
import { BillingUsageService } from './billing-usage.service' import { BillingUsageService } from './billing-usage.service'
const usageQuerySchema = z.object({ const usageQuerySchema = z.object({
limit: z.number().optional(), limit: z.coerce.number().positive().int().optional().default(10),
}) })
class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {} class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {}

View File

@@ -3,13 +3,38 @@ import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.con
import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types' import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types'
import { z } from 'zod' import { z } from 'zod'
const planQuotaFields = (() => {
const fields: Record<string, z.ZodTypeAny> = {}
for (const planId of BILLING_PLAN_IDS) {
fields[`billingPlan.${planId}.quota.monthlyAssetProcessLimit`] = z.number().int().min(0).nullable().optional()
fields[`billingPlan.${planId}.quota.libraryItemLimit`] = z.number().int().min(0).nullable().optional()
fields[`billingPlan.${planId}.quota.maxUploadSizeMb`] = z.number().int().min(1).nullable().optional()
fields[`billingPlan.${planId}.quota.maxSyncObjectSizeMb`] = z.number().int().min(1).nullable().optional()
}
return fields
})()
const planPricingFields = (() => {
const fields: Record<string, z.ZodTypeAny> = {}
for (const planId of BILLING_PLAN_IDS) {
fields[`billingPlan.${planId}.pricing.monthlyPrice`] = z.number().min(0).nullable().optional()
fields[`billingPlan.${planId}.pricing.currency`] = z.string().trim().min(1).nullable().optional()
}
return fields
})()
const planProductFields = (() => {
const fields: Record<string, z.ZodTypeAny> = {}
for (const planId of BILLING_PLAN_IDS) {
fields[`billingPlan.${planId}.payment.creemProductId`] = z.string().trim().min(1).nullable().optional()
}
return fields
})()
const updateSuperAdminSettingsSchema = z const updateSuperAdminSettingsSchema = z
.object({ .object({
allowRegistration: z.boolean().optional(), allowRegistration: z.boolean().optional(),
maxRegistrableUsers: z.number().int().min(0).nullable().optional(), maxRegistrableUsers: z.number().int().min(0).nullable().optional(),
maxPhotoUploadSizeMb: z.number().int().positive().nullable().optional(),
maxDataSyncObjectSizeMb: z.number().int().positive().nullable().optional(),
maxPhotoLibraryItems: z.number().int().min(0).nullable().optional(),
localProviderEnabled: z.boolean().optional(), localProviderEnabled: z.boolean().optional(),
baseDomain: z baseDomain: z
.string() .string()
@@ -30,6 +55,9 @@ const updateSuperAdminSettingsSchema = z
oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(), oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(),
oauthGithubClientId: z.string().trim().min(1).nullable().optional(), oauthGithubClientId: z.string().trim().min(1).nullable().optional(),
oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(), oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(),
...planQuotaFields,
...planPricingFields,
...planProductFields,
}) })
.refine((value) => Object.values(value).some((entry) => entry !== undefined), { .refine((value) => Object.values(value).some((entry) => entry !== undefined), {
message: '至少需要更新一项设置', message: '至少需要更新一项设置',

View File

@@ -19,6 +19,7 @@
"@afilmory/hooks": "workspace:*", "@afilmory/hooks": "workspace:*",
"@afilmory/ui": "workspace:*", "@afilmory/ui": "workspace:*",
"@afilmory/utils": "workspace:*", "@afilmory/utils": "workspace:*",
"@creem_io/better-auth": "0.0.8",
"@essentials/request-timeout": "1.3.0", "@essentials/request-timeout": "1.3.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.9",
"@pastel-palette/tailwindcss": "1.0.0-canary.3", "@pastel-palette/tailwindcss": "1.0.0-canary.3",

View File

@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Finishing checkout…</title>
<style>
:root {
color: #0f172a;
background: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
main {
max-width: 420px;
text-align: center;
padding: 3rem 2rem;
border-radius: 1rem;
background: white;
box-shadow:
0 10px 15px rgba(15, 23, 42, 0.08),
0 4px 6px rgba(15, 23, 42, 0.04);
}
p {
line-height: 1.5;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<main>
<h1>Finishing checkout</h1>
<p data-status>Redirecting you back to your workspace…</p>
<p data-manual hidden>
If this page does not close automatically,
<a data-link href="/">click here to continue</a>.
</p>
</main>
<script>
;(() => {
const statusEl = document.querySelector('[data-status]')
const manualEl = document.querySelector('[data-manual]')
const linkEl = document.querySelector('[data-link]')
if (!statusEl || !manualEl || !linkEl) {
return
}
const params = new URLSearchParams(window.location.search)
const redirectParam = params.get('redirect')
if (!redirectParam) {
statusEl.textContent = 'Missing redirect target. You can safely close this tab.'
return
}
let target
try {
target = new URL(redirectParam)
} catch {
statusEl.textContent = 'Invalid redirect target received.'
manualEl.hidden = false
return
}
const host = target.hostname.toLowerCase()
const allowed =
host === 'localhost' ||
host.endsWith('.localhost') ||
host === window.location.hostname.toLowerCase()
if (!allowed) {
statusEl.textContent = 'Blocked an unexpected redirect destination.'
linkEl.href = target.toString()
linkEl.textContent = target.toString()
manualEl.hidden = false
return
}
manualEl.hidden = true
statusEl.textContent = 'Redirecting you back to your workspace…'
window.setTimeout(() => {
window.location.replace(target.toString())
}, 500)
})()
</script>
</body>
</html>

View File

@@ -4,9 +4,10 @@ import { camelCaseKeys } from '~/lib/case'
import type { BetterAuthSession, BetterAuthUser } from '../types' import type { BetterAuthSession, BetterAuthUser } from '../types'
export interface SessionTenant { export interface SessionTenant {
isPlaceholder: boolean
requestedSlug: string | null
id: string id: string
slug: string | null slug: string | null
isPlaceholder: boolean
} }
export type SessionResponse = { export type SessionResponse = {

View File

@@ -1,4 +1,5 @@
import { createAuthClient } from 'better-auth/react' import { creemClient } from '@creem_io/better-auth/client'
import { createCreemAuthClient } from '@creem_io/better-auth/create-creem-auth-client'
import { FetchError } from 'ofetch' import { FetchError } from 'ofetch'
const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api' const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api'
@@ -11,8 +12,9 @@ const commonOptions = {
}, },
} }
export const authClient = createAuthClient({ export const authClient = createCreemAuthClient({
baseURL: authBase, baseURL: authBase,
plugins: [creemClient()],
...commonOptions, ...commonOptions,
}) })

View File

@@ -10,6 +10,13 @@ export interface BillingPlanSummary {
name: string name: string
description: string description: string
quotas: BillingPlanQuota quotas: BillingPlanQuota
pricing?: {
monthlyPrice: number | null
currency: string | null
}
payment?: {
creemProductId?: string | null
}
} }
export interface BillingPlanResponse { export interface BillingPlanResponse {

View File

@@ -7,7 +7,7 @@ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } fr
import { LinearBorderPanel } from '~/components/common/GlassPanel' import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer'
import type { SchemaFormState, SchemaFormValue } from '../../schema-form/types' import type { SchemaFormState, SchemaFormValue, UiNode } from '../../schema-form/types'
import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks' import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks'
import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from '../types' import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from '../types'
import type { SuperAdminFieldMap } from '../utils/schema-form-adapter' import type { SuperAdminFieldMap } from '../utils/schema-form-adapter'
@@ -45,7 +45,11 @@ function extractRawSettings(payload: SuperAdminSettingsResponse): Record<string,
return null return null
} }
export function SuperAdminSettingsForm() { interface SuperAdminSettingsFormProps {
visibleSectionIds?: readonly string[]
}
export function SuperAdminSettingsForm({ visibleSectionIds }: SuperAdminSettingsFormProps = {}) {
const { data, isLoading, isError, error } = useSuperAdminSettingsQuery() const { data, isLoading, isError, error } = useSuperAdminSettingsQuery()
const [fieldMap, setFieldMap] = useState<SuperAdminFieldMap>(() => new Map()) const [fieldMap, setFieldMap] = useState<SuperAdminFieldMap>(() => new Map())
const [formState, setFormState] = useState<FormState | null>(null) const [formState, setFormState] = useState<FormState | null>(null)
@@ -145,6 +149,19 @@ export function SuperAdminSettingsForm() {
return '所有设置已同步' return '所有设置已同步'
}, [hasChanges, updateMutation.error, updateMutation.isError, updateMutation.isPending, updateMutation.isSuccess]) }, [hasChanges, updateMutation.error, updateMutation.isError, updateMutation.isPending, updateMutation.isSuccess])
const shouldRenderNode = useMemo(() => {
if (!visibleSectionIds || visibleSectionIds.length === 0) {
return
}
const allowed = new Set(visibleSectionIds)
return (node: UiNode<string>) => {
if (node.type === 'section') {
return allowed.has(node.id)
}
return true
}
}, [visibleSectionIds])
if (isError) { if (isError) {
return ( return (
<LinearBorderPanel className="p-6"> <LinearBorderPanel className="p-6">
@@ -190,7 +207,12 @@ export function SuperAdminSettingsForm() {
transition={Spring.presets.smooth} transition={Spring.presets.smooth}
className="space-y-6" className="space-y-6"
> >
<SchemaFormRenderer schema={data.schema} values={formState} onChange={handleChange} /> <SchemaFormRenderer
schema={data.schema}
values={formState}
onChange={handleChange}
shouldRenderNode={shouldRenderNode}
/>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<LinearBorderPanel className="p-6"> <LinearBorderPanel className="p-6">

View File

@@ -1,8 +1,14 @@
import { Button } from '@afilmory/ui' import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils' import { clsxm } from '@afilmory/utils'
import { m } from 'motion/react' import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { LinearBorderPanel } from '~/components/common/GlassPanel'
import { MainPageLayout } from '~/components/layouts/MainPageLayout' import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import type { SessionResponse } from '~/modules/auth/api/session'
import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session'
import { authClient } from '~/modules/auth/auth-client'
import type { BillingPlanSummary } from '~/modules/billing' import type { BillingPlanSummary } from '~/modules/billing'
import { useTenantPlanQuery } from '~/modules/billing' import { useTenantPlanQuery } from '~/modules/billing'
@@ -22,12 +28,30 @@ const QUOTA_UNITS: Record<string, string> = {
export function Component() { export function Component() {
const planQuery = useTenantPlanQuery() const planQuery = useTenantPlanQuery()
const queryClient = useQueryClient()
const session = (queryClient.getQueryData<SessionResponse | null>(AUTH_SESSION_QUERY_KEY) ??
null) as SessionResponse | null
const tenantId = session?.tenant?.id ?? null
const tenantSlug = session?.tenant?.slug ?? null
const plan = planQuery.data?.plan ?? null const plan = planQuery.data?.plan ?? null
const availablePlans = planQuery.data?.availablePlans ?? [] const availablePlans = planQuery.data?.availablePlans ?? []
const plans = useMemo(() => {
if (!plan) {
return []
}
const merged = new Map<string, BillingPlanSummary>()
for (const candidate of [plan, ...availablePlans]) {
if (candidate && !merged.has(candidate.planId)) {
merged.set(candidate.planId, candidate)
}
}
return Array.from(merged.values())
}, [availablePlans, plan])
return ( return (
<MainPageLayout title="订阅计划" description="查看当前订阅状态与资源限制,未来版本将在此处开放升级入口。"> <MainPageLayout title="订阅计划" description="查看当前订阅状态与资源限制,并在此处发起升级或管理订阅。">
<div className="space-y-6"> <div className="space-y-6">
{planQuery.isError && ( {planQuery.isError && (
<div className="text-red text-sm"> <div className="text-red text-sm">
@@ -38,37 +62,133 @@ export function Component() {
{planQuery.isLoading || !plan ? ( {planQuery.isLoading || !plan ? (
<PlanSkeleton /> <PlanSkeleton />
) : ( ) : (
<PlanList currentPlanId={plan.planId} plans={availablePlans.length > 0 ? availablePlans : [plan]} /> <PlanList currentPlanId={plan.planId} plans={plans} tenantId={tenantId} tenantSlug={tenantSlug} />
)} )}
</div> </div>
</MainPageLayout> </MainPageLayout>
) )
} }
function PlanList({ currentPlanId, plans }: { currentPlanId: string; plans: BillingPlanSummary[] }) { function PlanList({
currentPlanId,
plans,
tenantId,
tenantSlug,
}: {
currentPlanId: string
plans: BillingPlanSummary[]
tenantId: string | null
tenantSlug: string | null
}) {
return ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{plans.map((plan) => ( {plans.map((plan) => (
<PlanCard key={plan.planId} plan={plan} isCurrent={plan.planId === currentPlanId} /> <PlanCard
key={plan.planId}
plan={plan}
isCurrent={plan.planId === currentPlanId}
tenantId={tenantId}
tenantSlug={tenantSlug}
/>
))} ))}
</div> </div>
) )
} }
function PlanCard({ plan, isCurrent }: { plan: BillingPlanSummary; isCurrent: boolean }) { function PlanCard({
plan,
isCurrent,
tenantId,
tenantSlug,
}: {
plan: BillingPlanSummary
isCurrent: boolean
tenantId: string | null
tenantSlug: string | null
}) {
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [portalLoading, setPortalLoading] = useState(false)
const productId = plan.payment?.creemProductId ?? null
const canCheckout = Boolean(!isCurrent && tenantId && productId)
const showPortalButton = isCurrent && plan.planId !== 'free' && Boolean(productId)
const handleCheckout = async () => {
if (!canCheckout || !tenantId || !productId) {
toast.error('该方案暂未开放,请稍后再试。')
return
}
setCheckoutLoading(true)
const successUrl = buildCheckoutSuccessUrl(tenantSlug)
const metadata: Record<string, string> = {
tenantId,
planId: plan.planId,
}
if (tenantSlug) {
metadata.tenantSlug = tenantSlug
}
try {
const { data, error } = await authClient.creem.createCheckout({
productId,
successUrl,
metadata,
})
if (error) {
throw new Error(error.message ?? 'Creem 返回了未知错误')
}
if (data?.url) {
window.location.href = data.url
return
}
toast.error('Creem 未返回有效的结算链接,请稍后再试。')
} catch (error) {
toast.error(error instanceof Error ? error.message : '无法创建订阅结算会话')
} finally {
setCheckoutLoading(false)
}
}
const handlePortal = async () => {
if (!showPortalButton) {
return
}
setPortalLoading(true)
try {
const { data, error } = await authClient.creem.createPortal()
if (error) {
throw new Error(error.message ?? '无法打开订阅管理')
}
if (data?.url) {
window.location.href = data.url
return
}
toast.error('Creem 未返回订阅管理地址,请稍后再试。')
} catch (error) {
toast.error(error instanceof Error ? error.message : '无法打开订阅管理')
} finally {
setPortalLoading(false)
}
}
return ( return (
<m.div <LinearBorderPanel className="bg-background-secondary/70 p-5">
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="rounded-2xl border border-border/40 bg-background-secondary/70 p-5"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h2 className="text-lg font-semibold text-text">{plan.name}</h2> <h2 className="text-lg font-semibold text-text">{plan.name}</h2>
<p className="text-text-secondary text-sm">{plan.description}</p> <p className="text-text-secondary text-sm">{plan.description}</p>
{plan.pricing && plan.pricing.monthlyPrice !== null && plan.pricing.monthlyPrice !== undefined && (
<p
className={clsxm(
'text-text absolute right-0 top-0 mt-1 text-sm font-semibold',
isCurrent && 'translate-y-6',
)}
>
{formatPrice(plan.pricing.monthlyPrice, plan.pricing.currency)}
</p>
)}
</div> </div>
{isCurrent && <CurrentBadge />} {isCurrent && <CurrentBadge planId={plan.planId} />}
</div> </div>
<ul className="mt-6 space-y-2"> <ul className="mt-6 space-y-2">
@@ -81,16 +201,54 @@ function PlanCard({ plan, isCurrent }: { plan: BillingPlanSummary; isCurrent: bo
</ul> </ul>
{!isCurrent && ( {!isCurrent && (
<Button type="button" disabled className="mt-4 w-full" size="sm"> <Button
type="button"
className="mt-4 w-full"
size="sm"
disabled={!canCheckout || checkoutLoading}
onClick={handleCheckout}
>
{checkoutLoading ? '请稍候…' : canCheckout ? '升级此方案' : '敬请期待'}
</Button> </Button>
)} )}
</m.div>
{showPortalButton && (
<Button
type="button"
variant="secondary"
className="mt-4 w-full"
size="sm"
disabled={portalLoading}
onClick={handlePortal}
>
{portalLoading ? '打开中…' : '管理订阅'}
</Button>
)}
</LinearBorderPanel>
) )
} }
function CurrentBadge() { function buildCheckoutSuccessUrl(tenantSlug: string | null): string {
return <span className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-xs font-semibold"></span> const { origin, pathname, search, hash, protocol, hostname, port } = window.location
const defaultUrl = `${origin}${pathname}${search}${hash}`
const isLocalSubdomain = hostname !== 'localhost' && hostname.endsWith('.localhost')
if (!isLocalSubdomain) {
return defaultUrl
}
const redirectOrigin = `${protocol}//localhost${port ? `:${port}` : ''}`
const redirectUrl = new URL('/creem-redirect.html', redirectOrigin)
redirectUrl.searchParams.set('redirect', defaultUrl)
if (tenantSlug) {
redirectUrl.searchParams.set('tenant', tenantSlug)
}
return redirectUrl.toString()
}
function CurrentBadge({ planId }: { planId: string }) {
const label = planId === 'friend' ? '内部方案' : '当前方案'
return <span className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-xs font-semibold">{label}</span>
} }
function renderQuotaValue(value: number | null, unit?: string): string { function renderQuotaValue(value: number | null, unit?: string): string {
@@ -101,6 +259,12 @@ function renderQuotaValue(value: number | null, unit?: string): string {
return unit ? `${numeral}${unit}` : numeral return unit ? `${numeral}${unit}` : numeral
} }
function formatPrice(value: number, currency: string | null | undefined): string {
const normalizedCurrency = currency?.toUpperCase() ?? ''
const formatted = value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted
}
function PlanSkeleton() { function PlanSkeleton() {
return ( return (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">

View File

@@ -1,22 +1,11 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { BuilderSettingsForm } from '~/modules/builder-settings' import { BuilderSettingsForm } from '~/modules/builder-settings'
export function Component() { export function Component() {
return ( return (
<m.div <MainPageLayout title="构建器设置">
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<header className="space-y-2">
<h1 className="text-text text-2xl font-semibold"></h1>
<p className="text-text-secondary text-sm"></p>
</header>
<BuilderSettingsForm /> <BuilderSettingsForm />
</m.div> </MainPageLayout>
) )
} }

View File

@@ -9,10 +9,11 @@ export function Component() {
const isSuperAdmin = useIsSuperAdmin() const isSuperAdmin = useIsSuperAdmin()
const navItems = [ const navItems = [
{ to: '/superadmin/settings', label: '系统设置', end: true }, { to: '/superadmin/settings', label: '系统设置', end: true },
{ to: '/superadmin/plans', label: '订阅计划', end: true },
{ to: '/superadmin/tenants', label: '租户管理', end: true }, { to: '/superadmin/tenants', label: '租户管理', end: true },
{ {
label: '构建器', label: '构建器',
to: '/settings/builder', to: '/superadmin/builder',
end: true, end: true,
}, },
{ to: '/superadmin/debug', label: 'Builder 调试', end: false }, { to: '/superadmin/debug', label: 'Builder 调试', end: false },

View File

@@ -0,0 +1,26 @@
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { SuperAdminSettingsForm } from '~/modules/super-admin'
const PLAN_SECTION_IDS = ['billing-plan-settings'] as const
export function Component() {
return (
<m.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.presets.smooth}
className="space-y-6"
>
<header className="space-y-2">
<h1 className="text-text text-2xl font-semibold"></h1>
<p className="text-text-secondary text-sm">
Creem Product
</p>
</header>
<SuperAdminSettingsForm visibleSectionIds={PLAN_SECTION_IDS} />
</m.div>
)
}

View File

@@ -16,7 +16,7 @@ export function Component() {
<p className="text-text-secondary text-sm"></p> <p className="text-text-secondary text-sm"></p>
</header> </header>
<SuperAdminSettingsForm /> <SuperAdminSettingsForm visibleSectionIds={['registration-control', 'oauth-providers']} />
</m.div> </m.div>
) )
} }

View File

@@ -0,0 +1,16 @@
CREATE TABLE "creem_subscription" (
"id" text PRIMARY KEY NOT NULL,
"product_id" text NOT NULL,
"reference_id" text NOT NULL,
"creem_customer_id" text,
"creem_subscription_id" text,
"creem_order_id" text,
"status" text DEFAULT 'pending' NOT NULL,
"period_start" timestamp,
"period_end" timestamp,
"cancel_at_period_end" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "auth_user" ADD COLUMN "creem_customer_id" text;

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1763292246073, "when": 1763292246073,
"tag": "0003_natural_ultimates", "tag": "0003_natural_ultimates",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1763315183401,
"tag": "0004_aromatic_bishop",
"breakpoints": true
} }
] ]
} }

View File

@@ -76,6 +76,7 @@ export const authUsers = pgTable('auth_user', {
email: text('email').notNull().unique(), email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(), emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'), image: text('image'),
creemCustomerId: text('creem_customer_id'),
role: userRoleEnum('role').notNull().default('user'), role: userRoleEnum('role').notNull().default('user'),
tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }), tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
@@ -131,6 +132,21 @@ export const authVerifications = pgTable('auth_verification', {
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
}) })
export const creemSubscriptions = pgTable('creem_subscription', {
id: text('id').primaryKey(),
productId: text('product_id').notNull(),
referenceId: text('reference_id').notNull(),
creemCustomerId: text('creem_customer_id'),
creemSubscriptionId: text('creem_subscription_id'),
creemOrderId: text('creem_order_id'),
status: text('status').notNull().default('pending'),
periodStart: timestamp('period_start', { mode: 'string' }),
periodEnd: timestamp('period_end', { mode: 'string' }),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
})
export const tenantAuthUsers = pgTable( export const tenantAuthUsers = pgTable(
'tenant_auth_user', 'tenant_auth_user',
{ {

View File

@@ -23,6 +23,10 @@ export const env = createEnv({
CONFIG_ENCRYPTION_KEY: z.string().min(1), CONFIG_ENCRYPTION_KEY: z.string().min(1),
// Payment
CREEM_API_KEY: z.string().min(1),
CREEM_WEBHOOK_SECRET: z.string().min(1),
DEFAULT_SUPERADMIN_EMAIL: z.email().default('root@local.host'), DEFAULT_SUPERADMIN_EMAIL: z.email().default('root@local.host'),
DEFAULT_SUPERADMIN_USERNAME: z DEFAULT_SUPERADMIN_USERNAME: z
.string() .string()

View File

@@ -22,6 +22,7 @@ This document tracks the current subscription plans, quota knobs, and the design
1. **Plan definitions** live in `billing-plan.constants.ts`. Each entry carries human-friendly metadata for the super-admin dashboard plus a `quotas` object. 1. **Plan definitions** live in `billing-plan.constants.ts`. Each entry carries human-friendly metadata for the super-admin dashboard plus a `quotas` object.
2. **Overrides** are stored under `system.billing.planOverrides`. This is a JSON blob keyed by plan id. It is parsed through zod (`SystemSettingService.getBillingPlanOverrides`) and merged in the billing plan service. 2. **Overrides** are stored under `system.billing.planOverrides`. This is a JSON blob keyed by plan id. It is parsed through zod (`SystemSettingService.getBillingPlanOverrides`) and merged in the billing plan service.
3. **Payment product mapping** lives in `system.billing.planProducts`. Each plan id can map to provider specific identifiers (e.g. `creemProductId`). Plans that require checkout (like `pro`) stay hidden until a product id is configured, which prevents exposing upgrade buttons in environments that are not ready.
3. **Tenant assignment** is tracked via `tenant.plan_id` and can only be changed by superadmins (see `/super-admin/tenants` backend+dashboard). The Friend plan is intentionally absent from any public selector. 3. **Tenant assignment** is tracked via `tenant.plan_id` and can only be changed by superadmins (see `/super-admin/tenants` backend+dashboard). The Friend plan is intentionally absent from any public selector.
4. **Quota enforcement** is performed in: 4. **Quota enforcement** is performed in:
- `PhotoAssetService` (manual upload size + library limit + monthly process allowance) - `PhotoAssetService` (manual upload size + library limit + monthly process allowance)

187
pnpm-lock.yaml generated
View File

@@ -98,7 +98,7 @@ importers:
version: 9.39.1(jiti@2.6.1) version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban: eslint-config-hyoban:
specifier: 4.0.10 specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
fast-glob: fast-glob:
specifier: 3.3.3 specifier: 3.3.3
version: 3.3.3 version: 3.3.3
@@ -345,7 +345,7 @@ importers:
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: next:
specifier: 16.0.1 specifier: 16.0.1
version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next-themes: next-themes:
specifier: 0.4.6 specifier: 0.4.6
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -578,7 +578,7 @@ importers:
version: 0.31.6 version: 0.31.6
next: next:
specifier: 16.0.1 specifier: 16.0.1
version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
postcss: postcss:
specifier: 8.5.6 specifier: 8.5.6
version: 8.5.6 version: 8.5.6
@@ -626,7 +626,7 @@ importers:
version: 2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/fluent-emoji': '@lobehub/fluent-emoji':
specifier: 2.0.0 specifier: 2.0.0
version: 2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@maplibre/maplibre-gl-geocoder': '@maplibre/maplibre-gl-geocoder':
specifier: ^1.9.1 specifier: ^1.9.1
version: 1.9.1(maplibre-gl@5.12.0) version: 1.9.1(maplibre-gl@5.12.0)
@@ -689,7 +689,7 @@ importers:
version: 10.2.0 version: 10.2.0
jotai: jotai:
specifier: 2.15.1 specifier: 2.15.1
version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0)
maplibre-gl: maplibre-gl:
specifier: ^5.12.0 specifier: ^5.12.0
version: 5.12.0 version: 5.12.0
@@ -737,7 +737,7 @@ importers:
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-scan: react-scan:
specifier: 0.4.3 specifier: 0.4.3
version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2) version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2)
react-use-measure: react-use-measure:
specifier: 2.1.7 specifier: 2.1.7
version: 2.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -926,6 +926,9 @@ importers:
'@aws-sdk/client-s3': '@aws-sdk/client-s3':
specifier: 3.929.0 specifier: 3.929.0
version: 3.929.0 version: 3.929.0
'@creem_io/better-auth':
specifier: 0.0.8
version: 0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)
'@hono/node-server': '@hono/node-server':
specifier: ^1.19.6 specifier: ^1.19.6
version: 1.19.6(hono@4.10.5) version: 1.19.6(hono@4.10.5)
@@ -934,7 +937,7 @@ importers:
version: 2.6.2 version: 2.6.2
better-auth: better-auth:
specifier: 1.3.34 specifier: 1.3.34
version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 specifier: ^0.44.7
version: 0.44.7(@types/pg@8.15.6)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7) version: 0.44.7(@types/pg@8.15.6)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7)
@@ -1008,6 +1011,9 @@ importers:
'@afilmory/utils': '@afilmory/utils':
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/utils version: link:../../../packages/utils
'@creem_io/better-auth':
specifier: 0.0.8
version: 0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)
'@essentials/request-timeout': '@essentials/request-timeout':
specifier: 1.3.0 specifier: 1.3.0
version: 1.3.0 version: 1.3.0
@@ -1061,7 +1067,7 @@ importers:
version: 5.90.8(react@19.2.0) version: 5.90.8(react@19.2.0)
better-auth: better-auth:
specifier: 1.3.34 specifier: 1.3.34
version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority: class-variance-authority:
specifier: 0.7.1 specifier: 0.7.1
version: 0.7.1 version: 0.7.1
@@ -1079,7 +1085,7 @@ importers:
version: 10.2.0 version: 10.2.0
jotai: jotai:
specifier: 2.15.1 specifier: 2.15.1
version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0)
lucide-react: lucide-react:
specifier: 0.553.0 specifier: 0.553.0
version: 0.553.0(react@19.2.0) version: 0.553.0(react@19.2.0)
@@ -1106,7 +1112,7 @@ importers:
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-scan: react-scan:
specifier: 0.4.3 specifier: 0.4.3
version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2) version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2)
sonner: sonner:
specifier: 2.0.7 specifier: 2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -1164,7 +1170,7 @@ importers:
version: 9.39.1(jiti@2.6.1) version: 9.39.1(jiti@2.6.1)
eslint-config-hyoban: eslint-config-hyoban:
specifier: 4.0.10 specifier: 4.0.10
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
lint-staged: lint-staged:
specifier: 16.2.6 specifier: 16.2.6
version: 16.2.6 version: 16.2.6
@@ -1548,7 +1554,7 @@ importers:
version: 0.16.3(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.3) version: 0.16.3(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.3)
unplugin-dts: unplugin-dts:
specifier: 1.0.0-beta.6 specifier: 1.0.0-beta.6
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
vite: vite:
specifier: 7.2.2 specifier: 7.2.2
version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
@@ -2408,6 +2414,13 @@ packages:
conventional-commits-parser: conventional-commits-parser:
optional: true optional: true
'@creem_io/better-auth@0.0.8':
resolution: {integrity: sha512-tTDsYqbyRgryk5jSBg+i8/eI05JLRLNOEF5aYsnKVDrEHerVMHz6Lp6059zewQ/bhcwKdn9Bwm5F2iFoL426tw==}
peerDependencies:
better-auth: ^1.3.34
creem: ^0.4.0
zod: ^3.23.8 || ^4
'@discoveryjs/json-ext@0.5.7': '@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -7004,6 +7017,15 @@ packages:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'} engines: {node: '>=10'}
creem@0.4.0:
resolution: {integrity: sha512-1RENze+vqUSq478DzisYaFuHmHt24QKg2zASuThQ9GqXi1XnW1KQa6ekuO0rA2RGqiDSYyfR6+DxaGJBYuVghg==}
hasBin: true
peerDependencies:
'@modelcontextprotocol/sdk': '>=1.5.0 <1.10.0'
peerDependenciesMeta:
'@modelcontextprotocol/sdk':
optional: true
cross-env@10.1.0: cross-env@10.1.0:
resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -12896,9 +12918,9 @@ snapshots:
regexpu-core: 6.4.0 regexpu-core: 6.4.0
semver: 6.3.1 semver: 6.3.1
'@babel/helper-define-polyfill-provider@0.6.4(@babel/core@7.28.4)': '@babel/helper-define-polyfill-provider@0.6.4(@babel/core@7.28.5)':
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.5
'@babel/helper-compilation-targets': 7.27.2 '@babel/helper-compilation-targets': 7.27.2
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.27.1
debug: 4.4.3(supports-color@5.5.0) debug: 4.4.3(supports-color@5.5.0)
@@ -13363,14 +13385,14 @@ snapshots:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-runtime@7.27.4(@babel/core@7.28.4)': '@babel/plugin-transform-runtime@7.27.4(@babel/core@7.28.5)':
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.5
'@babel/helper-module-imports': 7.27.1 '@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.27.1
babel-plugin-polyfill-corejs2: 0.4.13(@babel/core@7.28.4) babel-plugin-polyfill-corejs2: 0.4.13(@babel/core@7.28.5)
babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.28.4) babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.28.5)
babel-plugin-polyfill-regenerator: 0.6.4(@babel/core@7.28.4) babel-plugin-polyfill-regenerator: 0.6.4(@babel/core@7.28.5)
semver: 6.3.1 semver: 6.3.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13663,6 +13685,12 @@ snapshots:
conventional-commits-filter: 5.0.0 conventional-commits-filter: 5.0.0
conventional-commits-parser: 6.2.1 conventional-commits-parser: 6.2.1
'@creem_io/better-auth@0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)':
dependencies:
better-auth: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
creem: 0.4.0
zod: 4.1.12
'@discoveryjs/json-ext@0.5.7': {} '@discoveryjs/json-ext@0.5.7': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.0)': '@dnd-kit/accessibility@3.1.1(react@19.2.0)':
@@ -14608,10 +14636,10 @@ snapshots:
'@lobehub/emojilib@1.0.0': {} '@lobehub/emojilib@1.0.0': {}
'@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': '@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@lobehub/emojilib': 1.0.0 '@lobehub/emojilib': 1.0.0
'@lobehub/ui': 2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@lobehub/ui': 2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
emoji-regex: 10.4.0 emoji-regex: 10.4.0
@@ -14628,9 +14656,9 @@ snapshots:
- framer-motion - framer-motion
- supports-color - supports-color
'@lobehub/icons@2.7.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': '@lobehub/icons@2.7.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@lobehub/ui': 2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@lobehub/ui': 2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lucide-react: 0.469.0(react@19.2.0) lucide-react: 0.469.0(react@19.2.0)
@@ -14645,7 +14673,7 @@ snapshots:
- framer-motion - framer-motion
- supports-color - supports-color
'@lobehub/ui@2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': '@lobehub/ui@2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@ant-design/cssinjs': 1.23.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@ant-design/cssinjs': 1.23.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -14656,8 +14684,8 @@ snapshots:
'@emoji-mart/react': 1.1.1(emoji-mart@5.6.0)(react@19.2.0) '@emoji-mart/react': 1.1.1(emoji-mart@5.6.0)(react@19.2.0)
'@floating-ui/react': 0.27.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@floating-ui/react': 0.27.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@giscus/react': 3.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@giscus/react': 3.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@lobehub/icons': 2.7.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@lobehub/icons': 2.7.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@mdx-js/mdx': 3.1.0(acorn@8.15.0) '@mdx-js/mdx': 3.1.0(acorn@8.15.0)
'@mdx-js/react': 3.1.1(@types/react@19.2.3)(react@19.2.0) '@mdx-js/react': 3.1.1(@types/react@19.2.3)(react@19.2.0)
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.3)(react@19.2.0) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.3)(react@19.2.0)
@@ -14687,7 +14715,7 @@ snapshots:
rc-menu: 9.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) rc-menu: 9.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
re-resizable: 6.11.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) re-resizable: 6.11.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0 react: 19.2.0
react-avatar-editor: 13.0.2(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-avatar-editor: 13.0.2(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
react-error-boundary: 5.0.0(react@19.2.0) react-error-boundary: 5.0.0(react@19.2.0)
react-hotkeys-hook: 5.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-hotkeys-hook: 5.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -18158,11 +18186,11 @@ snapshots:
cosmiconfig: 7.1.0 cosmiconfig: 7.1.0
resolve: 1.22.11 resolve: 1.22.11
babel-plugin-polyfill-corejs2@0.4.13(@babel/core@7.28.4): babel-plugin-polyfill-corejs2@0.4.13(@babel/core@7.28.5):
dependencies: dependencies:
'@babel/compat-data': 7.28.0 '@babel/compat-data': 7.28.0
'@babel/core': 7.28.4 '@babel/core': 7.28.5
'@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5)
semver: 6.3.1 semver: 6.3.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -18176,10 +18204,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.28.4): babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.28.5):
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.5
'@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5)
core-js-compat: 3.46.0 core-js-compat: 3.46.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -18192,10 +18220,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
babel-plugin-polyfill-regenerator@0.6.4(@babel/core@7.28.4): babel-plugin-polyfill-regenerator@0.6.4(@babel/core@7.28.5):
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.5
'@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -18220,7 +18248,7 @@ snapshots:
batch-cluster@15.0.1: {} batch-cluster@15.0.1: {}
better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): better-auth@1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies: dependencies:
'@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
'@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
@@ -18237,7 +18265,7 @@ snapshots:
nanostores: 1.0.1 nanostores: 1.0.1
zod: 4.1.12 zod: 4.1.12
optionalDependencies: optionalDependencies:
next: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
@@ -18657,6 +18685,10 @@ snapshots:
path-type: 4.0.0 path-type: 4.0.0
yaml: 1.10.2 yaml: 1.10.2
creem@0.4.0:
dependencies:
zod: 3.25.76
cross-env@10.1.0: cross-env@10.1.0:
dependencies: dependencies:
'@epic-web/invariant': 1.0.0 '@epic-web/invariant': 1.0.0
@@ -21839,7 +21871,7 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies: dependencies:
'@next/env': 16.0.1 '@next/env': 16.0.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -21857,14 +21889,13 @@ snapshots:
'@next/swc-linux-x64-musl': 16.0.1 '@next/swc-linux-x64-musl': 16.0.1
'@next/swc-win32-arm64-msvc': 16.0.1 '@next/swc-win32-arm64-msvc': 16.0.1
'@next/swc-win32-x64-msvc': 16.0.1 '@next/swc-win32-x64-msvc': 16.0.1
babel-plugin-react-compiler: 19.1.0-rc.3
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
optional: true optional: true
next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies: dependencies:
'@next/env': 16.0.1 '@next/env': 16.0.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -21882,6 +21913,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.0.1 '@next/swc-linux-x64-musl': 16.0.1
'@next/swc-win32-arm64-msvc': 16.0.1 '@next/swc-win32-arm64-msvc': 16.0.1
'@next/swc-win32-x64-msvc': 16.0.1 '@next/swc-win32-x64-msvc': 16.0.1
babel-plugin-react-compiler: 19.1.0-rc.3
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -22803,9 +22835,9 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
react-avatar-editor@13.0.2(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): react-avatar-editor@13.0.2(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies: dependencies:
'@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.28.4) '@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.28.5)
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
prop-types: 15.8.1 prop-types: 15.8.1
react: 19.2.0 react: 19.2.0
@@ -23004,38 +23036,7 @@ snapshots:
optionalDependencies: optionalDependencies:
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2): react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
'@babel/types': 7.28.4
'@clack/core': 0.3.5
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@types/node': 20.19.25
bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0)
esbuild: 0.25.11
estree-walker: 3.0.3
kleur: 4.1.5
mri: 1.2.0
playwright: 1.55.0
preact: 10.27.2
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tsx: 4.20.6
optionalDependencies:
next: 16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
unplugin: 2.1.0
transitivePeerDependencies:
- '@types/react'
- rollup
- supports-color
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2):
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.4
'@babel/generator': 7.28.3 '@babel/generator': 7.28.3
@@ -23057,7 +23058,38 @@ snapshots:
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
tsx: 4.20.6 tsx: 4.20.6
optionalDependencies: optionalDependencies:
next: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
unplugin: 2.1.0
transitivePeerDependencies:
- '@types/react'
- rollup
- supports-color
react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
dependencies:
'@babel/core': 7.28.4
'@babel/generator': 7.28.3
'@babel/types': 7.28.4
'@clack/core': 0.3.5
'@clack/prompts': 0.8.2
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/signals': 1.3.2(preact@10.27.2)
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
'@types/node': 20.19.25
bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0)
esbuild: 0.25.11
estree-walker: 3.0.3
kleur: 4.1.5
mri: 1.2.0
playwright: 1.55.0
preact: 10.27.2
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tsx: 4.20.6
optionalDependencies:
next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
unplugin: 2.1.0 unplugin: 2.1.0
@@ -24380,7 +24412,7 @@ snapshots:
magic-string-ast: 1.0.3 magic-string-ast: 1.0.3
unplugin: 2.3.10 unplugin: 2.3.10
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
dependencies: dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.2) '@rollup/pluginutils': 5.3.0(rollup@4.53.2)
'@volar/typescript': 2.4.23 '@volar/typescript': 2.4.23
@@ -24394,6 +24426,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1) '@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.25.12 esbuild: 0.25.12
rolldown: 1.0.0-beta.49
rollup: 4.53.2 rollup: 4.53.2
vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies: transitivePeerDependencies: