feat: enhance registration wizard with localization and improved state management

- Integrated i18next for localization across registration wizard components, including headers, footers, and steps.
- Refactored registration steps to utilize a custom hook for managing localized step titles and descriptions.
- Updated various components to improve user experience, including dynamic button labels and error messages.
- Adjusted initial form values to ensure terms acceptance is set to true by default.
- Enhanced the workspace step to derive the tenant name from the slug automatically.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-06 01:03:50 +08:00
parent 62e98a9599
commit 7f9dd316f7
14 changed files with 421 additions and 286 deletions

View File

@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router'
import { PUBLIC_ROUTES } from '~/constants/routes'
import type { SessionResponse } from '~/modules/auth/api/session'
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
import { useStorageProvidersQuery } from '~/modules/storage-providers'
const STORAGE_SETUP_PATH = '/photos/storage'
@@ -28,11 +29,24 @@ export function useRequireStorageProvider({ session, isLoading }: UseRequireStor
enabled: shouldCheck,
})
const managedStoragePlansQuery = useManagedStoragePlansQuery({
enabled: shouldCheck,
})
const hasManagedStoragePlan =
managedStoragePlansQuery.isSuccess &&
managedStoragePlansQuery.data.managedStorageEnabled &&
Boolean(managedStoragePlansQuery.data.currentPlanId)
const isCheckingManagedStoragePlan = managedStoragePlansQuery.isPending
const needsSetup =
shouldCheck &&
storageProvidersQuery.isSuccess &&
(storageProvidersQuery.data?.providers.length ?? 0) === 0 &&
!storageProvidersQuery.isFetching
!storageProvidersQuery.isFetching &&
!isCheckingManagedStoragePlan &&
!hasManagedStoragePlan
const navigateOnceRef = useRef(false)
useEffect(() => {

View File

@@ -1,5 +1,6 @@
import { Button } from '@afilmory/ui'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
type FooterProps = {
disableBack: boolean
@@ -17,33 +18,37 @@ export const RegistrationFooter: FC<FooterProps> = ({
disableNext,
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 && (
}) => {
const { t } = useTranslation()
return (
<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}
>
{t('auth.registration.footer.back')}
</Button>
)}
<Button
type="button"
variant="ghost"
variant="primary"
size="md"
className="text-text-secondary hover:text-text hover:bg-fill/50 min-w-[140px]"
onClick={onBack}
disabled={isSubmitting}
className="min-w-40"
onClick={onNext}
isLoading={isSubmitting}
disabled={disableNext}
>
Back
{isLastStep ? t('auth.registration.footer.create_workspace') : t('auth.registration.footer.continue')}
</Button>
)}
<Button
type="button"
variant="primary"
size="md"
className="min-w-40"
onClick={onNext}
isLoading={isSubmitting}
disabled={disableNext}
>
{isLastStep ? 'Create workspace' : 'Continue'}
</Button>
</div>
</footer>
)
</div>
</footer>
)
}

View File

