mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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's identity provider to create a workspace. We'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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
}))
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function buildRegistrationInitialValues(
|
||||
return {
|
||||
tenantName: initial?.tenantName ?? '',
|
||||
tenantSlug: initial?.tenantSlug ?? '',
|
||||
termsAccepted: initial?.termsAccepted ?? false,
|
||||
termsAccepted: initial?.termsAccepted ?? true,
|
||||
...siteValues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user