mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: implement site settings management and onboarding enhancements
- Added site settings management with a dedicated UI for configuration. - Introduced new onboarding steps for site branding, including site name, title, and description. - Updated API endpoints for site settings retrieval and updates. - Enhanced the onboarding wizard to include site settings integration. - Refactored related components and hooks for better state management and validation. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export interface InjectConfig {
|
||||
useApi: boolean
|
||||
useCloud: boolean
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum ErrorCode {
|
||||
AUTH_UNAUTHORIZED = 10,
|
||||
AUTH_FORBIDDEN = 11,
|
||||
AUTH_TENANT_NOT_FOUND = 12,
|
||||
AUTH_TENANT_NOT_FOUND_GUARD = 13,
|
||||
|
||||
// Tenant
|
||||
TENANT_NOT_FOUND = 20,
|
||||
@@ -59,6 +60,10 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
|
||||
httpStatus: 400,
|
||||
message: 'Tenant context not found',
|
||||
},
|
||||
[ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD]: {
|
||||
httpStatus: 400,
|
||||
message: 'Tenant context not found (guard)',
|
||||
},
|
||||
[ErrorCode.TENANT_NOT_FOUND]: {
|
||||
httpStatus: 404,
|
||||
message: 'Tenant not found',
|
||||
|
||||
@@ -8,6 +8,16 @@ import { injectable } from 'tsyringe'
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = createLogger('AllExceptionsFilter')
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const store = host.getContext()
|
||||
const ctx = store.hono
|
||||
|
||||
const error = exception instanceof Error ? exception : new Error(String(exception))
|
||||
|
||||
this.logger.error(`--- ${ctx.req.method} ${toUri(ctx.req.url)} --->\n`, error)
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.logger.error(error)
|
||||
}
|
||||
if (exception instanceof BizException) {
|
||||
const response = exception.toResponse()
|
||||
return new Response(JSON.stringify(response), {
|
||||
@@ -36,13 +46,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
})
|
||||
}
|
||||
|
||||
const store = host.getContext()
|
||||
const ctx = store.hono
|
||||
|
||||
const error = exception instanceof Error ? exception : new Error(String(exception))
|
||||
|
||||
this.logger.error(`--- ${ctx.req.method} ${toUri(ctx.req.url)} --->\n`, error)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
statusCode: 500,
|
||||
|
||||
@@ -104,14 +104,14 @@ export class AuthGuard implements CanActivate {
|
||||
this.log.warn(
|
||||
`Denied access: session ${(authSession.user as { id?: string }).id ?? 'unknown'} missing tenant id for ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
|
||||
if (!tenantContext) {
|
||||
this.log.warn(
|
||||
`Denied access: tenant context missing while session tenant=${sessionTenantId} accessing ${method} ${path}`,
|
||||
)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND)
|
||||
throw new BizException(ErrorCode.AUTH_TENANT_NOT_FOUND_GUARD)
|
||||
}
|
||||
if (sessionTenantId !== tenantContext.tenant.id) {
|
||||
this.log.warn(
|
||||
|
||||
@@ -4,6 +4,10 @@ import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { SETTING_SCHEMAS } from '../setting/setting.constant'
|
||||
import type { SettingEntryInput } from '../setting/setting.service'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import type { SettingKeyType } from '../setting/setting.type'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { getTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantRepository } from '../tenant/tenant.repository'
|
||||
@@ -23,6 +27,7 @@ type RegisterTenantInput = {
|
||||
name: string
|
||||
slug?: string | null
|
||||
}
|
||||
settings?: Array<{ key: string; value: unknown }>
|
||||
}
|
||||
|
||||
export interface RegisterTenantResult {
|
||||
@@ -49,6 +54,7 @@ export class AuthRegistrationService {
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly tenantRepository: TenantRepository,
|
||||
private readonly superAdminSettings: SuperAdminSettingService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
) {}
|
||||
|
||||
@@ -66,7 +72,7 @@ export class AuthRegistrationService {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '租户信息不能为空' })
|
||||
}
|
||||
|
||||
return await this.registerNewTenant(account, input.tenant, headers)
|
||||
return await this.registerNewTenant(account, input.tenant, headers, input.settings)
|
||||
}
|
||||
|
||||
private async generateUniqueSlug(base: string): Promise<string> {
|
||||
@@ -157,10 +163,44 @@ export class AuthRegistrationService {
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeSettings(settings?: RegisterTenantInput['settings']): SettingEntryInput[] {
|
||||
if (!settings || settings.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized: SettingEntryInput[] = []
|
||||
|
||||
for (const entry of settings) {
|
||||
const key = entry.key?.trim() ?? ''
|
||||
if (!key) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: 'Setting key cannot be empty',
|
||||
})
|
||||
}
|
||||
|
||||
if (!(key in SETTING_SCHEMAS)) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: `Unknown setting key: ${key}`,
|
||||
})
|
||||
}
|
||||
|
||||
const schema = SETTING_SCHEMAS[key as SettingKeyType]
|
||||
const value = schema.parse(entry.value)
|
||||
|
||||
normalized.push({
|
||||
key: key as SettingKeyType,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async registerNewTenant(
|
||||
account: Required<RegisterTenantAccountInput>,
|
||||
tenantInput: RegisterTenantInput['tenant'],
|
||||
headers: Headers,
|
||||
settings?: RegisterTenantInput['settings'],
|
||||
): Promise<RegisterTenantResult> {
|
||||
const tenantName = tenantInput?.name?.trim() ?? ''
|
||||
if (!tenantName) {
|
||||
@@ -222,6 +262,19 @@ export class AuthRegistrationService {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.update(authUsers).set({ tenantId, role: 'admin' }).where(eq(authUsers.id, userId))
|
||||
|
||||
const initialSettings = this.normalizeSettings(settings)
|
||||
if (initialSettings.length > 0) {
|
||||
await this.settingService.setMany(
|
||||
initialSettings.map((entry) => ({
|
||||
...entry,
|
||||
options: {
|
||||
tenantId,
|
||||
isSensitive: false,
|
||||
},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const refreshed = await this.tenantService.getById(tenantId)
|
||||
|
||||
return {
|
||||
|
||||
@@ -59,6 +59,7 @@ type TenantSignUpRequest = {
|
||||
name?: string
|
||||
slug?: string | null
|
||||
}
|
||||
settings?: Array<{ key?: string; value?: unknown }>
|
||||
}
|
||||
|
||||
type SocialSignInRequest = {
|
||||
@@ -208,6 +209,7 @@ export class AuthController {
|
||||
slug: body.tenant.slug ?? null,
|
||||
}
|
||||
: undefined,
|
||||
settings: body.settings,
|
||||
},
|
||||
headers,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SystemSettingModule } from '../system-setting/system-setting.module'
|
||||
import { TenantModule } from '../tenant/tenant.module'
|
||||
import { AuthConfig } from './auth.config'
|
||||
@@ -9,7 +10,7 @@ import { AuthProvider } from './auth.provider'
|
||||
import { AuthRegistrationService } from './auth-registration.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, SystemSettingModule, TenantModule],
|
||||
imports: [DatabaseModule, SystemSettingModule, SettingModule, TenantModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthProvider, AuthConfig, AuthRegistrationService],
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { OnboardingModule } from './onboarding/onboarding.module'
|
||||
import { PhotoModule } from './photo/photo.module'
|
||||
import { ReactionModule } from './reaction/reaction.module'
|
||||
import { SettingModule } from './setting/setting.module'
|
||||
import { SiteSettingModule } from './site-setting/site-setting.module'
|
||||
import { StaticWebModule } from './static-web/static-web.module'
|
||||
import { StorageSettingModule } from './storage-setting/storage-setting.module'
|
||||
import { SuperAdminModule } from './super-admin/super-admin.module'
|
||||
@@ -33,6 +34,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
AuthModule,
|
||||
SettingModule,
|
||||
StorageSettingModule,
|
||||
SiteSettingModule,
|
||||
SystemSettingModule,
|
||||
SuperAdminModule,
|
||||
OnboardingModule,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Body, Controller, Get, Post } from '@afilmory/framework'
|
||||
import { SkipTenant } from 'core/decorators/skip-tenant.decorator'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { OnboardingInitDto } from './onboarding.dto'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
@@ -14,6 +16,13 @@ export class OnboardingController {
|
||||
return { initialized }
|
||||
}
|
||||
|
||||
@Get('/site-schema')
|
||||
@BypassResponseTransform()
|
||||
@SkipTenant()
|
||||
async getSiteSchema() {
|
||||
return await this.service.getSiteSchema()
|
||||
}
|
||||
|
||||
@Post('/init')
|
||||
async initialize(@Body() dto: OnboardingInitDto) {
|
||||
const initialized = await this.service.isInitialized()
|
||||
|
||||
@@ -3,12 +3,13 @@ import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { AuthModule } from '../auth/auth.module'
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SiteSettingModule } from '../site-setting/site-setting.module'
|
||||
import { TenantModule } from '../tenant/tenant.module'
|
||||
import { OnboardingController } from './onboarding.controller'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, SettingModule, TenantModule],
|
||||
imports: [DatabaseModule, AuthModule, SettingModule, SiteSettingModule, TenantModule],
|
||||
providers: [OnboardingService],
|
||||
controllers: [OnboardingController],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { injectable } from 'tsyringe'
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { AuthProvider } from '../auth/auth.provider'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import { SiteSettingService } from '../site-setting/site-setting.service'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto'
|
||||
|
||||
@@ -22,6 +23,7 @@ export class OnboardingService {
|
||||
private readonly auth: AuthProvider,
|
||||
private readonly settings: SettingService,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly siteSettingService: SiteSettingService,
|
||||
) {}
|
||||
|
||||
async isInitialized(): Promise<boolean> {
|
||||
@@ -30,6 +32,10 @@ export class OnboardingService {
|
||||
return Boolean(user)
|
||||
}
|
||||
|
||||
async getSiteSchema() {
|
||||
return await this.siteSettingService.getOnboardingUiSchema()
|
||||
}
|
||||
|
||||
async initialize(
|
||||
payload: OnboardingInitDto,
|
||||
): Promise<{ adminUserId: string; superAdminUserId: string; tenantId: string }> {
|
||||
|
||||
@@ -2,6 +2,68 @@ import { z } from 'zod'
|
||||
|
||||
import type { SettingDefinition, SettingMetadata } from './setting.type'
|
||||
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}){1,2}$/i
|
||||
|
||||
function createOptionalUrlSchema(errorMessage: string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createJsonStringArraySchema(options: {
|
||||
allowEmpty?: boolean
|
||||
validator?: (value: unknown) => boolean
|
||||
errorMessage: string
|
||||
}) {
|
||||
return z.string().transform((value, ctx) => {
|
||||
const normalized = value.trim()
|
||||
|
||||
if (normalized.length === 0) {
|
||||
if (options.allowEmpty) {
|
||||
return '[]'
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: options.errorMessage,
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!Array.isArray(parsed) || (options.validator && !parsed.every(options.validator))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: options.errorMessage,
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return JSON.stringify(parsed)
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: options.errorMessage,
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
// 'ai.openai.apiKey': {
|
||||
// isSensitive: true,
|
||||
@@ -17,35 +79,130 @@ export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
// },
|
||||
'builder.storage.providers': {
|
||||
isSensitive: false,
|
||||
schema: z.string().transform((value, ctx) => {
|
||||
const normalized = value.trim()
|
||||
if (normalized.length === 0) {
|
||||
return '[]'
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!Array.isArray(parsed)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Builder storage providers must be a JSON array',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return JSON.stringify(parsed)
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Builder storage providers must be valid JSON',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
schema: createJsonStringArraySchema({
|
||||
allowEmpty: true,
|
||||
errorMessage: 'Builder storage providers must be a JSON array',
|
||||
}),
|
||||
},
|
||||
'builder.storage.activeProvider': {
|
||||
isSensitive: false,
|
||||
schema: z.string().transform((value) => value.trim()),
|
||||
},
|
||||
'site.name': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().min(1, 'Site name cannot be empty'),
|
||||
},
|
||||
'site.title': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().min(1, 'Site title cannot be empty'),
|
||||
},
|
||||
'site.description': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().min(1, 'Site description cannot be empty'),
|
||||
},
|
||||
'site.url': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().url('Site URL must be a valid URL'),
|
||||
},
|
||||
'site.accentColor': {
|
||||
isSensitive: false,
|
||||
schema: z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Accent color cannot be empty',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!HEX_COLOR_REGEX.test(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Accent color must be a valid hex color',
|
||||
})
|
||||
}
|
||||
}),
|
||||
},
|
||||
'site.author.name': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().min(1, 'Author name cannot be empty'),
|
||||
},
|
||||
'site.author.url': {
|
||||
isSensitive: false,
|
||||
schema: z.url('Author URL must be a valid URL'),
|
||||
},
|
||||
'site.author.avatar': {
|
||||
isSensitive: false,
|
||||
schema: createOptionalUrlSchema('Author avatar must be a valid URL'),
|
||||
},
|
||||
'site.social.twitter': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
},
|
||||
'site.social.github': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
},
|
||||
'site.social.rss': {
|
||||
isSensitive: false,
|
||||
schema: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.toLowerCase())
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'RSS toggle must be either "true" or "false"',
|
||||
})
|
||||
}
|
||||
}),
|
||||
},
|
||||
'site.feed.folo.challenge.feedId': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
},
|
||||
'site.feed.folo.challenge.userId': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
},
|
||||
'site.map.providers': {
|
||||
isSensitive: false,
|
||||
schema: createJsonStringArraySchema({
|
||||
allowEmpty: true,
|
||||
errorMessage: 'Map providers must be a JSON array of provider identifiers',
|
||||
validator: (value): value is string => typeof value === 'string',
|
||||
}),
|
||||
},
|
||||
'site.mapStyle': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
},
|
||||
'site.mapProjection': {
|
||||
isSensitive: false,
|
||||
schema: z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value !== 'globe' && value !== 'mercator') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Map projection must be either globe or mercator',
|
||||
})
|
||||
}
|
||||
}),
|
||||
},
|
||||
} as const satisfies Record<string, SettingDefinition>
|
||||
|
||||
export const DEFAULT_SETTING_METADATA = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Post } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { UpdateSiteSettingsDto } from './site-setting.dto'
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Controller('site/settings')
|
||||
@Roles('admin')
|
||||
export class SiteSettingController {
|
||||
constructor(private readonly siteSettingService: SiteSettingService) {}
|
||||
|
||||
@Get('/ui-schema')
|
||||
@BypassResponseTransform()
|
||||
async getUiSchema() {
|
||||
return await this.siteSettingService.getUiSchema()
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
async update(@Body() { entries }: UpdateSiteSettingsDto) {
|
||||
await this.siteSettingService.setMany(entries)
|
||||
return { updated: entries }
|
||||
}
|
||||
}
|
||||
23
be/apps/core/src/modules/site-setting/site-setting.dto.ts
Normal file
23
be/apps/core/src/modules/site-setting/site-setting.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createZodDto } from '@afilmory/framework'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SETTING_SCHEMAS } from '../setting/setting.constant'
|
||||
import { SITE_SETTING_KEYS } from './site-setting.type'
|
||||
|
||||
const keySchema = z.enum(SITE_SETTING_KEYS)
|
||||
|
||||
const entrySchema = z
|
||||
.object({
|
||||
key: keySchema,
|
||||
value: z.unknown(),
|
||||
})
|
||||
.transform((entry) => ({
|
||||
key: entry.key,
|
||||
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
||||
}))
|
||||
|
||||
export class UpdateSiteSettingsDto extends createZodDto(
|
||||
z.object({
|
||||
entries: z.array(entrySchema).min(1),
|
||||
}),
|
||||
) {}
|
||||
13
be/apps/core/src/modules/site-setting/site-setting.module.ts
Normal file
13
be/apps/core/src/modules/site-setting/site-setting.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SiteSettingController } from './site-setting.controller'
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Module({
|
||||
imports: [SettingModule],
|
||||
controllers: [SiteSettingController],
|
||||
providers: [SiteSettingService],
|
||||
exports: [SiteSettingService],
|
||||
})
|
||||
export class SiteSettingModule {}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import type { SettingEntryInput } from '../setting/setting.service'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import type { UiNode } from '../ui-schema/ui-schema.type'
|
||||
import type { SiteSettingEntryInput, SiteSettingKey, SiteSettingUiSchemaResponse } from './site-setting.type'
|
||||
import { ONBOARDING_SITE_SETTING_KEYS, SITE_SETTING_KEYS } from './site-setting.type'
|
||||
import { SITE_SETTING_UI_SCHEMA, SITE_SETTING_UI_SCHEMA_KEYS } from './site-setting.ui-schema'
|
||||
|
||||
@injectable()
|
||||
export class SiteSettingService {
|
||||
constructor(private readonly settingService: SettingService) {}
|
||||
|
||||
async getUiSchema(): Promise<SiteSettingUiSchemaResponse> {
|
||||
const values = await this.settingService.getMany(SITE_SETTING_UI_SCHEMA_KEYS, {})
|
||||
const typedValues: SiteSettingUiSchemaResponse['values'] = {}
|
||||
|
||||
for (const key of SITE_SETTING_KEYS) {
|
||||
typedValues[key] = values[key] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
schema: SITE_SETTING_UI_SCHEMA,
|
||||
values: typedValues,
|
||||
}
|
||||
}
|
||||
|
||||
async getOnboardingUiSchema(): Promise<SiteSettingUiSchemaResponse> {
|
||||
const allowedKeys = new Set<SiteSettingKey>(ONBOARDING_SITE_SETTING_KEYS)
|
||||
const schema = this.filterSchema(SITE_SETTING_UI_SCHEMA, allowedKeys)
|
||||
|
||||
return {
|
||||
schema,
|
||||
values: {},
|
||||
}
|
||||
}
|
||||
|
||||
async setMany(entries: readonly SiteSettingEntryInput[]): Promise<void> {
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
value: typeof entry.value === 'string' ? entry.value : String(entry.value),
|
||||
})) as readonly SettingEntryInput[]
|
||||
|
||||
await this.settingService.setMany(normalizedEntries)
|
||||
}
|
||||
|
||||
async get(key: SiteSettingKey) {
|
||||
return await this.settingService.get(key, {})
|
||||
}
|
||||
|
||||
private filterSchema(
|
||||
schema: SiteSettingUiSchemaResponse['schema'],
|
||||
allowed: Set<SiteSettingKey>,
|
||||
): SiteSettingUiSchemaResponse['schema'] {
|
||||
const filterNodes = (nodes: ReadonlyArray<UiNode<SiteSettingKey>>): Array<UiNode<SiteSettingKey>> => {
|
||||
const filtered: Array<UiNode<SiteSettingKey>> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
if (allowed.has(node.key)) {
|
||||
filtered.push(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const filteredChildren = filterNodes(node.children)
|
||||
if (filteredChildren.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered.push({ ...node, children: filteredChildren })
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return {
|
||||
...schema,
|
||||
sections: filterNodes(schema.sections) as SiteSettingUiSchemaResponse['schema']['sections'],
|
||||
}
|
||||
}
|
||||
}
|
||||
37
be/apps/core/src/modules/site-setting/site-setting.type.ts
Normal file
37
be/apps/core/src/modules/site-setting/site-setting.type.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SettingEntryInput } from '../setting/setting.service'
|
||||
import type { SettingKeyType } from '../setting/setting.type'
|
||||
import type { UiSchema } from '../ui-schema/ui-schema.type'
|
||||
|
||||
export const SITE_SETTING_KEYS = [
|
||||
'site.name',
|
||||
'site.title',
|
||||
'site.description',
|
||||
'site.url',
|
||||
'site.accentColor',
|
||||
'site.author.name',
|
||||
'site.author.url',
|
||||
'site.author.avatar',
|
||||
'site.social.twitter',
|
||||
'site.social.github',
|
||||
'site.social.rss',
|
||||
'site.feed.folo.challenge.feedId',
|
||||
'site.feed.folo.challenge.userId',
|
||||
'site.map.providers',
|
||||
'site.mapStyle',
|
||||
'site.mapProjection',
|
||||
] as const satisfies readonly SettingKeyType[]
|
||||
|
||||
export type SiteSettingKey = (typeof SITE_SETTING_KEYS)[number]
|
||||
|
||||
export const ONBOARDING_SITE_SETTING_KEYS = [
|
||||
'site.name',
|
||||
'site.title',
|
||||
'site.description',
|
||||
] as const satisfies readonly SiteSettingKey[]
|
||||
|
||||
export type SiteSettingEntryInput = Extract<SettingEntryInput, { key: SiteSettingKey }>
|
||||
|
||||
export interface SiteSettingUiSchemaResponse {
|
||||
readonly schema: UiSchema<SiteSettingKey>
|
||||
readonly values: Partial<Record<SiteSettingKey, string | null>>
|
||||
}
|
||||
304
be/apps/core/src/modules/site-setting/site-setting.ui-schema.ts
Normal file
304
be/apps/core/src/modules/site-setting/site-setting.ui-schema.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
|
||||
import type { SiteSettingKey } from './site-setting.type'
|
||||
|
||||
export const SITE_SETTING_UI_SCHEMA_VERSION = '1.0.0'
|
||||
|
||||
export const SITE_SETTING_UI_SCHEMA: UiSchema<SiteSettingKey> = {
|
||||
version: SITE_SETTING_UI_SCHEMA_VERSION,
|
||||
title: '站点设置',
|
||||
description: '配置前台站点的基础信息、品牌样式与地图展示能力。',
|
||||
sections: [
|
||||
{
|
||||
type: 'section',
|
||||
id: 'site-basic',
|
||||
title: '基础信息',
|
||||
description: '这些信息将直接展示在站点的导航栏、标题和 SEO 中。',
|
||||
icon: 'layout-dashboard',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-name',
|
||||
title: '站点名称',
|
||||
description: '显示在站点导航栏和页面标题中。',
|
||||
key: 'site.name',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入站点名称',
|
||||
},
|
||||
icon: 'type',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-title',
|
||||
title: '首页标题',
|
||||
description: '用于浏览器标签页及 SEO 标题。',
|
||||
key: 'site.title',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入首页标题',
|
||||
},
|
||||
icon: 'heading-1',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-description',
|
||||
title: '站点描述',
|
||||
description: '展示在站点简介及搜索引擎摘要中。',
|
||||
key: 'site.description',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'textarea',
|
||||
placeholder: '请输入站点描述…',
|
||||
minRows: 3,
|
||||
maxRows: 6,
|
||||
},
|
||||
icon: 'align-left',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-url',
|
||||
title: '站点 URL',
|
||||
description: '站点对外访问的主域名,必须为绝对地址。',
|
||||
key: 'site.url',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://afilmory.innei.in',
|
||||
autoComplete: 'url',
|
||||
},
|
||||
icon: 'globe',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-accent-color',
|
||||
title: '品牌主题色',
|
||||
description: '用于按钮、强调文本等高亮元素,支持 HEX 格式。',
|
||||
helperText: '示例:#007bff',
|
||||
key: 'site.accentColor',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '#007bff',
|
||||
},
|
||||
icon: 'palette',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'site-author-social',
|
||||
title: '作者与社交',
|
||||
description: '展示在站点关于信息与页脚的联系人和社交账号。',
|
||||
icon: 'user-round',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'site-author-group',
|
||||
title: '作者信息',
|
||||
icon: 'id-card',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-name',
|
||||
title: '作者名称',
|
||||
key: 'site.author.name',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入作者名称',
|
||||
autoComplete: 'name',
|
||||
},
|
||||
icon: 'user-circle',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-url',
|
||||
title: '作者主页链接',
|
||||
key: 'site.author.url',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://innei.in/',
|
||||
autoComplete: 'url',
|
||||
},
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-avatar',
|
||||
title: '头像地址',
|
||||
description: '可选,展示在站点顶部与分享卡片中。',
|
||||
helperText: '留空则不显示头像。',
|
||||
key: 'site.author.avatar',
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://cdn.example.com/avatar.png',
|
||||
},
|
||||
icon: 'image',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'site-social-group',
|
||||
title: '社交渠道',
|
||||
description: '填写完整的链接或用户名,展示在站点社交区块。',
|
||||
icon: 'share-2',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-social-twitter',
|
||||
title: 'Twitter',
|
||||
helperText: '支持完整链接或 @用户名。',
|
||||
key: 'site.social.twitter',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: 'https://twitter.com/username',
|
||||
},
|
||||
icon: 'twitter',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-social-github',
|
||||
title: 'GitHub',
|
||||
helperText: '支持完整链接或用户名。',
|
||||
key: 'site.social.github',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: 'https://github.com/username',
|
||||
},
|
||||
icon: 'github',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-social-rss',
|
||||
title: '生成 RSS 订阅源',
|
||||
description: '启用后将在前台站点暴露 RSS 订阅入口。',
|
||||
helperText: '开启后,访客可通过 RSS 订阅最新照片更新。',
|
||||
key: 'site.social.rss',
|
||||
component: {
|
||||
type: 'switch',
|
||||
},
|
||||
icon: 'rss',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'site-feed',
|
||||
title: 'Feed 设置',
|
||||
description: '配置第三方 Feed 数据源,用于聚合内容或挑战进度。',
|
||||
icon: 'radio',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'site-feed-folo',
|
||||
title: 'Folo Challenge',
|
||||
description: '同步 Folo Challenge 数据所需的 Feed ID 与用户 ID。',
|
||||
icon: 'goal',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-feed-folo-feed-id',
|
||||
title: 'Feed ID',
|
||||
key: 'site.feed.folo.challenge.feedId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入 Feed ID',
|
||||
},
|
||||
icon: 'hash',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-feed-folo-user-id',
|
||||
title: 'User ID',
|
||||
key: 'site.feed.folo.challenge.userId',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入 User ID',
|
||||
},
|
||||
icon: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'site-map',
|
||||
title: '地图展示',
|
||||
description: '配置地图组件的可用提供商、样式与投影。',
|
||||
icon: 'map',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-map-providers',
|
||||
title: '地图提供商列表',
|
||||
description: '使用 JSON 数组表示优先级列表,例如 ["maplibre" ]。',
|
||||
helperText: '留空则禁用地图功能。',
|
||||
key: 'site.map.providers',
|
||||
component: {
|
||||
type: 'textarea',
|
||||
placeholder: '["maplibre"]',
|
||||
minRows: 3,
|
||||
maxRows: 6,
|
||||
},
|
||||
icon: 'list',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-map-style',
|
||||
title: '地图样式',
|
||||
description: '填写 MapLibre Style URL,或使用 builtin 选择内置样式。',
|
||||
helperText: '示例:builtin 或 https://tiles.example.com/style.json',
|
||||
key: 'site.mapStyle',
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: 'builtin',
|
||||
},
|
||||
icon: 'paintbrush',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-map-projection',
|
||||
title: '地图投影',
|
||||
description: '选择地图渲染的投影方式。',
|
||||
helperText: '默认为 mercator,可根据需求切换为 globe。',
|
||||
key: 'site.mapProjection',
|
||||
component: {
|
||||
type: 'select',
|
||||
placeholder: '选择投影方式',
|
||||
options: ['mercator', 'globe'],
|
||||
},
|
||||
icon: 'compass',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function collectKeys(nodes: ReadonlyArray<UiNode<SiteSettingKey>>): SiteSettingKey[] {
|
||||
const keys: SiteSettingKey[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
keys.push(node.key)
|
||||
continue
|
||||
}
|
||||
|
||||
keys.push(...collectKeys(node.children))
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export const SITE_SETTING_UI_SCHEMA_KEYS = Array.from(
|
||||
new Set(collectKeys(SITE_SETTING_UI_SCHEMA.sections)),
|
||||
) as SiteSettingKey[]
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
|
||||
<meta name="description" content="Afilmory Dashboard for managing your gallery" />
|
||||
<meta name="favicon" content="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<title>Afilmory Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-hook/window-size": "3.1.1",
|
||||
"@remixicon/react": "4.7.0",
|
||||
"@tanstack/react-form": "1.23.8",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"better-auth": "1.3.34",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -99,4 +100,4 @@
|
||||
"eslint --fix"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
be/apps/dashboard/public/android-chrome-192x192.png
Normal file
BIN
be/apps/dashboard/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
be/apps/dashboard/public/android-chrome-512x512.png
Normal file
BIN
be/apps/dashboard/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
be/apps/dashboard/public/apple-touch-icon.png
Normal file
BIN
be/apps/dashboard/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
be/apps/dashboard/public/favicon-16x16.png
Normal file
BIN
be/apps/dashboard/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 697 B |
BIN
be/apps/dashboard/public/favicon-32x32.png
Normal file
BIN
be/apps/dashboard/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
be/apps/dashboard/public/favicon.ico
Normal file
BIN
be/apps/dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
be/apps/dashboard/public/site.webmanifest
Normal file
1
be/apps/dashboard/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -14,6 +14,10 @@ export interface RegisterTenantPayload {
|
||||
name: string
|
||||
slug: string | null
|
||||
}
|
||||
settings?: Array<{
|
||||
key: string
|
||||
value: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export type RegisterTenantResult = FetchResponse<unknown>
|
||||
|
||||
@@ -1,635 +1 @@
|
||||
import { Button, Checkbox, FormError, Input, Label, ScrollArea } from '@afilmory/ui'
|
||||
import { cx, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
|
||||
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
|
||||
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
|
||||
const REGISTRATION_STEPS = [
|
||||
{
|
||||
id: 'workspace',
|
||||
title: 'Workspace details',
|
||||
description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator account',
|
||||
description: 'Set up the primary administrator who will manage the workspace after creation.',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review & confirm',
|
||||
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: 'workspace' | 'admin' | 'review'
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
|
||||
const STEP_FIELDS: Record<(typeof REGISTRATION_STEPS)[number]['id'], Array<keyof TenantRegistrationFormState>> = {
|
||||
workspace: ['tenantName', 'tenantSlug'],
|
||||
admin: ['accountName', 'email', 'password', 'confirmPassword'],
|
||||
review: ['termsAccepted'],
|
||||
}
|
||||
|
||||
const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100)
|
||||
|
||||
type SidebarProps = {
|
||||
currentStepIndex: number
|
||||
canNavigateTo: (index: number) => boolean
|
||||
onStepSelect: (index: number) => void
|
||||
}
|
||||
|
||||
const RegistrationSidebar: FC<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
|
||||
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
|
||||
<div>
|
||||
<p className="text-accent text-xs font-medium">Workspace Setup</p>
|
||||
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1">
|
||||
{REGISTRATION_STEPS.map((step, index) => {
|
||||
const status: 'done' | 'current' | 'pending' =
|
||||
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
|
||||
const isLast = index === REGISTRATION_STEPS.length - 1
|
||||
const isClickable = canNavigateTo(index)
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{!isLast && (
|
||||
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
|
||||
{status === 'done' && <div className="bg-accent h-full w-full" />}
|
||||
{status === 'current' && (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
|
||||
isClickable ? 'cursor-pointer' : 'cursor-default',
|
||||
!isClickable && 'opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isClickable) onStepSelect(index)
|
||||
}}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 pt-0.5">
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
|
||||
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
|
||||
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
|
||||
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cx(
|
||||
'text-sm font-medium transition-colors duration-200',
|
||||
status === 'done' && 'text-text',
|
||||
status === 'current' && 'text-accent',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
isClickable && status !== 'current' && 'group-hover:text-text',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
'mt-0.5 text-xs transition-colors duration-200',
|
||||
status === 'done' && 'text-text-secondary',
|
||||
status === 'current' && 'text-text-secondary',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
|
||||
</div>
|
||||
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressForStep(currentStepIndex)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
type HeaderProps = {
|
||||
currentStepIndex: number
|
||||
}
|
||||
|
||||
const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
return (
|
||||
<header className="p-8 pb-6">
|
||||
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
|
||||
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
|
||||
</div>
|
||||
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
|
||||
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
disableBack: boolean
|
||||
isSubmitting: boolean
|
||||
isLastStep: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
|
||||
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
{!disableBack ? (
|
||||
<div className="text-text-tertiary text-xs">
|
||||
Adjustments are always possible—use the sidebar or go back to modify earlier details.
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{!disableBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
|
||||
{isLastStep ? 'Create workspace' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
type StepCommonProps = {
|
||||
values: TenantRegistrationFormState
|
||||
errors: Partial<Record<keyof TenantRegistrationFormState, string>>
|
||||
onFieldChange: <Field extends keyof TenantRegistrationFormState>(
|
||||
field: Field,
|
||||
value: TenantRegistrationFormState[Field],
|
||||
) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const WorkspaceStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This information appears in navigation, invitations, and other tenant-facing areas.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-name">Workspace name</Label>
|
||||
<Input
|
||||
id="tenant-name"
|
||||
value={values.tenantName}
|
||||
onChange={(event) => onFieldChange('tenantName', event.currentTarget.value)}
|
||||
placeholder="Acme Studio"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.tenantName)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormError>{errors.tenantName}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tenant-slug">Workspace slug</Label>
|
||||
<Input
|
||||
id="tenant-slug"
|
||||
value={values.tenantSlug}
|
||||
onChange={(event) => onFieldChange('tenantSlug', event.currentTarget.value)}
|
||||
placeholder="acme"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.tenantSlug)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique.
|
||||
</p>
|
||||
<FormError>{errors.tenantSlug}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AdminStep: FC<StepCommonProps> = ({ values, errors, onFieldChange, isLoading }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Administrator</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The first user becomes the workspace administrator and can invite additional members later.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-name">Full name</Label>
|
||||
<Input
|
||||
id="account-name"
|
||||
value={values.accountName}
|
||||
onChange={(event) => onFieldChange('accountName', event.currentTarget.value)}
|
||||
placeholder="Jane Doe"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.accountName)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<FormError>{errors.accountName}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-email">Work email</Label>
|
||||
<Input
|
||||
id="account-email"
|
||||
type="email"
|
||||
value={values.email}
|
||||
onChange={(event) => onFieldChange('email', event.currentTarget.value)}
|
||||
placeholder="jane@acme.studio"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.email)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FormError>{errors.email}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-password">Password</Label>
|
||||
<Input
|
||||
id="account-password"
|
||||
type="password"
|
||||
value={values.password}
|
||||
onChange={(event) => onFieldChange('password', event.currentTarget.value)}
|
||||
placeholder="Create a strong password"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.password)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{errors.password}</FormError>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account-confirm-password">Confirm password</Label>
|
||||
<Input
|
||||
id="account-confirm-password"
|
||||
type="password"
|
||||
value={values.confirmPassword}
|
||||
onChange={(event) => onFieldChange('confirmPassword', event.currentTarget.value)}
|
||||
placeholder="Repeat your password"
|
||||
disabled={isLoading}
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{errors.confirmPassword}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
We recommend using a secure password manager to store credentials for critical roles like the administrator.
|
||||
</p>
|
||||
<SocialAuthButtons
|
||||
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
|
||||
title="Or use single sign-on"
|
||||
requestSignUp
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
type ReviewStepProps = Omit<StepCommonProps, 'onFieldChange'> & {
|
||||
onToggleTerms: (value: boolean) => void
|
||||
serverError: string | null
|
||||
}
|
||||
|
||||
const ReviewStep: FC<ReviewStepProps> = ({ values, errors, onToggleTerms, isLoading, serverError }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Double-check the details below. You can go back to make adjustments before creating the workspace.
|
||||
</p>
|
||||
</section>
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{serverError && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<p className="text-red text-sm">{serverError}</p>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-text text-base font-semibold">Policies</h3>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="text-text flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={values.termsAccepted}
|
||||
onCheckedChange={(checked) => onToggleTerms(checked === true)}
|
||||
disabled={isLoading}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-text-secondary">
|
||||
I agree to the{' '}
|
||||
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<FormError>{errors.termsAccepted}</FormError>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const RegistrationWizard: FC = () => {
|
||||
const { values, errors, updateValue, validate, getFieldError } = useRegistrationForm()
|
||||
const { registerTenant, isLoading, error, clearError } = useRegisterTenant()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0)
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRef.current
|
||||
if (!root) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const selector = [
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(selector))
|
||||
const firstVisible = candidates.find((el) => {
|
||||
if (el.getAttribute('aria-hidden') === 'true') return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return false
|
||||
if ((el as HTMLInputElement).disabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
firstVisible?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentStepIndex])
|
||||
|
||||
const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex])
|
||||
|
||||
const jumpToStep = useCallback(
|
||||
(index: number) => {
|
||||
if (isLoading) return
|
||||
if (index === currentStepIndex) return
|
||||
if (!canNavigateTo(index)) return
|
||||
if (error) clearError()
|
||||
setCurrentStepIndex(index)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, index))
|
||||
},
|
||||
[canNavigateTo, clearError, currentStepIndex, error, isLoading],
|
||||
)
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
<Field extends keyof TenantRegistrationFormState>(field: Field, value: TenantRegistrationFormState[Field]) => {
|
||||
updateValue(field, value)
|
||||
if (error) clearError()
|
||||
},
|
||||
[clearError, error, updateValue],
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (isLoading) return
|
||||
if (currentStepIndex === 0) return
|
||||
if (error) clearError()
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}, [clearError, currentStepIndex, error, isLoading])
|
||||
|
||||
const focusFirstInvalidStep = useCallback(() => {
|
||||
const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) =>
|
||||
STEP_FIELDS[step.id].some((field) => Boolean(getFieldError(field))),
|
||||
)
|
||||
|
||||
if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) {
|
||||
setCurrentStepIndex(invalidStepIndex)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex))
|
||||
}
|
||||
}, [currentStepIndex, getFieldError])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLoading) return
|
||||
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
const fields = STEP_FIELDS[step.id]
|
||||
|
||||
const isStepValid = validate(fields)
|
||||
if (!isStepValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.id === 'review') {
|
||||
const formIsValid = validate()
|
||||
if (!formIsValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
if (error) clearError()
|
||||
|
||||
registerTenant({
|
||||
tenantName: values.tenantName,
|
||||
tenantSlug: values.tenantSlug,
|
||||
accountName: values.accountName,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex((prev) => {
|
||||
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
|
||||
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
|
||||
return nextIndex
|
||||
})
|
||||
}, [
|
||||
clearError,
|
||||
currentStepIndex,
|
||||
error,
|
||||
focusFirstInvalidStep,
|
||||
isLoading,
|
||||
registerTenant,
|
||||
validate,
|
||||
values.accountName,
|
||||
values.email,
|
||||
values.password,
|
||||
values.tenantName,
|
||||
values.tenantSlug,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter') return
|
||||
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean }
|
||||
if (nativeEvent?.isComposing) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
if (target.isContentEditable) return
|
||||
if (target.tagName === 'TEXTAREA') return
|
||||
if (target.tagName === 'BUTTON' || target.tagName === 'A') return
|
||||
if (target.tagName === 'INPUT') {
|
||||
const { type } = target as HTMLInputElement
|
||||
if (type === 'checkbox' || type === 'radio') return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleNext()
|
||||
},
|
||||
[handleNext],
|
||||
)
|
||||
|
||||
const StepComponent = useMemo(() => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
switch (step.id) {
|
||||
case 'workspace': {
|
||||
return <WorkspaceStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
|
||||
}
|
||||
case 'admin': {
|
||||
return <AdminStep values={values} errors={errors} onFieldChange={handleFieldChange} isLoading={isLoading} />
|
||||
}
|
||||
case 'review': {
|
||||
return (
|
||||
<ReviewStep
|
||||
values={values}
|
||||
errors={errors}
|
||||
onToggleTerms={(accepted) => handleFieldChange('termsAccepted', accepted)}
|
||||
isLoading={isLoading}
|
||||
serverError={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}, [currentStepIndex, error, errors, handleFieldChange, isLoading, values])
|
||||
|
||||
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
|
||||
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
|
||||
<div className="grid h-full lg:grid-cols-[280px_1fr]">
|
||||
<div className="relative h-full">
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
|
||||
<RegistrationSidebar
|
||||
currentStepIndex={currentStepIndex}
|
||||
canNavigateTo={canNavigateTo}
|
||||
onStepSelect={jumpToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex h-full w-[700px] flex-col">
|
||||
<div className="shrink-0">
|
||||
<RegistrationHeader currentStepIndex={currentStepIndex} />
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-0 flex-1">
|
||||
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
|
||||
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
|
||||
{StepComponent}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<RegistrationFooter
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={isLoading}
|
||||
isLastStep={isLastStep}
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
|
||||
<p className="text-text-tertiary mt-6 text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { RegistrationWizard } from './registration-wizard/RegistrationWizard'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type FooterProps = {
|
||||
disableBack: boolean
|
||||
isSubmitting: boolean
|
||||
isLastStep: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export const RegistrationFooter: FC<FooterProps> = ({ disableBack, isSubmitting, isLastStep, onBack, onNext }) => (
|
||||
<footer className="flex flex-col gap-3 p-8 pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div />
|
||||
<div className="flex gap-2">
|
||||
{!disableBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="primary" size="md" className="min-w-40" onClick={onNext} isLoading={isSubmitting}>
|
||||
{isLastStep ? 'Create workspace' : 'Continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { REGISTRATION_STEPS } from './constants'
|
||||
|
||||
type HeaderProps = {
|
||||
currentStepIndex: number
|
||||
}
|
||||
|
||||
export const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
return (
|
||||
<header className="p-8 pb-6">
|
||||
<div className="bg-accent/10 text-accent inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium">
|
||||
Step {currentStepIndex + 1} of {REGISTRATION_STEPS.length}
|
||||
</div>
|
||||
<h1 className="text-text mt-4 text-3xl font-bold">{step.title}</h1>
|
||||
<p className="text-text-secondary mt-2 max-w-2xl text-sm">{step.description}</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { cx } from '@afilmory/utils'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { progressForStep,REGISTRATION_STEPS } from './constants'
|
||||
|
||||
type SidebarProps = {
|
||||
currentStepIndex: number
|
||||
canNavigateTo: (index: number) => boolean
|
||||
onStepSelect: (index: number) => void
|
||||
}
|
||||
|
||||
export const RegistrationSidebar: FC<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => (
|
||||
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
|
||||
<div>
|
||||
<p className="text-accent text-xs font-medium">Workspace Setup</p>
|
||||
<h2 className="text-text mt-2 text-base font-semibold">Create your tenant</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1">
|
||||
{REGISTRATION_STEPS.map((step, index) => {
|
||||
const status: 'done' | 'current' | 'pending' =
|
||||
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
|
||||
const isLast = index === REGISTRATION_STEPS.length - 1
|
||||
const isClickable = canNavigateTo(index)
|
||||
|
||||
return (
|
||||
<div key={step.id} className="relative flex gap-3">
|
||||
{!isLast && (
|
||||
<div className="absolute top-7 bottom-0 left-[13px] w-[1.5px]">
|
||||
{status === 'done' && <div className="bg-accent h-full w-full" />}
|
||||
{status === 'current' && (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to bottom, var(--color-accent) 0%, var(--color-accent) 35%, color-mix(in srgb, var(--color-text) 15%, transparent) 100%)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
'group relative flex w-full items-start gap-3 pb-6 text-left transition-all duration-200',
|
||||
isClickable ? 'cursor-pointer' : 'cursor-default',
|
||||
!isClickable && 'opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isClickable) onStepSelect(index)
|
||||
}}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<div className="relative z-10 shrink-0 pt-0.5">
|
||||
<div
|
||||
className={cx(
|
||||
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200',
|
||||
status === 'done' && 'bg-accent text-white ring-4 ring-accent/10',
|
||||
status === 'current' && 'bg-accent text-white ring-4 ring-accent/25',
|
||||
status === 'pending' && 'border-[1.5px] border-text/20 bg-background text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{status === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className={cx(
|
||||
'text-sm font-medium transition-colors duration-200',
|
||||
status === 'done' && 'text-text',
|
||||
status === 'current' && 'text-accent',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
isClickable && status !== 'current' && 'group-hover:text-text',
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
'mt-0.5 text-xs transition-colors duration-200',
|
||||
status === 'done' && 'text-text-secondary',
|
||||
status === 'current' && 'text-text-secondary',
|
||||
status === 'pending' && 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="via-text/20 mb-4 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<div className="text-text-tertiary mb-2 flex items-center justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span className="text-accent font-medium">{progressForStep(currentStepIndex)}%</span>
|
||||
</div>
|
||||
<div className="bg-fill-tertiary relative h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-accent absolute top-0 left-0 h-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressForStep(currentStepIndex)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
@@ -0,0 +1,442 @@
|
||||
import { ScrollArea } from '@afilmory/ui'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { FC, KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useRegisterTenant } from '~/modules/auth/hooks/useRegisterTenant'
|
||||
import type { TenantRegistrationFormState, TenantSiteFieldKey } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { getOnboardingSiteSchema } from '~/modules/onboarding/api'
|
||||
import { LinearBorderContainer } from '~/modules/onboarding/components/LinearBorderContainer'
|
||||
import { SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema'
|
||||
import {
|
||||
coerceSiteFieldValue,
|
||||
collectSchemaFieldMap,
|
||||
createInitialSiteStateFromFieldMap,
|
||||
serializeSiteFieldValue,
|
||||
} from '~/modules/onboarding/utils'
|
||||
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import { REGISTRATION_STEPS, STEP_FIELDS } from './constants'
|
||||
import { RegistrationFooter } from './RegistrationFooter'
|
||||
import { RegistrationHeader } from './RegistrationHeader'
|
||||
import { RegistrationSidebar } from './RegistrationSidebar'
|
||||
import { AdminStep } from './steps/AdminStep'
|
||||
import { ReviewStep } from './steps/ReviewStep'
|
||||
import { SiteSettingsStep } from './steps/SiteSettingsStep'
|
||||
import { WorkspaceStep } from './steps/WorkspaceStep'
|
||||
import { firstErrorMessage } from './utils'
|
||||
|
||||
export const RegistrationWizard: FC = () => {
|
||||
const form = useRegistrationForm()
|
||||
const formValues = useStore(form.store, (state) => state.values)
|
||||
const fieldMeta = useStore(form.store, (state) => state.fieldMeta)
|
||||
const { registerTenant, isLoading, error, clearError } = useRegisterTenant()
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||
const [maxVisitedIndex, setMaxVisitedIndex] = useState(0)
|
||||
const contentRef = useRef<HTMLElement | null>(null)
|
||||
const slugManuallyEditedRef = useRef(false)
|
||||
const siteDefaultsAppliedRef = useRef(false)
|
||||
|
||||
const siteSchemaQuery = useQuery({
|
||||
queryKey: ['onboarding', 'site-schema'],
|
||||
queryFn: getOnboardingSiteSchema,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const [siteSchema, setSiteSchema] = useState<UiSchema<TenantSiteFieldKey> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const data = siteSchemaQuery.data as
|
||||
| {
|
||||
schema?: UiSchema<TenantSiteFieldKey>
|
||||
values?: Partial<Record<TenantSiteFieldKey, SchemaFormValue>>
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.schema && !siteSchema) {
|
||||
setSiteSchema(data.schema)
|
||||
}
|
||||
|
||||
if (!data.schema || siteDefaultsAppliedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldMap = collectSchemaFieldMap(data.schema)
|
||||
const defaults = createInitialSiteStateFromFieldMap(fieldMap)
|
||||
const presetValues = data.values ?? {}
|
||||
|
||||
let applied = false
|
||||
|
||||
for (const [key, field] of fieldMap) {
|
||||
const defaultValue = defaults[key]
|
||||
if (defaultValue !== undefined) {
|
||||
applied = true
|
||||
form.setFieldValue(key, () => defaultValue)
|
||||
}
|
||||
|
||||
const coerced = coerceSiteFieldValue(field, presetValues[key])
|
||||
if (coerced !== undefined) {
|
||||
applied = true
|
||||
form.setFieldValue(key, () => coerced)
|
||||
}
|
||||
}
|
||||
|
||||
if (applied) {
|
||||
siteDefaultsAppliedRef.current = true
|
||||
}
|
||||
}, [form, siteSchema, siteSchemaQuery.data])
|
||||
|
||||
const siteSchemaLoading = siteSchemaQuery.isLoading && !siteSchema
|
||||
const siteSchemaErrorMessage = siteSchemaQuery.isError
|
||||
? siteSchemaQuery.error instanceof Error
|
||||
? siteSchemaQuery.error.message
|
||||
: 'Unable to load site configuration schema from the server.'
|
||||
: undefined
|
||||
|
||||
const siteFieldMap = useMemo(() => {
|
||||
const data = siteSchemaQuery.data as
|
||||
| {
|
||||
schema?: UiSchema<TenantSiteFieldKey>
|
||||
}
|
||||
| undefined
|
||||
const schema = siteSchema ?? data?.schema ?? null
|
||||
return schema ? collectSchemaFieldMap(schema) : null
|
||||
}, [siteSchema, siteSchemaQuery.data])
|
||||
|
||||
const siteFieldKeys = useMemo(
|
||||
() =>
|
||||
siteFieldMap
|
||||
? (Array.from(siteFieldMap.keys()) as TenantSiteFieldKey[])
|
||||
: (SITE_SETTINGS_KEYS as TenantSiteFieldKey[]),
|
||||
[siteFieldMap],
|
||||
)
|
||||
|
||||
const getStepFields = useCallback(
|
||||
(stepId: (typeof REGISTRATION_STEPS)[number]['id']) => {
|
||||
if (stepId === 'site') {
|
||||
return siteFieldKeys as Array<keyof TenantRegistrationFormState>
|
||||
}
|
||||
return STEP_FIELDS[stepId]
|
||||
},
|
||||
[siteFieldKeys],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = contentRef.current
|
||||
if (!root) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const selector = [
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const candidates = Array.from(root.querySelectorAll<HTMLElement>(selector))
|
||||
const firstVisible = candidates.find((el) => {
|
||||
if (el.getAttribute('aria-hidden') === 'true') return false
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) return false
|
||||
if ((el as HTMLInputElement).disabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
firstVisible?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [currentStepIndex])
|
||||
|
||||
const canNavigateTo = useCallback((index: number) => index <= maxVisitedIndex, [maxVisitedIndex])
|
||||
|
||||
const onFieldInteraction = useCallback(() => {
|
||||
if (error) {
|
||||
clearError()
|
||||
}
|
||||
}, [clearError, error])
|
||||
|
||||
const jumpToStep = useCallback(
|
||||
(index: number) => {
|
||||
if (isLoading) return
|
||||
if (index === currentStepIndex) return
|
||||
if (!canNavigateTo(index)) return
|
||||
onFieldInteraction()
|
||||
setCurrentStepIndex(index)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, index))
|
||||
},
|
||||
[canNavigateTo, currentStepIndex, isLoading, onFieldInteraction],
|
||||
)
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (isLoading) return
|
||||
if (currentStepIndex === 0) return
|
||||
onFieldInteraction()
|
||||
setCurrentStepIndex((prev) => Math.max(0, prev - 1))
|
||||
}, [currentStepIndex, isLoading, onFieldInteraction])
|
||||
|
||||
const fieldHasError = useCallback(
|
||||
(field: keyof TenantRegistrationFormState) => {
|
||||
const meta = form.getFieldMeta(field)
|
||||
return Boolean(meta?.errors?.length)
|
||||
},
|
||||
[form],
|
||||
)
|
||||
|
||||
const focusFirstInvalidStep = useCallback(() => {
|
||||
const invalidStepIndex = REGISTRATION_STEPS.findIndex((step) =>
|
||||
getStepFields(step.id).some((field) => fieldHasError(field)),
|
||||
)
|
||||
|
||||
if (invalidStepIndex !== -1 && invalidStepIndex !== currentStepIndex) {
|
||||
setCurrentStepIndex(invalidStepIndex)
|
||||
setMaxVisitedIndex((prev) => Math.max(prev, invalidStepIndex))
|
||||
}
|
||||
}, [currentStepIndex, fieldHasError, getStepFields])
|
||||
|
||||
const ensureStepValid = useCallback(
|
||||
async (stepId: (typeof REGISTRATION_STEPS)[number]['id']) => {
|
||||
const fields = getStepFields(stepId)
|
||||
|
||||
await Promise.all(fields.map((field) => form.validateField(field, 'submit')))
|
||||
|
||||
return fields.every((field) => !fieldHasError(field))
|
||||
},
|
||||
[fieldHasError, form, getStepFields],
|
||||
)
|
||||
|
||||
const handleNext = useCallback(async () => {
|
||||
if (isLoading) return
|
||||
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
if (step.id === 'site') {
|
||||
const result = siteSettingsSchema.safeParse(formValues)
|
||||
if (!result.success) {
|
||||
toast.error(`Error in ${result.error.issues.map((issue) => issue.message).join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex(currentStepIndex + 1)
|
||||
return
|
||||
}
|
||||
const stepIsValid = await ensureStepValid(step.id)
|
||||
|
||||
if (!stepIsValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
if (step.id === 'review') {
|
||||
await form.validateAllFields('submit')
|
||||
const { state } = form
|
||||
if (!state.isFormValid) {
|
||||
focusFirstInvalidStep()
|
||||
return
|
||||
}
|
||||
|
||||
onFieldInteraction()
|
||||
|
||||
const trimmedTenantName = state.values.tenantName.trim()
|
||||
const trimmedTenantSlug = state.values.tenantSlug.trim()
|
||||
const siteSettings = (
|
||||
siteFieldMap && siteFieldMap.size > 0
|
||||
? Array.from(siteFieldMap.entries()).map(([key, field]) => ({
|
||||
key,
|
||||
value: serializeSiteFieldValue(field, state.values[key]),
|
||||
}))
|
||||
: siteFieldKeys.map((key) => {
|
||||
const entry = state.values[key]
|
||||
if (typeof entry === 'boolean') {
|
||||
return { key, value: entry ? 'true' : 'false' }
|
||||
}
|
||||
const text = String(entry ?? '').trim()
|
||||
return { key, value: text }
|
||||
})
|
||||
) as Array<{ key: TenantSiteFieldKey; value: string }>
|
||||
|
||||
registerTenant({
|
||||
tenantName: trimmedTenantName,
|
||||
tenantSlug: trimmedTenantSlug,
|
||||
accountName: state.values.accountName.trim(),
|
||||
email: state.values.email.trim(),
|
||||
password: state.values.password,
|
||||
settings: siteSettings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentStepIndex((prev) => {
|
||||
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
|
||||
setMaxVisitedIndex((visited) => Math.max(visited, nextIndex))
|
||||
return nextIndex
|
||||
})
|
||||
}, [
|
||||
currentStepIndex,
|
||||
ensureStepValid,
|
||||
focusFirstInvalidStep,
|
||||
form,
|
||||
isLoading,
|
||||
onFieldInteraction,
|
||||
registerTenant,
|
||||
siteFieldKeys,
|
||||
siteFieldMap,
|
||||
])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter') return
|
||||
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const nativeEvent = event.nativeEvent as unknown as { isComposing?: boolean }
|
||||
if (nativeEvent?.isComposing) return
|
||||
|
||||
const target = event.target as HTMLElement
|
||||
if (target.isContentEditable) return
|
||||
if (target.tagName === 'TEXTAREA') return
|
||||
if (target.tagName === 'BUTTON' || target.tagName === 'A') return
|
||||
if (target.tagName === 'INPUT') {
|
||||
const { type } = target as HTMLInputElement
|
||||
if (type === 'checkbox' || type === 'radio') return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
void handleNext()
|
||||
},
|
||||
[handleNext],
|
||||
)
|
||||
|
||||
const siteFieldErrors = useMemo(() => {
|
||||
const result: Record<string, string> = {}
|
||||
for (const key of siteFieldKeys) {
|
||||
const meta = fieldMeta?.[key]
|
||||
const message = meta ? firstErrorMessage(meta.errors) : undefined
|
||||
if (message) {
|
||||
result[key] = message
|
||||
}
|
||||
}
|
||||
return result
|
||||
}, [fieldMeta, siteFieldKeys])
|
||||
|
||||
const StepComponent = useMemo(() => {
|
||||
const step = REGISTRATION_STEPS[currentStepIndex]
|
||||
switch (step.id) {
|
||||
case 'workspace': {
|
||||
return (
|
||||
<WorkspaceStep
|
||||
form={form}
|
||||
slugManuallyEditedRef={slugManuallyEditedRef}
|
||||
isSubmitting={isLoading}
|
||||
onFieldInteraction={onFieldInteraction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'site': {
|
||||
return (
|
||||
<SiteSettingsStep
|
||||
form={form}
|
||||
schema={siteSchema}
|
||||
isLoading={siteSchemaLoading}
|
||||
errorMessage={siteSchemaErrorMessage}
|
||||
values={formValues}
|
||||
errors={siteFieldErrors}
|
||||
onFieldInteraction={onFieldInteraction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'admin': {
|
||||
return <AdminStep form={form} isSubmitting={isLoading} onFieldInteraction={onFieldInteraction} />
|
||||
}
|
||||
case 'review': {
|
||||
return (
|
||||
<ReviewStep
|
||||
form={form}
|
||||
values={formValues}
|
||||
siteSchema={siteSchema}
|
||||
siteSchemaLoading={siteSchemaLoading}
|
||||
siteSchemaError={siteSchemaErrorMessage}
|
||||
isSubmitting={isLoading}
|
||||
serverError={error}
|
||||
onFieldInteraction={onFieldInteraction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentStepIndex,
|
||||
error,
|
||||
form,
|
||||
formValues,
|
||||
isLoading,
|
||||
onFieldInteraction,
|
||||
siteFieldErrors,
|
||||
siteSchema,
|
||||
siteSchemaErrorMessage,
|
||||
siteSchemaLoading,
|
||||
])
|
||||
|
||||
const isLastStep = currentStepIndex === REGISTRATION_STEPS.length - 1
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-10">
|
||||
<LinearBorderContainer className="bg-background-tertiary h-[85vh] w-full max-w-5xl">
|
||||
<div className="grid h-full lg:grid-cols-[280px_1fr]">
|
||||
<div className="relative h-full">
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
|
||||
<RegistrationSidebar
|
||||
currentStepIndex={currentStepIndex}
|
||||
canNavigateTo={canNavigateTo}
|
||||
onStepSelect={jumpToStep}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main className="flex h-full w-[700px] flex-col">
|
||||
<div className="shrink-0">
|
||||
<RegistrationHeader currentStepIndex={currentStepIndex} />
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-0 flex-1">
|
||||
<ScrollArea rootClassName="absolute! inset-0 h-full w-full">
|
||||
<section ref={contentRef} className="p-12" onKeyDown={handleKeyDown}>
|
||||
{StepComponent}
|
||||
</section>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<div className="via-text/20 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<RegistrationFooter
|
||||
disableBack={currentStepIndex === 0}
|
||||
isSubmitting={isLoading}
|
||||
isLastStep={isLastStep}
|
||||
onBack={handleBack}
|
||||
onNext={() => {
|
||||
void handleNext()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</LinearBorderContainer>
|
||||
|
||||
<p className="text-text-tertiary mt-6 text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
|
||||
export const REGISTRATION_STEPS = [
|
||||
{
|
||||
id: 'workspace',
|
||||
title: 'Workspace details',
|
||||
description: 'Give your workspace a recognizable name and choose a slug for tenant URLs.',
|
||||
},
|
||||
{
|
||||
id: 'site',
|
||||
title: 'Site information',
|
||||
description: 'Configure the public gallery branding your visitors will see.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator account',
|
||||
description: 'Set up the primary administrator who will manage the workspace after creation.',
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review & confirm',
|
||||
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: 'workspace' | 'site' | 'admin' | 'review'
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
|
||||
export type RegistrationStepId = (typeof REGISTRATION_STEPS)[number]['id']
|
||||
|
||||
export const STEP_FIELDS: Record<RegistrationStepId, Array<keyof TenantRegistrationFormState>> = {
|
||||
workspace: ['tenantName', 'tenantSlug'],
|
||||
site: [],
|
||||
admin: ['accountName', 'email', 'password', 'confirmPassword'],
|
||||
review: ['termsAccepted'],
|
||||
}
|
||||
|
||||
export const progressForStep = (index: number) => Math.round((index / (REGISTRATION_STEPS.length - 1 || 1)) * 100)
|
||||
@@ -0,0 +1,133 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { SocialAuthButtons } from '~/modules/auth/components/SocialAuthButtons'
|
||||
import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
|
||||
import { firstErrorMessage } from '../utils'
|
||||
|
||||
type AdminStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
isSubmitting: boolean
|
||||
onFieldInteraction: () => void
|
||||
}
|
||||
|
||||
export const AdminStep: FC<AdminStepProps> = ({ form, isSubmitting, onFieldInteraction }) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Administrator</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
The first user becomes the workspace administrator and can invite additional members later.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<form.Field name="accountName">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Full name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Jane Doe"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="name"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="email">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Work email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="jane@acme.studio"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="password">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Create a strong password"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="confirmPassword">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Confirm password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Repeat your password"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
We recommend using a secure password manager to store credentials for critical roles like the administrator.
|
||||
</p>
|
||||
<SocialAuthButtons
|
||||
className="border border-white/5 bg-fill/40 rounded-2xl p-4"
|
||||
title="Or use single sign-on"
|
||||
requestSignUp
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Checkbox, FormError } from '@afilmory/ui'
|
||||
import { cx, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type {
|
||||
TenantRegistrationFormState,
|
||||
TenantSiteFieldKey,
|
||||
useRegistrationForm,
|
||||
} from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import type { SchemaFormValue, UiFieldNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import { collectSiteFields, firstErrorMessage } from '../utils'
|
||||
|
||||
type ReviewStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
values: TenantRegistrationFormState
|
||||
siteSchema: UiSchema<string> | null
|
||||
siteSchemaLoading: boolean
|
||||
siteSchemaError?: string
|
||||
isSubmitting: boolean
|
||||
serverError: string | null
|
||||
onFieldInteraction: () => void
|
||||
}
|
||||
|
||||
export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
form,
|
||||
values,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
isSubmitting,
|
||||
serverError,
|
||||
onFieldInteraction,
|
||||
}) => {
|
||||
const formatSiteValue = (value: SchemaFormValue | undefined) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
const text = String(value).trim()
|
||||
return text || '—'
|
||||
}
|
||||
|
||||
const siteSummary = useMemo(() => {
|
||||
if (!siteSchema) {
|
||||
return [] as Array<{ field: UiFieldNode<string>; value: SchemaFormValue | undefined }>
|
||||
}
|
||||
|
||||
return collectSiteFields(siteSchema.sections).map((field) => {
|
||||
const key = field.key as TenantSiteFieldKey
|
||||
return {
|
||||
field,
|
||||
value: values[key],
|
||||
}
|
||||
})
|
||||
}, [siteSchema, values])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Confirm workspace configuration</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
Double-check the details below. You can go back to make adjustments before creating the workspace.
|
||||
</p>
|
||||
</section>
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Workspace slug</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.tenantSlug || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator name</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.accountName || '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">Administrator email</dt>
|
||||
<dd className="text-text mt-1 text-sm font-medium">{values.email || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h3 className="text-text text-base font-semibold">Site details</h3>
|
||||
{siteSchemaLoading && <div className="bg-fill/40 border border-white/5 h-32 animate-pulse rounded-2xl" />}
|
||||
{!siteSchemaLoading && siteSchemaError && (
|
||||
<div className="border-red/60 bg-red/10 rounded-2xl border px-4 py-3 text-sm text-red">{siteSchemaError}</div>
|
||||
)}
|
||||
{!siteSchemaLoading && !siteSchemaError && siteSchema && (
|
||||
<dl className="bg-fill/40 border border-white/5 grid gap-x-6 gap-y-4 rounded-2xl p-6 md:grid-cols-2">
|
||||
{siteSummary.map(({ field, value }) => {
|
||||
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
|
||||
const isMono = field.key === 'site.accentColor'
|
||||
|
||||
return (
|
||||
<div key={field.id} className={cx(spanClass, 'min-w-0')}>
|
||||
<dt className="text-text-tertiary text-xs uppercase tracking-wide">{field.title}</dt>
|
||||
<dd
|
||||
className={cx(
|
||||
'text-text mt-1 text-sm font-medium wrap-break-word',
|
||||
isMono && 'font-mono text-xs tracking-wide text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{formatSiteValue(value)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{serverError && (
|
||||
<m.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={Spring.presets.snappy}
|
||||
className="border-red/60 bg-red/10 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<p className="text-red text-sm">{serverError}</p>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-text text-base font-semibold">Policies</h3>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
Creating a workspace means you agree to comply with our usage guidelines and privacy practices.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<form.Field name="termsAccepted">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<>
|
||||
<label className="text-text flex items-center gap-3 text-sm">
|
||||
<Checkbox
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
onFieldInteraction()
|
||||
field.handleChange(checked === true)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-text-secondary">
|
||||
I agree to the{' '}
|
||||
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
<FormError>{error}</FormError>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type {
|
||||
TenantRegistrationFormState,
|
||||
TenantSiteFieldKey,
|
||||
useRegistrationForm,
|
||||
} from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { SiteStep } from '~/modules/onboarding/components/steps/SiteStep'
|
||||
import type { SchemaFormState, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
type SiteSettingsStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
schema: UiSchema<TenantSiteFieldKey> | null
|
||||
isLoading: boolean
|
||||
errorMessage?: string
|
||||
values: TenantRegistrationFormState
|
||||
errors: Record<string, string>
|
||||
onFieldInteraction: () => void
|
||||
}
|
||||
|
||||
export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
|
||||
form,
|
||||
schema,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
values,
|
||||
errors,
|
||||
}) => {
|
||||
if (!schema) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Site branding</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
These details appear on your public gallery, metadata, and social sharing cards. You can change them later
|
||||
from the dashboard.
|
||||
</p>
|
||||
</section>
|
||||
<div className="bg-fill/40 border border-white/5 h-56 animate-pulse rounded-2xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Site branding</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
We couldn't load the site configuration schema from the server. Refresh the page or contact support.
|
||||
</p>
|
||||
</section>
|
||||
{errorMessage && (
|
||||
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 -mx-6 -mt-12">
|
||||
{errorMessage && (
|
||||
<div className="border-red/50 bg-red/10 rounded-xl border px-4 py-3 text-sm text-red">{errorMessage}</div>
|
||||
)}
|
||||
<SiteStep
|
||||
schema={schema}
|
||||
values={values as SchemaFormState<TenantSiteFieldKey>}
|
||||
errors={errors}
|
||||
onFieldChange={(key, value) => {
|
||||
form.state.values[key] = value
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { FormError, Input, Label } from '@afilmory/ui'
|
||||
import type { FC,MutableRefObject } from 'react'
|
||||
|
||||
import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
|
||||
import { slugify } from '~/modules/onboarding/utils'
|
||||
|
||||
import { firstErrorMessage } from '../utils'
|
||||
|
||||
type WorkspaceStepProps = {
|
||||
form: ReturnType<typeof useRegistrationForm>
|
||||
slugManuallyEditedRef: MutableRefObject<boolean>
|
||||
isSubmitting: boolean
|
||||
onFieldInteraction: () => void
|
||||
}
|
||||
|
||||
export const WorkspaceStep: FC<WorkspaceStepProps> = ({
|
||||
form,
|
||||
slugManuallyEditedRef,
|
||||
isSubmitting,
|
||||
onFieldInteraction,
|
||||
}) => (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-text text-lg font-semibold">Workspace basics</h2>
|
||||
<p className="text-text-secondary text-sm">
|
||||
This information appears in navigation, invitations, and other tenant-facing areas.
|
||||
</p>
|
||||
</section>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<form.Field name="tenantName">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Workspace name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
const nextValue = event.currentTarget.value
|
||||
field.handleChange(nextValue)
|
||||
if (!slugManuallyEditedRef.current) {
|
||||
const nextSlug = slugify(nextValue)
|
||||
if (nextSlug !== form.getFieldValue('tenantSlug')) {
|
||||
form.setFieldValue('tenantSlug', () => nextSlug)
|
||||
void form.validateField('tenantSlug', 'change')
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="Acme Studio"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="organization"
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="tenantSlug">
|
||||
{(field) => {
|
||||
const error = firstErrorMessage(field.state.meta.errors)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Workspace slug</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
onFieldInteraction()
|
||||
slugManuallyEditedRef.current = true
|
||||
field.handleChange(event.currentTarget.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
placeholder="acme"
|
||||
disabled={isSubmitting}
|
||||
error={Boolean(error)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
Lowercase letters, numbers, and hyphen are allowed. We'll ensure the slug is unique.
|
||||
</p>
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { UiFieldNode, UiNode } from '~/modules/schema-form/types'
|
||||
|
||||
export const collectSiteFields = (nodes: ReadonlyArray<UiNode<string>>): Array<UiFieldNode<string>> => {
|
||||
const fields: Array<UiFieldNode<string>> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectSiteFields(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
export const firstErrorMessage = (errors: Array<unknown>): string | undefined => {
|
||||
if (!errors?.length) return undefined
|
||||
const [first] = errors
|
||||
if (first == null) return undefined
|
||||
if (typeof first === 'string') return first
|
||||
if (first instanceof Error) return first.message
|
||||
if (typeof first === 'object' && 'message' in (first as Record<string, unknown>)) {
|
||||
return String((first as Record<string, unknown>).message)
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(first)
|
||||
} catch {
|
||||
return String(first)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import { useState } from 'react'
|
||||
import type { RegisterTenantPayload } from '~/modules/auth/api/registerTenant'
|
||||
import { registerTenant } from '~/modules/auth/api/registerTenant'
|
||||
|
||||
import type { TenantSiteFieldKey } from './useRegistrationForm'
|
||||
|
||||
interface TenantRegistrationRequest {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
email: string
|
||||
password: string
|
||||
settings: Array<{ key: TenantSiteFieldKey; value: string }>
|
||||
}
|
||||
|
||||
const SECOND_LEVEL_PUBLIC_SUFFIXES = new Set(['ac', 'co', 'com', 'edu', 'gov', 'net', 'org'])
|
||||
@@ -84,6 +87,10 @@ export function useRegisterTenant() {
|
||||
},
|
||||
}
|
||||
|
||||
if (data.settings.length > 0) {
|
||||
payload.settings = data.settings
|
||||
}
|
||||
|
||||
const response = await registerTenant(payload)
|
||||
|
||||
const headerSlug = response.headers.get('x-tenant-slug')?.trim().toLowerCase() ?? null
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import { useMemo } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { isLikelyEmail, slugify } from '~/modules/onboarding/utils'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '~/modules/onboarding/siteSchema'
|
||||
import type { SiteFormState } from '~/modules/onboarding/types'
|
||||
import { isLikelyEmail } from '~/modules/onboarding/utils'
|
||||
|
||||
export interface TenantRegistrationFormState {
|
||||
export type TenantSiteFieldKey = (typeof SITE_SETTINGS_KEYS)[number]
|
||||
|
||||
export type TenantRegistrationFormState = SiteFormState & {
|
||||
tenantName: string
|
||||
tenantSlug: string
|
||||
accountName: string
|
||||
@@ -13,18 +19,59 @@ export interface TenantRegistrationFormState {
|
||||
}
|
||||
|
||||
const REQUIRED_PASSWORD_LENGTH = 8
|
||||
const ALL_FIELDS: Array<keyof TenantRegistrationFormState> = [
|
||||
'tenantName',
|
||||
'tenantSlug',
|
||||
'accountName',
|
||||
'email',
|
||||
'password',
|
||||
'confirmPassword',
|
||||
'termsAccepted',
|
||||
]
|
||||
|
||||
export function useRegistrationForm(initial?: Partial<TenantRegistrationFormState>) {
|
||||
const [values, setValues] = useState<TenantRegistrationFormState>({
|
||||
const baseRegistrationSchema = z.object({
|
||||
tenantName: z.string().min(1, { error: 'Workspace name is required' }),
|
||||
tenantSlug: z
|
||||
.string()
|
||||
.min(1, { error: 'Slug is required' })
|
||||
.regex(/^[a-z0-9-]+$/, { error: 'Use lowercase letters, numbers, and hyphen only' }),
|
||||
accountName: z.string().min(1, { error: 'Administrator name is required' }),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, { error: 'Email is required' })
|
||||
.refine((value) => isLikelyEmail(value), { error: 'Enter a valid email address' }),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, { error: 'Password is required' })
|
||||
.min(REQUIRED_PASSWORD_LENGTH, {
|
||||
error: `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`,
|
||||
}),
|
||||
confirmPassword: z.string().min(1, { error: 'Confirm your password' }),
|
||||
termsAccepted: z.boolean({
|
||||
error: 'You must accept the terms to continue',
|
||||
}),
|
||||
})
|
||||
|
||||
export const tenantRegistrationSchema = siteSettingsSchema.merge(baseRegistrationSchema).superRefine((data, ctx) => {
|
||||
if (data.confirmPassword !== '' && data.password !== data.confirmPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
error: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export function buildRegistrationInitialValues(
|
||||
initial?: Partial<TenantRegistrationFormState>,
|
||||
): TenantRegistrationFormState {
|
||||
const siteValues: SiteFormState = { ...DEFAULT_SITE_SETTINGS_VALUES }
|
||||
|
||||
if (initial) {
|
||||
for (const key of SITE_SETTINGS_KEYS) {
|
||||
const value = initial[key]
|
||||
if (value === undefined || value === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || typeof value === 'string' || typeof value === 'number') {
|
||||
siteValues[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tenantName: initial?.tenantName ?? '',
|
||||
tenantSlug: initial?.tenantSlug ?? '',
|
||||
accountName: initial?.accountName ?? '',
|
||||
@@ -32,125 +79,46 @@ export function useRegistrationForm(initial?: Partial<TenantRegistrationFormStat
|
||||
password: initial?.password ?? '',
|
||||
confirmPassword: initial?.confirmPassword ?? '',
|
||||
termsAccepted: initial?.termsAccepted ?? false,
|
||||
})
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof TenantRegistrationFormState, string>>>({})
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false)
|
||||
|
||||
const updateValue = <K extends keyof TenantRegistrationFormState>(
|
||||
field: K,
|
||||
value: TenantRegistrationFormState[K],
|
||||
) => {
|
||||
setValues((prev) => {
|
||||
if (field === 'tenantName' && !slugManuallyEdited) {
|
||||
return {
|
||||
...prev,
|
||||
tenantName: value as string,
|
||||
tenantSlug: slugify(value as string),
|
||||
}
|
||||
}
|
||||
|
||||
if (field === 'tenantSlug') {
|
||||
setSlugManuallyEdited(true)
|
||||
}
|
||||
|
||||
return { ...prev, [field]: value }
|
||||
})
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[field]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fieldError = (field: keyof TenantRegistrationFormState): string | undefined => {
|
||||
switch (field) {
|
||||
case 'tenantName': {
|
||||
return values.tenantName.trim() ? undefined : 'Workspace name is required'
|
||||
}
|
||||
case 'tenantSlug': {
|
||||
const slug = values.tenantSlug.trim()
|
||||
if (!slug) return 'Slug is required'
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) return 'Use lowercase letters, numbers, and hyphen only'
|
||||
return undefined
|
||||
}
|
||||
case 'email': {
|
||||
const email = values.email.trim()
|
||||
if (!email) return 'Email is required'
|
||||
if (!isLikelyEmail(email)) return 'Enter a valid email address'
|
||||
return undefined
|
||||
}
|
||||
case 'accountName': {
|
||||
return values.accountName.trim() ? undefined : 'Administrator name is required'
|
||||
}
|
||||
case 'password': {
|
||||
if (!values.password) return 'Password is required'
|
||||
if (values.password.length < REQUIRED_PASSWORD_LENGTH) {
|
||||
return `Password must be at least ${REQUIRED_PASSWORD_LENGTH} characters`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
case 'confirmPassword': {
|
||||
if (!values.confirmPassword) return 'Confirm your password'
|
||||
if (values.confirmPassword !== values.password) return 'Passwords do not match'
|
||||
return undefined
|
||||
}
|
||||
case 'termsAccepted': {
|
||||
return values.termsAccepted ? undefined : 'You must accept the terms to continue'
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const validate = (fields?: Array<keyof TenantRegistrationFormState>) => {
|
||||
const fieldsToValidate = fields ?? ALL_FIELDS
|
||||
const stepErrors: Partial<Record<keyof TenantRegistrationFormState, string>> = {}
|
||||
let hasErrors = false
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
const error = fieldError(field)
|
||||
if (error) {
|
||||
stepErrors[field] = error
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const field of fieldsToValidate) {
|
||||
const error = stepErrors[field]
|
||||
if (error) {
|
||||
next[field] = error
|
||||
} else {
|
||||
delete next[field]
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
return !hasErrors
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setValues({
|
||||
tenantName: '',
|
||||
tenantSlug: '',
|
||||
accountName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAccepted: false,
|
||||
})
|
||||
setErrors({})
|
||||
setSlugManuallyEdited(false)
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
updateValue,
|
||||
validate,
|
||||
getFieldError: fieldError,
|
||||
reset,
|
||||
...siteValues,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRegistrationValues(values: TenantRegistrationFormState): Record<string, string> {
|
||||
const result = tenantRegistrationSchema.safeParse(values)
|
||||
|
||||
if (result.success) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.join('.')
|
||||
|
||||
if (!path || fieldErrors[path]) {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldErrors[path] = issue.message
|
||||
}
|
||||
|
||||
return fieldErrors
|
||||
}
|
||||
|
||||
export function useRegistrationForm(initial?: Partial<TenantRegistrationFormState>) {
|
||||
const defaultValues = useMemo(() => buildRegistrationInitialValues(initial), [initial])
|
||||
|
||||
return useForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onChange: ({ value }) => {
|
||||
const fieldErrors = validateRegistrationValues(value)
|
||||
return Object.keys(fieldErrors).length > 0 ? { fields: fieldErrors } : undefined
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
const fieldErrors = validateRegistrationValues(value)
|
||||
return Object.keys(fieldErrors).length > 0 ? { fields: fieldErrors } : undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type { OnboardingSettingKey } from './constants'
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
export type OnboardingStatusResponse = {
|
||||
initialized: boolean
|
||||
@@ -17,7 +17,7 @@ export type OnboardingInitPayload = {
|
||||
slug: string
|
||||
}
|
||||
settings?: Array<{
|
||||
key: OnboardingSettingKey
|
||||
key: OnboardingSettingKey | OnboardingSiteSettingKey
|
||||
value: unknown
|
||||
}>
|
||||
}
|
||||
@@ -35,6 +35,12 @@ export async function getOnboardingStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
export async function getOnboardingSiteSchema() {
|
||||
return await coreApi('/onboarding/site-schema', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export async function postOnboardingInit(payload: OnboardingInitPayload) {
|
||||
return await coreApi<OnboardingInitResponse>('/onboarding/init', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -30,14 +30,14 @@ export const LinearBorderContainer: FC<LinearBorderContainerProps> = ({
|
||||
children,
|
||||
className,
|
||||
|
||||
tint = 'var(--color-text)',
|
||||
tint = 'var(--color-text-secondary)',
|
||||
}) => {
|
||||
// Generate inline styles for gradients with dynamic tint color
|
||||
const horizontalGradient = {
|
||||
background: `linear-gradient(to right, transparent, ${tint}, transparent)`,
|
||||
}
|
||||
const verticalGradient = {
|
||||
background: `linear-gradient(to bottom, transparent, ${tint}, transparent)`,
|
||||
background: `linear-gradient(to bottom, transparent -15%, ${tint} 50%, transparent 115%)`,
|
||||
}
|
||||
|
||||
// Advanced mode: uses flex layout for borders that span full dimensions
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LoadingState } from './states/LoadingState'
|
||||
import { AdminStep } from './steps/AdminStep'
|
||||
import { ReviewStep } from './steps/ReviewStep'
|
||||
import { SettingsStep } from './steps/SettingsStep'
|
||||
import { SiteStep } from './steps/SiteStep'
|
||||
import { TenantStep } from './steps/TenantStep'
|
||||
import { WelcomeStep } from './steps/WelcomeStep'
|
||||
|
||||
@@ -30,6 +31,7 @@ export const OnboardingWizard: FC = () => {
|
||||
canNavigateTo,
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
settingsState,
|
||||
acknowledged,
|
||||
setAcknowledged,
|
||||
@@ -39,7 +41,11 @@ export const OnboardingWizard: FC = () => {
|
||||
updateAdminField,
|
||||
toggleSetting,
|
||||
updateSettingValue,
|
||||
updateSiteField,
|
||||
reviewSettings,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
} = wizard
|
||||
|
||||
// Autofocus management: focus first focusable control when step changes
|
||||
@@ -117,12 +123,21 @@ export const OnboardingWizard: FC = () => {
|
||||
return <InitializedState />
|
||||
}
|
||||
|
||||
if (siteSchemaLoading || !siteSchema) {
|
||||
return <LoadingState />
|
||||
}
|
||||
|
||||
if (siteSchemaError) {
|
||||
return <ErrorState />
|
||||
}
|
||||
|
||||
const stepContent: Record<typeof currentStep.id, ReactNode> = {
|
||||
welcome: <WelcomeStep />,
|
||||
tenant: (
|
||||
<TenantStep tenant={tenant} errors={errors} onNameChange={updateTenantName} onSlugChange={updateTenantSlug} />
|
||||
),
|
||||
admin: <AdminStep admin={admin} errors={errors} onChange={updateAdminField} />,
|
||||
site: <SiteStep schema={siteSchema} values={site} errors={errors} onFieldChange={updateSiteField} />,
|
||||
settings: (
|
||||
<SettingsStep
|
||||
settingsState={settingsState}
|
||||
@@ -135,6 +150,10 @@ export const OnboardingWizard: FC = () => {
|
||||
<ReviewStep
|
||||
tenant={tenant}
|
||||
admin={admin}
|
||||
site={site}
|
||||
siteSchema={siteSchema}
|
||||
siteSchemaLoading={false}
|
||||
siteSchemaError={null}
|
||||
reviewSettings={reviewSettings}
|
||||
acknowledged={acknowledged}
|
||||
errors={errors}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Checkbox } from '@afilmory/ui'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { SettingFieldDefinition } from '../../constants'
|
||||
import type { AdminFormState, OnboardingErrors, TenantFormState } from '../../types'
|
||||
import type { SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSiteSettingKey, SettingFieldDefinition } from '../../constants'
|
||||
import type { AdminFormState, OnboardingErrors, SiteFormState, TenantFormState } from '../../types'
|
||||
import { maskSecret } from '../../utils'
|
||||
|
||||
export type ReviewSettingEntry = {
|
||||
@@ -13,15 +15,63 @@ export type ReviewSettingEntry = {
|
||||
type ReviewStepProps = {
|
||||
tenant: TenantFormState
|
||||
admin: AdminFormState
|
||||
site: SiteFormState
|
||||
siteSchema: UiSchema<OnboardingSiteSettingKey>
|
||||
siteSchemaLoading?: boolean
|
||||
siteSchemaError?: string | null
|
||||
reviewSettings: ReviewSettingEntry[]
|
||||
acknowledged: boolean
|
||||
errors: OnboardingErrors
|
||||
onAcknowledgeChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const optionalSiteValue = (value: SchemaFormValue | undefined) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
const lowered = value.toLowerCase()
|
||||
if (lowered === 'true' || lowered === 'false') {
|
||||
return lowered === 'true' ? 'Enabled' : 'Disabled'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function collectSiteFields(
|
||||
nodes: ReadonlyArray<UiNode<OnboardingSiteSettingKey>>,
|
||||
): Array<UiFieldNode<OnboardingSiteSettingKey>> {
|
||||
const fields: Array<UiFieldNode<OnboardingSiteSettingKey>> = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectSiteFields(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
siteSchema,
|
||||
siteSchemaLoading = false,
|
||||
siteSchemaError = null,
|
||||
reviewSettings,
|
||||
acknowledged,
|
||||
errors,
|
||||
@@ -60,6 +110,29 @@ export const ReviewStep: FC<ReviewStepProps> = ({
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Site information</h3>
|
||||
{siteSchemaLoading && <div className="bg-fill/60 border border-white/5 h-24 animate-pulse rounded-xl" />}
|
||||
{!siteSchemaLoading && siteSchemaError && (
|
||||
<div className="border-red/60 bg-red/10 mt-2 rounded-xl border px-4 py-3 text-sm text-red">
|
||||
{siteSchemaError}
|
||||
</div>
|
||||
)}
|
||||
{!siteSchemaLoading && !siteSchemaError && (
|
||||
<dl className="text-text-secondary grid gap-4 text-sm md:grid-cols-2">
|
||||
{collectSiteFields(siteSchema.sections).map((field) => {
|
||||
const spanClass = field.component?.type === 'textarea' ? 'md:col-span-2' : ''
|
||||
return (
|
||||
<div key={field.id} className={`${spanClass} min-w-0`}>
|
||||
<dt className="text-text font-semibold">{field.title}</dt>
|
||||
<dd className="mt-1 leading-relaxed break-words">{optionalSiteValue(site[field.key])}</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-fill-tertiary bg-background rounded-lg border p-6">
|
||||
<h3 className="text-text mb-4 text-sm font-semibold">Enabled integrations</h3>
|
||||
{reviewSettings.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { FC } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { SchemaFormRendererUncontrolled } from '~/modules/schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormValue, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSiteSettingKey } from '../../constants'
|
||||
import type { SiteFormState } from '../../types'
|
||||
|
||||
type SiteStepProps = {
|
||||
schema: UiSchema<OnboardingSiteSettingKey>
|
||||
values: SiteFormState
|
||||
errors: Record<string, string>
|
||||
onFieldChange: (key: OnboardingSiteSettingKey, value: string | boolean) => void
|
||||
}
|
||||
|
||||
export const SiteStep: FC<SiteStepProps> = ({ schema, values, errors, onFieldChange }) => {
|
||||
const schemaWithErrors = useMemo(() => {
|
||||
return {
|
||||
...schema,
|
||||
sections: schema.sections.map((section) => ({
|
||||
...section,
|
||||
children: section.children.map((child: any) => {
|
||||
if (child.type !== 'field') {
|
||||
return child
|
||||
}
|
||||
const error = errors[child.key]
|
||||
return {
|
||||
...child,
|
||||
helperText: error ?? child.helperText ?? null,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}, [errors, schema])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SchemaFormRendererUncontrolled
|
||||
initialValues={values}
|
||||
schema={schemaWithErrors}
|
||||
onChange={(key: OnboardingSiteSettingKey, value: SchemaFormValue) => {
|
||||
onFieldChange(key, typeof value === 'boolean' ? value : value == null ? '' : String(value))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export type OnboardingSettingKey =
|
||||
| 'http.cors.allowedOrigins'
|
||||
| 'services.amap.apiKey'
|
||||
|
||||
export type OnboardingSiteSettingKey = 'site.name' | 'site.title' | 'site.description'
|
||||
|
||||
export type SettingFieldDefinition = {
|
||||
key: OnboardingSettingKey
|
||||
label: string
|
||||
@@ -81,8 +83,8 @@ export const ONBOARDING_SETTING_SECTIONS: SettingSectionDefinition[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const ONBOARDING_TOTAL_STEPS = 5 as const
|
||||
export const ONBOARDING_STEP_ORDER = ['welcome', 'tenant', 'admin', 'settings', 'review'] as const
|
||||
export const ONBOARDING_TOTAL_STEPS = 6 as const
|
||||
export const ONBOARDING_STEP_ORDER = ['welcome', 'tenant', 'site', 'admin', 'settings', 'review'] as const
|
||||
|
||||
export type OnboardingStepId = (typeof ONBOARDING_STEP_ORDER)[number]
|
||||
|
||||
@@ -103,6 +105,11 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
title: 'Tenant Profile',
|
||||
description: 'Name your workspace and choose a slug.',
|
||||
},
|
||||
{
|
||||
id: 'site',
|
||||
title: 'Site Branding',
|
||||
description: 'Set the public gallery information shown to your visitors.',
|
||||
},
|
||||
{
|
||||
id: 'admin',
|
||||
title: 'Administrator',
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { FetchError } from 'ofetch'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { UiFieldNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingInitPayload } from '../api'
|
||||
import { getOnboardingStatus, postOnboardingInit } from '../api'
|
||||
import type { OnboardingSettingKey, OnboardingStepId } from '../constants'
|
||||
import { getOnboardingSiteSchema, getOnboardingStatus, postOnboardingInit } from '../api'
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey, OnboardingStepId } from '../constants'
|
||||
import { ONBOARDING_STEPS } from '../constants'
|
||||
import type { AdminFormState, OnboardingErrors, SettingFormState, TenantFormState } from '../types'
|
||||
import { createInitialSettingsState, getFieldByKey, isLikelyEmail, maskSecret, slugify } from '../utils'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES, SITE_SETTINGS_KEYS, siteSettingsSchema } from '../siteSchema'
|
||||
import type { AdminFormState, OnboardingErrors, SettingFormState, SiteFormState, TenantFormState } from '../types'
|
||||
import {
|
||||
coerceSiteFieldValue,
|
||||
collectSchemaFieldMap,
|
||||
createInitialSettingsState,
|
||||
createInitialSiteStateFromFieldMap,
|
||||
getFieldByKey,
|
||||
isLikelyEmail,
|
||||
maskSecret,
|
||||
serializeSiteFieldValue,
|
||||
slugify,
|
||||
} from '../utils'
|
||||
|
||||
const INITIAL_STEP_INDEX = 0
|
||||
|
||||
@@ -26,8 +39,10 @@ export function useOnboardingWizard() {
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [settingsState, setSettingsState] = useState<SettingFormState>(createInitialSettingsState)
|
||||
const [site, setSite] = useState<SiteFormState>(() => ({ ...DEFAULT_SITE_SETTINGS_VALUES }))
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [errors, setErrors] = useState<OnboardingErrors>({})
|
||||
const [siteDefaultsApplied, setSiteDefaultsApplied] = useState(false)
|
||||
|
||||
const currentStep = ONBOARDING_STEPS[currentStepIndex] ?? ONBOARDING_STEPS[INITIAL_STEP_INDEX]
|
||||
|
||||
@@ -37,6 +52,45 @@ export function useOnboardingWizard() {
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const siteSchemaQuery = useQuery({
|
||||
queryKey: ['onboarding', 'site-schema'],
|
||||
queryFn: getOnboardingSiteSchema,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const siteSchemaData = siteSchemaQuery.data as
|
||||
| {
|
||||
schema?: UiSchema<OnboardingSiteSettingKey>
|
||||
values?: Partial<Record<OnboardingSiteSettingKey, unknown>>
|
||||
}
|
||||
| undefined
|
||||
|
||||
const siteSchema = siteSchemaData?.schema ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteSchema || siteDefaultsApplied) {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldMap = collectSchemaFieldMap(siteSchema)
|
||||
const defaults = createInitialSiteStateFromFieldMap(fieldMap)
|
||||
const values = siteSchemaData?.values ?? {}
|
||||
const next: SiteFormState = { ...DEFAULT_SITE_SETTINGS_VALUES, ...defaults }
|
||||
|
||||
for (const [key, field] of fieldMap) {
|
||||
const coerced = coerceSiteFieldValue(field, values[key])
|
||||
if (coerced !== undefined) {
|
||||
next[key] = coerced
|
||||
}
|
||||
}
|
||||
|
||||
setSite(next)
|
||||
setSiteDefaultsApplied(true)
|
||||
}, [siteDefaultsApplied, siteSchema, siteSchemaData?.values])
|
||||
|
||||
const siteSchemaLoading = siteSchemaQuery.isLoading && !siteSchema
|
||||
const siteSchemaError = siteSchemaQuery.isError
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: OnboardingInitPayload) => postOnboardingInit(payload),
|
||||
onSuccess: () => {
|
||||
@@ -144,6 +198,31 @@ export function useOnboardingWizard() {
|
||||
return valid
|
||||
}
|
||||
|
||||
const validateSite = () => {
|
||||
const candidate: Record<string, unknown> = {}
|
||||
for (const key of SITE_SETTINGS_KEYS) {
|
||||
candidate[key] = site[key] ?? DEFAULT_SITE_SETTINGS_VALUES[key]
|
||||
}
|
||||
|
||||
const result = siteSettingsSchema.safeParse(candidate)
|
||||
const fieldErrors: Record<string, string> = {}
|
||||
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const pathKey = issue.path[0]
|
||||
if (typeof pathKey === 'string' && !(pathKey in fieldErrors)) {
|
||||
fieldErrors[pathKey] = issue.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of SITE_SETTINGS_KEYS) {
|
||||
setFieldError(key, fieldErrors[key] ?? null)
|
||||
}
|
||||
|
||||
return result.success
|
||||
}
|
||||
|
||||
const validateSettings = () => {
|
||||
let valid = true
|
||||
for (const [key, entry] of Object.entries(settingsState) as Array<
|
||||
@@ -175,6 +254,7 @@ export function useOnboardingWizard() {
|
||||
const validators: Partial<Record<OnboardingStepId, () => boolean>> = {
|
||||
welcome: () => true,
|
||||
tenant: validateTenant,
|
||||
site: validateSite,
|
||||
admin: validateAdmin,
|
||||
settings: validateSettings,
|
||||
review: validateAcknowledgement,
|
||||
@@ -200,8 +280,18 @@ export function useOnboardingWizard() {
|
||||
value: entry.value.trim(),
|
||||
}))
|
||||
|
||||
if (settingEntries.length > 0) {
|
||||
payload.settings = settingEntries
|
||||
const fieldMap = siteSchema
|
||||
? collectSchemaFieldMap(siteSchema)
|
||||
: new Map<OnboardingSiteSettingKey, UiFieldNode<OnboardingSiteSettingKey>>()
|
||||
const siteEntries = Array.from(fieldMap.entries()).map(([key, field]) => ({
|
||||
key,
|
||||
value: serializeSiteFieldValue(field, site[key]),
|
||||
}))
|
||||
|
||||
const combined = [...settingEntries, ...siteEntries]
|
||||
|
||||
if (combined.length > 0) {
|
||||
payload.settings = combined as Array<{ key: OnboardingSettingKey | OnboardingSiteSettingKey; value: string }>
|
||||
}
|
||||
|
||||
mutation.mutate(payload)
|
||||
@@ -278,9 +368,21 @@ export function useOnboardingWizard() {
|
||||
value: entry.value.trim(),
|
||||
}))
|
||||
|
||||
const updateSiteField = (key: OnboardingSiteSettingKey, value: string | boolean) => {
|
||||
setSite((prev) => ({
|
||||
...prev,
|
||||
[key]:
|
||||
typeof value === 'boolean' ? value : value == null ? '' : typeof value === 'number' ? String(value) : value,
|
||||
}))
|
||||
setFieldError(key, null)
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
mutation,
|
||||
siteSchema,
|
||||
siteSchemaLoading,
|
||||
siteSchemaError,
|
||||
currentStepIndex,
|
||||
currentStep,
|
||||
goToNext,
|
||||
@@ -289,6 +391,7 @@ export function useOnboardingWizard() {
|
||||
canNavigateTo: (index: number) => index <= currentStepIndex,
|
||||
tenant,
|
||||
admin,
|
||||
site,
|
||||
settingsState,
|
||||
acknowledged,
|
||||
setAcknowledged: (value: boolean) => {
|
||||
@@ -303,6 +406,7 @@ export function useOnboardingWizard() {
|
||||
updateAdminField,
|
||||
toggleSetting,
|
||||
updateSettingValue,
|
||||
updateSiteField,
|
||||
reviewSettings,
|
||||
maskSecret,
|
||||
}
|
||||
|
||||
26
be/apps/dashboard/src/modules/onboarding/siteSchema.ts
Normal file
26
be/apps/dashboard/src/modules/onboarding/siteSchema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
const stringValue = (validator: z.ZodString) => z.preprocess((value) => (value == null ? '' : String(value)), validator)
|
||||
|
||||
const trimmed = (min: number, message: string) => stringValue(z.string().trim().min(min, { message }))
|
||||
|
||||
export const siteSettingsSchema = z
|
||||
.object({
|
||||
'site.name': trimmed(1, 'Site name is required'),
|
||||
'site.title': trimmed(1, 'Home title is required'),
|
||||
'site.description': trimmed(1, 'Site description is required'),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export type SiteSettingsSchema = typeof siteSettingsSchema
|
||||
export type SiteSettingsValues = z.infer<SiteSettingsSchema>
|
||||
|
||||
export const SITE_SETTINGS_KEYS = Object.keys(siteSettingsSchema.shape) as OnboardingSiteSettingKey[]
|
||||
|
||||
export const DEFAULT_SITE_SETTINGS_VALUES: SiteSettingsValues = {
|
||||
'site.name': '',
|
||||
'site.title': '',
|
||||
'site.description': '',
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { OnboardingSettingKey } from './constants'
|
||||
import type { SchemaFormState } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSettingKey, OnboardingSiteSettingKey } from './constants'
|
||||
|
||||
export type TenantFormState = {
|
||||
name: string
|
||||
@@ -20,4 +22,6 @@ export type SettingFormState = Record<
|
||||
}
|
||||
>
|
||||
|
||||
export type SiteFormState = SchemaFormState<OnboardingSiteSettingKey>
|
||||
|
||||
export type OnboardingErrors = Record<string, string>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { SchemaFormState, SchemaFormValue, UiFieldNode, UiNode, UiSchema } from '~/modules/schema-form/types'
|
||||
|
||||
import type { OnboardingSettingKey, SettingFieldDefinition } from './constants'
|
||||
import { ONBOARDING_SETTING_SECTIONS, ONBOARDING_STEPS } from './constants'
|
||||
import { DEFAULT_SITE_SETTINGS_VALUES } from './siteSchema'
|
||||
import type { SettingFormState } from './types'
|
||||
|
||||
export function createInitialSettingsState(): SettingFormState {
|
||||
@@ -47,3 +50,126 @@ export function getFieldByKey(key: OnboardingSettingKey): SettingFieldDefinition
|
||||
}
|
||||
throw new Error(`Unknown onboarding setting key: ${key}`)
|
||||
}
|
||||
|
||||
const traverseSchemaNodes = <Key extends string>(
|
||||
nodes: ReadonlyArray<UiNode<Key>>,
|
||||
map: Map<Key, UiFieldNode<Key>>,
|
||||
) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
map.set(node.key, node)
|
||||
continue
|
||||
}
|
||||
|
||||
traverseSchemaNodes(node.children, map)
|
||||
}
|
||||
}
|
||||
|
||||
export function collectSchemaFieldMap<Key extends string>(schema: UiSchema<Key>): Map<Key, UiFieldNode<Key>> {
|
||||
const map = new Map<Key, UiFieldNode<Key>>()
|
||||
traverseSchemaNodes(schema.sections, map)
|
||||
return map
|
||||
}
|
||||
|
||||
export function collectSchemaFieldKeys<Key extends string>(schema: UiSchema<Key>): Key[] {
|
||||
return [...collectSchemaFieldMap(schema).keys()]
|
||||
}
|
||||
|
||||
export function createInitialSiteStateFromFieldMap<Key extends string>(
|
||||
fieldMap: Map<Key, UiFieldNode<Key>>,
|
||||
): SchemaFormState<Key> {
|
||||
const state = {} as SchemaFormState<Key>
|
||||
for (const [key, field] of fieldMap) {
|
||||
const defaultValue = (DEFAULT_SITE_SETTINGS_VALUES as Record<string, SchemaFormValue | undefined>)[key as string]
|
||||
if (defaultValue !== undefined) {
|
||||
state[key] = defaultValue
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.component.type === 'switch') {
|
||||
state[key] = false
|
||||
continue
|
||||
}
|
||||
|
||||
state[key] = ''
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function createInitialSiteStateFromSchema<Key extends string>(schema: UiSchema<Key>): SchemaFormState<Key> {
|
||||
return createInitialSiteStateFromFieldMap(collectSchemaFieldMap(schema))
|
||||
}
|
||||
|
||||
export function coerceSiteFieldValue<Key extends string>(
|
||||
field: UiFieldNode<Key>,
|
||||
raw: unknown,
|
||||
): SchemaFormValue | undefined {
|
||||
if (raw == null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (field.component.type === 'switch') {
|
||||
if (typeof raw === 'boolean') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
return raw.toLowerCase() === 'true'
|
||||
}
|
||||
return Boolean(raw)
|
||||
}
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
return raw
|
||||
}
|
||||
|
||||
if (typeof raw === 'number') {
|
||||
return String(raw)
|
||||
}
|
||||
|
||||
if (typeof raw === 'boolean') {
|
||||
return raw ? 'true' : 'false'
|
||||
}
|
||||
|
||||
try {
|
||||
return String(raw)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSiteFieldValue<Key extends string>(
|
||||
field: UiFieldNode<Key>,
|
||||
value: SchemaFormValue | undefined,
|
||||
): string {
|
||||
if (field.component.type === 'switch') {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true' ? 'true' : 'false'
|
||||
}
|
||||
return 'false'
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
|
||||
import { LinearBorderPanel } from '../../components/common/GlassPanel'
|
||||
import type {
|
||||
@@ -312,6 +312,27 @@ export interface SchemaFormRendererProps<Key extends string> {
|
||||
renderSlot?: SlotRenderer<Key>
|
||||
}
|
||||
|
||||
export function SchemaFormRendererUncontrolled<Key extends string>({
|
||||
initialValues,
|
||||
onChange,
|
||||
...rest
|
||||
}: Omit<SchemaFormRendererProps<Key>, 'values'> & { initialValues: SchemaFormState<Key> }) {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
|
||||
const handleChange = useMemo(
|
||||
() => (key: Key, value: SchemaFormValue) => {
|
||||
setValues((prev) => {
|
||||
const next = { ...prev, [key]: value }
|
||||
onChange?.(key, value)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
return <SchemaFormRenderer {...rest} values={values} onChange={handleChange} />
|
||||
}
|
||||
|
||||
export function SchemaFormRenderer<Key extends string>({
|
||||
schema,
|
||||
values,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { PageTabs } from '~/components/navigation/PageTabs'
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{
|
||||
id: 'general',
|
||||
label: '通用设置',
|
||||
path: '/settings',
|
||||
id: 'site',
|
||||
label: '站点设置',
|
||||
path: '/settings/site',
|
||||
end: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
16
be/apps/dashboard/src/modules/site-settings/api.ts
Normal file
16
be/apps/dashboard/src/modules/site-settings/api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse } from './types'
|
||||
|
||||
const SITE_SETTINGS_ENDPOINT = '/site/settings'
|
||||
|
||||
export async function getSiteSettingUiSchema() {
|
||||
return await coreApi<SiteSettingUiSchemaResponse>(`${SITE_SETTINGS_ENDPOINT}/ui-schema`)
|
||||
}
|
||||
|
||||
export async function updateSiteSettings(entries: readonly SiteSettingEntryInput[]) {
|
||||
return await coreApi<{ updated: readonly SiteSettingEntryInput[] }>(`${SITE_SETTINGS_ENDPOINT}`, {
|
||||
method: 'POST',
|
||||
body: { entries },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Button } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
|
||||
import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer'
|
||||
import type { SchemaFormValue, UiFieldNode } from '../../schema-form/types'
|
||||
import { collectFieldNodes } from '../../schema-form/utils'
|
||||
import { useSiteSettingUiSchemaQuery, useUpdateSiteSettingsMutation } from '../hooks'
|
||||
import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse, SiteSettingValueState } from '../types'
|
||||
|
||||
function coerceInitialValue(field: UiFieldNode<string>, rawValue: string | null): SchemaFormValue {
|
||||
const { component } = field
|
||||
|
||||
if (component.type === 'switch') {
|
||||
if (typeof rawValue !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = rawValue.trim().toLowerCase()
|
||||
if (normalized === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalized === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return typeof rawValue === 'string' ? rawValue : ''
|
||||
}
|
||||
|
||||
function buildInitialState(
|
||||
schema: SiteSettingUiSchemaResponse['schema'],
|
||||
values: SiteSettingUiSchemaResponse['values'],
|
||||
): SiteSettingValueState<string> {
|
||||
const state: SiteSettingValueState<string> = {} as SiteSettingValueState<string>
|
||||
const fields = collectFieldNodes(schema.sections)
|
||||
|
||||
for (const field of fields) {
|
||||
const rawValue = values[field.key] ?? null
|
||||
state[field.key] = coerceInitialValue(field, rawValue)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function serializeValue(field: UiFieldNode<string>, value: SchemaFormValue | undefined): string {
|
||||
if (field.component.type === 'switch') {
|
||||
return value ? 'true' : 'false'
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function SiteSettingsForm() {
|
||||
const { data, isLoading, isError, error } = useSiteSettingUiSchemaQuery()
|
||||
const updateSettingsMutation = useUpdateSiteSettingsMutation()
|
||||
const { setHeaderActionState } = useMainPageLayout()
|
||||
const formId = useId()
|
||||
const [formState, setFormState] = useState<SiteSettingValueState<string>>({} as SiteSettingValueState<string>)
|
||||
const [initialState, setInitialState] = useState<SiteSettingValueState<string> | null>(null)
|
||||
|
||||
const fieldMap = useMemo(() => {
|
||||
if (!data) {
|
||||
return new Map<string, UiFieldNode<string>>()
|
||||
}
|
||||
|
||||
const fields = collectFieldNodes(data.schema.sections)
|
||||
return new Map(fields.map((field) => [field.key, field]))
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialValues = buildInitialState(data.schema, data.values)
|
||||
startTransition(() => {
|
||||
setFormState(initialValues)
|
||||
setInitialState(initialValues)
|
||||
})
|
||||
}, [data])
|
||||
|
||||
const changedEntries = useMemo<SiteSettingEntryInput[]>(() => {
|
||||
if (!initialState) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries: SiteSettingEntryInput[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(formState)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(initialState, key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (initialState[key] === value) {
|
||||
continue
|
||||
}
|
||||
|
||||
const field = fieldMap.get(key)
|
||||
if (!field) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
value: serializeValue(field, value),
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}, [fieldMap, formState, initialState])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: SchemaFormValue) => {
|
||||
const field = fieldMap.get(key)
|
||||
const normalizedValue: SchemaFormValue = value == null ? (field?.component.type === 'switch' ? false : '') : value
|
||||
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[key]: normalizedValue,
|
||||
}))
|
||||
},
|
||||
[fieldMap],
|
||||
)
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault()
|
||||
if (changedEntries.length === 0 || updateSettingsMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSettingsMutation.mutate(changedEntries)
|
||||
}
|
||||
|
||||
const mutationErrorMessage =
|
||||
updateSettingsMutation.isError && updateSettingsMutation.error
|
||||
? updateSettingsMutation.error instanceof Error
|
||||
? updateSettingsMutation.error.message
|
||||
: '未知错误'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderActionState((prev) => {
|
||||
const nextState = {
|
||||
disabled: isLoading || isError || changedEntries.length === 0,
|
||||
loading: updateSettingsMutation.isPending,
|
||||
}
|
||||
return prev.disabled === nextState.disabled && prev.loading === nextState.loading ? prev : nextState
|
||||
})
|
||||
|
||||
return () => {
|
||||
setHeaderActionState({ disabled: false, loading: false })
|
||||
}
|
||||
}, [changedEntries.length, isError, isLoading, setHeaderActionState, updateSettingsMutation.isPending])
|
||||
|
||||
const headerActionPortal = (
|
||||
<MainPageLayout.Actions>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={changedEntries.length === 0}
|
||||
isLoading={updateSettingsMutation.isPending}
|
||||
loadingText="保存中…"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
</MainPageLayout.Actions>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-fill/40 h-5 w-1/2 animate-pulse rounded-lg" />
|
||||
<div className="space-y-3">
|
||||
{['skeleton-1', 'skeleton-2', 'skeleton-3', 'skeleton-4'].map((key) => (
|
||||
<div key={key} className="bg-fill/30 h-20 animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="text-red flex items-center gap-3 text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>{`无法加载站点设置:${error instanceof Error ? error.message : '未知错误'}`}</span>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return headerActionPortal
|
||||
}
|
||||
|
||||
const { schema } = data
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<m.form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
<SchemaFormRenderer schema={schema} values={formState} onChange={handleChange} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="text-text-tertiary text-xs">
|
||||
{mutationErrorMessage
|
||||
? `保存失败:${mutationErrorMessage}`
|
||||
: updateSettingsMutation.isSuccess && changedEntries.length === 0
|
||||
? '保存成功,站点设置已同步'
|
||||
: changedEntries.length > 0
|
||||
? `有 ${changedEntries.length} 项设置待保存`
|
||||
: '所有设置已同步'}
|
||||
</div>
|
||||
</div>
|
||||
</m.form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
be/apps/dashboard/src/modules/site-settings/hooks.ts
Normal file
28
be/apps/dashboard/src/modules/site-settings/hooks.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getSiteSettingUiSchema, updateSiteSettings } from './api'
|
||||
import type { SiteSettingEntryInput } from './types'
|
||||
|
||||
export const SITE_SETTING_UI_SCHEMA_QUERY_KEY = ['site-settings', 'ui-schema'] as const
|
||||
|
||||
export function useSiteSettingUiSchemaQuery() {
|
||||
return useQuery({
|
||||
queryKey: SITE_SETTING_UI_SCHEMA_QUERY_KEY,
|
||||
queryFn: getSiteSettingUiSchema,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSiteSettingsMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (entries: readonly SiteSettingEntryInput[]) => {
|
||||
await updateSiteSettings(entries)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: SITE_SETTING_UI_SCHEMA_QUERY_KEY,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
4
be/apps/dashboard/src/modules/site-settings/index.ts
Normal file
4
be/apps/dashboard/src/modules/site-settings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './components/SiteSettingsForm'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
13
be/apps/dashboard/src/modules/site-settings/types.ts
Normal file
13
be/apps/dashboard/src/modules/site-settings/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SchemaFormValue, UiSchema } from '../schema-form/types'
|
||||
|
||||
export interface SiteSettingUiSchemaResponse<Key extends string = string> {
|
||||
readonly schema: UiSchema<Key>
|
||||
readonly values: Partial<Record<Key, string | null>>
|
||||
}
|
||||
|
||||
export type SiteSettingValueState<Key extends string = string> = Record<Key, SchemaFormValue | undefined>
|
||||
|
||||
export type SiteSettingEntryInput<Key extends string = string> = {
|
||||
readonly key: Key
|
||||
readonly value: string
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function Component() {
|
||||
return (
|
||||
<MainPageLayout title="系统设置" description="管理后台与核心功能的通用配置,修改后会立即同步生效。">
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="general" />
|
||||
<SettingsNavigation active="site" />
|
||||
<SettingsForm />
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
|
||||
14
be/apps/dashboard/src/pages/(main)/settings/site.tsx
Normal file
14
be/apps/dashboard/src/pages/(main)/settings/site.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
import { SiteSettingsForm } from '~/modules/site-settings'
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<MainPageLayout title="站点设置" description="配置前台站点的品牌信息、社交渠道与地图展示。">
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="site" />
|
||||
<SiteSettingsForm />
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
140
pnpm-lock.yaml
generated
140
pnpm-lock.yaml
generated
@@ -603,7 +603,7 @@ importers:
|
||||
version: 1.19.6(hono@4.10.4)
|
||||
better-auth:
|
||||
specifier: 1.3.34
|
||||
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)
|
||||
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)
|
||||
drizzle-orm:
|
||||
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)
|
||||
@@ -719,12 +719,15 @@ importers:
|
||||
'@remixicon/react':
|
||||
specifier: 4.7.0
|
||||
version: 4.7.0(react@19.2.0)
|
||||
'@tanstack/react-form':
|
||||
specifier: 1.23.8
|
||||
version: 1.23.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.90.5
|
||||
version: 5.90.5(react@19.2.0)
|
||||
better-auth:
|
||||
specifier: 1.3.34
|
||||
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)
|
||||
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)
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -742,7 +745,7 @@ importers:
|
||||
version: 10.2.0
|
||||
jotai:
|
||||
specifier: 2.15.0
|
||||
version: 2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
|
||||
version: 2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
|
||||
lucide-react:
|
||||
specifier: 0.552.0
|
||||
version: 0.552.0(react@19.2.0)
|
||||
@@ -769,7 +772,7 @@ importers:
|
||||
version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react-scan:
|
||||
specifier: 0.4.3
|
||||
version: 0.4.3(@types/react@19.2.2)(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.52.5)
|
||||
version: 0.4.3(@types/react@19.2.2)(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.52.5)
|
||||
sonner:
|
||||
specifier: 2.0.7
|
||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -1341,7 +1344,7 @@ importers:
|
||||
version: 0.15.12(typescript@5.9.3)
|
||||
unplugin-dts:
|
||||
specifier: 1.0.0-beta.6
|
||||
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rolldown@1.0.0-beta.45)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
vite:
|
||||
specifier: 7.1.12
|
||||
version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
@@ -5197,20 +5200,49 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/devtools-event-client@0.3.4':
|
||||
resolution: {integrity: sha512-eq+PpuutUyubXu+ycC1GIiVwBs86NF/8yYJJAKSpPcJLWl6R/761F1H4F/9ziX6zKezltFUH1ah3Cz8Ah+KJrw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/form-core@1.24.4':
|
||||
resolution: {integrity: sha512-+eIR7DiDamit1zvTVgaHxuIRA02YFgJaXMUGxsLRJoBpUjGl/g/nhUocQoNkRyfXqOlh8OCMTanjwDprWSRq6w==}
|
||||
|
||||
'@tanstack/pacer@0.15.4':
|
||||
resolution: {integrity: sha512-vGY+CWsFZeac3dELgB6UZ4c7OacwsLb8hvL2gLS6hTgy8Fl0Bm/aLokHaeDIP+q9F9HUZTnp360z9uv78eg8pg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/query-core@5.90.5':
|
||||
resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==}
|
||||
|
||||
'@tanstack/react-form@1.23.8':
|
||||
resolution: {integrity: sha512-ivfkiOHAI3aIWkCY4FnPWVAL6SkQWGWNVjtwIZpaoJE4ulukZWZ1KB8TQKs8f4STl+egjTsMHrWJuf2fv3Xh1w==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-start': ^1.130.10
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
|
||||
'@tanstack/react-query@5.90.5':
|
||||
resolution: {integrity: sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-store@0.7.7':
|
||||
resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/store@0.7.7':
|
||||
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
@@ -6698,6 +6730,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decode-formdata@0.9.0:
|
||||
resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||
|
||||
@@ -6752,6 +6787,9 @@ packages:
|
||||
detect-node-es@1.1.0:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
devalue@5.4.2:
|
||||
resolution: {integrity: sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
@@ -16272,19 +16310,51 @@ snapshots:
|
||||
tailwindcss: 4.1.16
|
||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
||||
'@tanstack/devtools-event-client@0.3.4': {}
|
||||
|
||||
'@tanstack/form-core@1.24.4':
|
||||
dependencies:
|
||||
'@tanstack/devtools-event-client': 0.3.4
|
||||
'@tanstack/pacer': 0.15.4
|
||||
'@tanstack/store': 0.7.7
|
||||
|
||||
'@tanstack/pacer@0.15.4':
|
||||
dependencies:
|
||||
'@tanstack/devtools-event-client': 0.3.4
|
||||
'@tanstack/store': 0.7.7
|
||||
|
||||
'@tanstack/query-core@5.90.5': {}
|
||||
|
||||
'@tanstack/react-form@1.23.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@tanstack/form-core': 1.24.4
|
||||
'@tanstack/react-store': 0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
decode-formdata: 0.9.0
|
||||
devalue: 5.4.2
|
||||
react: 19.2.0
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@tanstack/react-query@5.90.5(react@19.2.0)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.90.5
|
||||
react: 19.2.0
|
||||
|
||||
'@tanstack/react-store@0.7.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.7.7
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
use-sync-external-store: 1.6.0(react@19.2.0)
|
||||
|
||||
'@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
'@tanstack/store@0.7.7': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tokenizer/inflate@0.2.7':
|
||||
@@ -17325,7 +17395,7 @@ snapshots:
|
||||
|
||||
batch-cluster@15.0.1: {}
|
||||
|
||||
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):
|
||||
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):
|
||||
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/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||
@@ -17342,7 +17412,7 @@ snapshots:
|
||||
nanostores: 1.0.1
|
||||
zod: 4.1.12
|
||||
optionalDependencies:
|
||||
next: 16.0.1(@babel/core@7.28.4)(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)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
@@ -18013,6 +18083,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
supports-color: 5.5.0
|
||||
|
||||
decode-formdata@0.9.0: {}
|
||||
|
||||
decode-named-character-reference@1.2.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
@@ -18055,6 +18127,8 @@ snapshots:
|
||||
|
||||
detect-node-es@1.1.0: {}
|
||||
|
||||
devalue@5.4.2: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -19776,13 +19850,6 @@ snapshots:
|
||||
|
||||
jose@6.1.0: {}
|
||||
|
||||
jotai@2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/template': 7.27.2
|
||||
'@types/react': 19.2.2
|
||||
react: 19.2.0
|
||||
|
||||
jotai@2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -20815,30 +20882,6 @@ snapshots:
|
||||
react: 19.2.0
|
||||
react-dom: 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:
|
||||
'@next/env': 16.0.1
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001752
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.0.1
|
||||
'@next/swc-darwin-x64': 16.0.1
|
||||
'@next/swc-linux-arm64-gnu': 16.0.1
|
||||
'@next/swc-linux-arm64-musl': 16.0.1
|
||||
'@next/swc-linux-x64-gnu': 16.0.1
|
||||
'@next/swc-linux-x64-musl': 16.0.1
|
||||
'@next/swc-win32-arm64-msvc': 16.0.1
|
||||
'@next/swc-win32-x64-msvc': 16.0.1
|
||||
sharp: 0.34.4
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
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:
|
||||
'@next/env': 16.0.1
|
||||
@@ -21949,7 +21992,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
react-scan@0.4.3(@types/react@19.2.2)(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.52.5):
|
||||
react-scan@0.4.3(@types/react@19.2.2)(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
|
||||
@@ -21958,7 +22001,7 @@ snapshots:
|
||||
'@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@4.52.5)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
|
||||
'@types/node': 20.19.24
|
||||
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
|
||||
esbuild: 0.25.11
|
||||
@@ -21971,7 +22014,7 @@ snapshots:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
tsx: 4.20.6
|
||||
optionalDependencies:
|
||||
next: 16.0.1(@babel/core@7.28.4)(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)
|
||||
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
|
||||
@@ -21980,7 +22023,7 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
react-scan@0.4.3(@types/react@19.2.2)(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-scan@0.4.3(@types/react@19.2.2)(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.52.5):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/generator': 7.28.3
|
||||
@@ -21989,7 +22032,7 @@ snapshots:
|
||||
'@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)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
|
||||
'@types/node': 20.19.24
|
||||
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
|
||||
esbuild: 0.25.11
|
||||
@@ -22848,14 +22891,6 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.4
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.4
|
||||
optional: true
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -23285,7 +23320,7 @@ snapshots:
|
||||
magic-string-ast: 1.0.3
|
||||
unplugin: 2.3.10
|
||||
|
||||
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rolldown@1.0.0-beta.45)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.9.2))(esbuild@0.25.11)(rollup@4.52.5)(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
|
||||
'@volar/typescript': 2.4.23
|
||||
@@ -23299,7 +23334,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@microsoft/api-extractor': 7.52.13(@types/node@24.9.2)
|
||||
esbuild: 0.25.11
|
||||
rolldown: 1.0.0-beta.45
|
||||
rollup: 4.52.5
|
||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user