@@ -1,17 +1,21 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { REGISTRATION_STEPS } from './constants'
import { useRegistrationSteps } from './useRegistrationSteps'
type HeaderProps = {
currentStepIndex: number
}
export const RegistrationHeader: FC<HeaderProps> = ({ currentStepIndex }) => {
const step = REGISTRATION_STEPS[currentStepIndex]
const { t } = useTranslation()
const steps = useRegistrationSteps()
const step = 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}
{t('auth.registration.header.step_indicator', { current: currentStepIndex + 1, total: 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>

View File

@@ -1,7 +1,9 @@
import { cx } from '@afilmory/utils'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { progressForStep, REGISTRATION_STEPS } from './constants'
import { progressForStep } from './constants'
import { useRegistrationSteps } from './useRegistrationSteps'
type SidebarProps = {
currentStepIndex: number
@@ -9,104 +11,109 @@ type SidebarProps = {
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>
export const RegistrationSidebar: FC<SidebarProps> = ({ currentStepIndex, canNavigateTo, onStepSelect }) => {
const { t } = useTranslation()
const steps = useRegistrationSteps()
<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 (
<aside className="hidden min-h-full flex-col gap-6 p-6 lg:flex">
<div>
<p className="text-accent text-xs font-medium">{t('auth.registration.sidebar.workspace_setup')}</p>
<h2 className="text-text mt-2 text-base font-semibold">{t('auth.registration.sidebar.create_tenant')}</h2>
</div>
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>
)}
<div className="relative flex-1">
{steps.map((step, index) => {
const status: 'done' | 'current' | 'pending' =
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : 'pending'
const isLast = index === steps.length - 1
const isClickable = canNavigateTo(index)
<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',
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 === 'done' ? <i className="i-mingcute-check-fill text-sm" /> : <span>{index + 1}</span>}
{status === 'pending' && <div className="bg-text/15 h-full w-full" />}
</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>
<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="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 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="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 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>{t('auth.registration.sidebar.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>
</div>
</aside>
)
</aside>
)
}

View File

@@ -3,6 +3,7 @@ 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 { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { toast } from 'sonner'
@@ -52,6 +53,7 @@ export const RegistrationWizard: FC = () => {
})
const [siteSchema, setSiteSchema] = useState<UiSchema<TenantSiteFieldKey> | null>(null)
const { t } = useTranslation()
const advanceStep = useCallback(() => {
setCurrentStepIndex((prev) => {
const nextIndex = Math.min(REGISTRATION_STEPS.length - 1, prev + 1)
@@ -327,7 +329,7 @@ export const RegistrationWizard: FC = () => {
const step = REGISTRATION_STEPS[currentStepIndex]
if (step.id === 'login') {
if (!authUser) {
toast.error('Please sign in to continue')
toast.error(t('auth.registration.wizard.toast.sign_in_required'))
return
}
advanceStep()
@@ -336,7 +338,11 @@ export const RegistrationWizard: FC = () => {
if (step.id === 'site') {
const result = siteSettingsSchema.safeParse(formValues)
if (!result.success) {
toast.error(`Error in ${result.error.issues.map((issue) => issue.message).join(', ')}`)
toast.error(
t('auth.registration.wizard.toast.validation_error', {
issues: result.error.issues.map((issue) => issue.message).join(', '),
}),
)
return
}
@@ -556,9 +562,9 @@ export const RegistrationWizard: FC = () => {
</LinearBorderContainer>
<p className="text-text-tertiary mt-6 text-sm">
Already have an account?{' '}
{t('auth.registration.wizard.have_account')}{' '}
<Link to="/login" className="text-accent hover:underline">
Sign in
{t('auth.registration.wizard.sign_in')}
</Link>
.
</p>

View File

@@ -3,28 +3,18 @@ import type { TenantRegistrationFormState } from '~/modules/auth/hooks/useRegist
export const REGISTRATION_STEPS = [
{
id: 'login',
title: 'Connect account',
description: 'Sign in with your identity provider to continue.',
},
{
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: 'review',
title: 'Review & confirm',
description: 'Verify everything looks right and accept the terms before provisioning the workspace.',
},
] as const satisfies ReadonlyArray<{
id: 'login' | 'workspace' | 'site' | 'review'
title: string
description: string
}>
export type RegistrationStepId = (typeof REGISTRATION_STEPS)[number]['id']

View File

@@ -1,6 +1,7 @@
import { Button } from '@afilmory/ui'
import { cx } from '@afilmory/utils'
import type { FC } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import type { BetterAuthUser } from '~/modules/auth/types'
@@ -13,50 +14,60 @@ type LoginStepProps = {
isContinuing: boolean
}
export const LoginStep: FC<LoginStepProps> = ({ user, isAuthenticated, onContinue, isContinuing }) => (
<div className="space-y-8">
<section className="space-y-3">
<p className="text-text text-sm">
Afilmory is a modern SaaS{' '}
<a href="https://github.com/Afilmory/Afilmory/blob/main/README.md" target="_blank" rel="noopener noreferrer">
photo gallery platform
</a>{' '}
that auto-syncs your libraries, renders them with WebGL, and powers tenant workspaces. The dashboard is the
command center for those capabilities, so connecting your account lets us personalize the workspace setup for
your team.
</p>
<h2 className="text-text text-lg font-semibold">Sign in to continue</h2>
<p className="text-text-secondary text-sm">
Use your organization&apos;s identity provider to create a workspace. We&apos;ll use your profile details to set
up the initial administrator.
</p>
</section>
export const LoginStep: FC<LoginStepProps> = ({ user, isAuthenticated, onContinue, isContinuing }) => {
const { t } = useTranslation()
{!isAuthenticated ? (
<div className="space-y-4">
<div className="bg-fill/40 rounded border border-white/5 px-6 py-5">
<p className="text-text-secondary text-sm">
Choose your provider below. After completing the sign-in flow you&apos;ll return here automatically.
</p>
return (
<div className="space-y-8">
<section className="space-y-3">
<p className="text-text text-sm">
<Trans i18nKey="auth.registration.steps.login.intro_text">
Afilmory is a modern SaaS{' '}
<a
href="https://github.com/Afilmory/Afilmory/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
>
photo gallery platform
</a>{' '}
that auto-syncs your libraries, renders them with WebGL, and powers tenant workspaces. The dashboard is the
command center for those capabilities, so connecting your account lets us personalize the workspace setup
for your team.
</Trans>
</p>
<h2 className="text-text text-lg font-semibold">{t('auth.registration.steps.login.sign_in_title')}</h2>
<p className="text-text-secondary text-sm">{t('auth.registration.steps.login.sign_in_description')}</p>
</section>
{!isAuthenticated ? (
<div className="space-y-4">
<div className="bg-fill/40 rounded border border-white/5 px-6 py-5">
<p className="text-text-secondary text-sm">{t('auth.registration.steps.login.provider_hint')}</p>
</div>
<SocialAuthButtons
className="max-w-sm"
requestSignUp
title={t('auth.registration.steps.login.continue_with')}
layout="row"
/>
</div>
<SocialAuthButtons className="max-w-sm" requestSignUp title="Continue with" layout="row" />
</div>
) : (
<div className="bg-fill/40 rounded border border-white/5 p-6">
<p className="text-text-secondary text-sm">You&apos;re signed in as</p>
<div className="text-text mt-2 text-lg font-semibold">{user?.name || user?.email}</div>
<div className="text-text-tertiary text-sm">{user?.email}</div>
<Button
type="button"
variant="primary"
size="md"
className={cx('mt-6 min-w-[200px]')}
onClick={onContinue}
isLoading={isContinuing}
>
Continue setup
</Button>
</div>
)}
</div>
)
) : (
<div className="bg-fill/40 rounded border border-white/5 p-6">
<p className="text-text-secondary text-sm">{t('auth.registration.steps.login.signed_in_as')}</p>
<div className="text-text mt-2 text-lg font-semibold">{user?.name || user?.email}</div>
<div className="text-text-tertiary text-sm">{user?.email}</div>
<Button
type="button"
variant="primary"
size="md"
className={cx('mt-6 min-w-[200px]')}
onClick={onContinue}
isLoading={isContinuing}
>
{t('auth.registration.steps.login.continue_setup')}
</Button>
</div>
)}
</div>
)
}

View File

@@ -3,6 +3,7 @@ import { cx, Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type {
TenantRegistrationFormState,
@@ -37,9 +38,13 @@ export const ReviewStep: FC<ReviewStepProps> = ({
serverError,
onFieldInteraction,
}) => {
const { t } = useTranslation()
const formatSiteValue = (value: SchemaFormValue | undefined) => {
if (typeof value === 'boolean') {
return value ? 'Enabled' : 'Disabled'
return value
? t('auth.registration.common.enabled', 'Enabled')
: t('auth.registration.common.disabled', 'Disabled')
}
if (value == null) {
return '—'
@@ -65,32 +70,40 @@ export const ReviewStep: FC<ReviewStepProps> = ({
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>
<h2 className="text-text text-lg font-semibold">{t('auth.registration.steps.review.title_confirm')}</h2>
<p className="text-text-secondary text-sm">{t('auth.registration.steps.review.description_confirm')}</p>
</section>
<dl className="bg-fill/40 grid gap-x-6 gap-y-4 rounded-2xl border border-white/5 p-6 md:grid-cols-2">
<div>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Workspace name</dt>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">
{t('auth.registration.steps.review.label_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 tracking-wide uppercase">Workspace slug</dt>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">
{t('auth.registration.steps.review.label_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 tracking-wide uppercase">Administrator name</dt>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">
{t('auth.registration.steps.review.label_admin_name')}
</dt>
<dd className="text-text mt-1 text-sm font-medium">{authUser?.name || authUser?.email || '—'}</dd>
</div>
<div>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">Administrator email</dt>
<dt className="text-text-tertiary text-xs tracking-wide uppercase">
{t('auth.registration.steps.review.label_admin_email')}
</dt>
<dd className="text-text mt-1 text-sm font-medium">{authUser?.email || '—'}</dd>
</div>
</dl>
<section className="space-y-4">
<h3 className="text-text text-base font-semibold">Site details</h3>
<h3 className="text-text text-base font-semibold">
{t('auth.registration.steps.review.section_site_details')}
</h3>
{siteSchemaLoading && <div className="bg-fill/40 h-32 animate-pulse rounded-2xl border border-white/5" />}
{!siteSchemaLoading && siteSchemaError && (
<div className="border-red/60 bg-red/10 text-red rounded-2xl border px-4 py-3 text-sm">{siteSchemaError}</div>
@@ -132,10 +145,8 @@ export const ReviewStep: FC<ReviewStepProps> = ({
)}
<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>
<h3 className="text-text text-base font-semibold">{t('auth.registration.steps.review.section_policies')}</h3>
<p className="text-text-tertiary text-sm">{t('auth.registration.steps.review.policies_description')}</p>
<div className="space-y-2">
<form.Field name="termsAccepted">
{(field) => {
@@ -153,13 +164,13 @@ export const ReviewStep: FC<ReviewStepProps> = ({
className="mt-0.5"
/>
<span className="text-text-secondary">
I agree to the{' '}
{t('auth.registration.steps.review.terms_agree_pre')}{' '}
<a href="/terms" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Terms of Service
{t('auth.registration.steps.review.terms_link')}
</a>{' '}
and{' '}
{t('auth.registration.steps.review.terms_and')}{' '}
<a href="/privacy" target="_blank" rel="noreferrer" className="text-accent hover:underline">
Privacy Policy
{t('auth.registration.steps.review.privacy_link')}
</a>
.
</span>

View File

@@ -1,4 +1,5 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type {
TenantRegistrationFormState,
@@ -26,16 +27,15 @@ export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
values,
errors,
}) => {
const { t } = useTranslation()
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>
<h2 className="text-text text-lg font-semibold">{t('auth.registration.steps.site.branding_title')}</h2>
<p className="text-text-secondary text-sm">{t('auth.registration.steps.site.branding_description')}</p>
</section>
<div className="bg-fill/40 h-56 animate-pulse rounded-2xl border border-white/5" />
</div>
@@ -45,10 +45,8 @@ export const SiteSettingsStep: FC<SiteSettingsStepProps> = ({
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&apos;t load the site configuration schema from the server. Refresh the page or contact support.
</p>
<h2 className="text-text text-lg font-semibold">{t('auth.registration.steps.site.branding_title')}</h2>
<p className="text-text-secondary text-sm">{t('auth.registration.steps.site.error_loading')}</p>
</section>
{errorMessage && (
<div className="border-red/50 bg-red/10 text-red rounded-xl border px-4 py-3 text-sm">{errorMessage}</div>

View File

@@ -1,11 +1,21 @@
import { FormError, Input, Label } from '@afilmory/ui'
import { useStore } from '@tanstack/react-form'
import type { FC, MutableRefObject } from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import type { useRegistrationForm } from '~/modules/auth/hooks/useRegistrationForm'
import { slugify } from '~/modules/welcome/utils'
import { firstErrorMessage } from '../utils'
const titleCaseFromSlug = (slug: string) =>
slug
.split(/[-_]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
type WorkspaceStepProps = {
form: ReturnType<typeof useRegistrationForm>
slugManuallyEditedRef: MutableRefObject<boolean>
@@ -20,83 +30,97 @@ export const WorkspaceStep: FC<WorkspaceStepProps> = ({
lockedTenantSlug,
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)
}) => {
const { t } = useTranslation()
const slugValue = useStore(form.store, (state) => state.values.tenantSlug)
const tenantNameValue = useStore(form.store, (state) => state.values.tenantName)
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')
useEffect(() => {
if (tenantNameValue || !slugValue) {
return
}
const derivedName = titleCaseFromSlug(slugValue)
if (derivedName) {
form.setFieldValue('tenantName', () => derivedName)
}
}, [form, slugValue, tenantNameValue])
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-text text-lg font-semibold">{t('auth.registration.steps.workspace.basics_title')}</h2>
<p className="text-text-secondary text-sm">{t('auth.registration.steps.workspace.basics_description')}</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}>{t('auth.registration.steps.workspace.label_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)
const isSlugLocked = Boolean(lockedTenantSlug)
const helperText = isSlugLocked
? 'Workspace slug follows the current subdomain and cannot be changed in this flow.'
: 'Lowercase letters, numbers, and hyphen are allowed. We&apos;ll ensure the slug is unique.'
}}
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)
const isSlugLocked = Boolean(lockedTenantSlug)
const helperText = isSlugLocked
? t('auth.registration.steps.workspace.slug_locked_helper')
: t('auth.registration.steps.workspace.slug_helper')
return (
<div className="space-y-2">
<Label htmlFor={field.name}>Workspace slug</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(event) => {
if (isSlugLocked) {
return
}
onFieldInteraction()
slugManuallyEditedRef.current = true
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="acme"
disabled={isSubmitting || isSlugLocked}
readOnly={isSlugLocked}
error={Boolean(error)}
autoComplete="off"
/>
<p className="text-text-tertiary text-xs">{helperText}</p>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
return (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('auth.registration.steps.workspace.label_slug')}</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(event) => {
if (isSlugLocked) {
return
}
onFieldInteraction()
slugManuallyEditedRef.current = true
field.handleChange(event.currentTarget.value)
}}
onBlur={field.handleBlur}
placeholder="acme"
disabled={isSubmitting || isSlugLocked}
readOnly={isSlugLocked}
error={Boolean(error)}
autoComplete="off"
/>
<p className="text-text-tertiary text-xs">{helperText}</p>
<FormError>{error}</FormError>
</div>
)
}}
</form.Field>
</div>
</div>
</div>
)
)
}

View File

@@ -0,0 +1,13 @@
import { useTranslation } from 'react-i18next'
import { REGISTRATION_STEPS } from './constants'
export const useRegistrationSteps = () => {
const { t } = useTranslation()
return REGISTRATION_STEPS.map((step) => ({
...step,
title: t(`auth.registration.steps.${step.id}.title`),
description: t(`auth.registration.steps.${step.id}.description`),
}))
}

View File

@@ -48,7 +48,7 @@ export function buildRegistrationInitialValues(
return {
tenantName: initial?.tenantName ?? '',
tenantSlug: initial?.tenantSlug ?? '',
termsAccepted: initial?.termsAccepted ?? false,
termsAccepted: initial?.termsAccepted ?? true,
...siteValues,
}
}

View File

@@ -5,10 +5,11 @@ import type { ManagedStorageOverview } from './types'
export const MANAGED_STORAGE_PLAN_QUERY_KEY = ['billing', 'storage-plan'] as const
export function useManagedStoragePlansQuery() {
export function useManagedStoragePlansQuery(options?: { enabled?: boolean }) {
return useQuery({
queryKey: MANAGED_STORAGE_PLAN_QUERY_KEY,
queryFn: getManagedStorageOverview,
enabled: options?.enabled ?? true,
})
}