| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134 |
- import { useEffect, useState, type FormEvent } from 'react'
- import { Link, useSearchParams } from 'react-router-dom'
- import { Helmet } from 'react-helmet-async'
- import { PublicLayout } from '../../layouts/PublicLayout'
- import { TEMPLATE_LIMIT, usePageContext, usePreviewUrl } from '../../shared/pageContext'
- import type { SimulationTemplateOption } from '../../services/api/contracts'
- import {
- createPublicSimulation,
- listPublicSimulationTemplates,
- uploadMedia,
- } from '../../services/api/simulations'
- import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
- import { resolveLocalizedTemplateName } from '../../shared/demoContent'
- export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
- export function CustomerSimulationPage() {
- const { locale, navigate, path, copy } = usePageContext()
- const [searchParams] = useSearchParams()
- const pageCopy = copy.customerSimulationFlow
- const [code, setCode] = useState(() => (searchParams.get('code') || '').trim().toUpperCase())
- const [codeChecked, setCodeChecked] = useState(false)
- const [stylistName, setStylistName] = useState('Aitori Stylist')
- const [file, setFile] = useState<File | null>(null)
- const [source, setSource] = useState<'stylist' | 'system'>('stylist')
- const [stylistTemplates, setStylistTemplates] = useState<SimulationTemplateOption[]>([])
- const [systemTemplates, setSystemTemplates] = useState<SimulationTemplateOption[]>([])
- const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
- const [applying, setApplying] = useState(false)
- const [submitting, setSubmitting] = useState(false)
- const [error, setError] = useState('')
- const previewUrl = usePreviewUrl(file, DEFAULT_USER_AVATAR_URL)
- const templates = source === 'stylist' ? stylistTemplates : systemTemplates
- const toggleTemplate = (templateId: string) => {
- setSelectedTemplates((current) => current.includes(templateId)
- ? current.filter((item) => item !== templateId)
- : current.length >= TEMPLATE_LIMIT ? current : [...current, templateId])
- }
- const handleApplyCode = async (nextCode = code) => {
- const normalizedCode = nextCode.trim().toUpperCase()
- if (normalizedCode.length !== 8) {
- setError(pageCopy.errors.codeLength)
- return
- }
- setApplying(true)
- setError('')
- try {
- const data = await listPublicSimulationTemplates(normalizedCode)
- setCode(normalizedCode)
- setCodeChecked(true)
- setStylistName(data.stylist.name)
- setStylistTemplates(data.stylistTemplates.map((template) => ({
- ...template,
- templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale),
- })))
- setSystemTemplates(data.systemTemplates.map((template) => ({
- ...template,
- templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale, copy.systemTemplateNames),
- })))
- setSource('stylist')
- setSelectedTemplates([])
- window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, normalizedCode)
- } catch (err) {
- setCodeChecked(false)
- setStylistName('Aitori Stylist')
- setStylistTemplates([])
- setSystemTemplates([])
- setSelectedTemplates([])
- setError(err instanceof Error ? err.message : pageCopy.errors.invalidCode)
- } finally {
- setApplying(false)
- }
- }
- useEffect(() => {
- const initialCode = (searchParams.get('code') || '').trim().toUpperCase()
- if (initialCode.length === 8) {
- void handleApplyCode(initialCode)
- }
- // Apply invite code from the public profile once on mount.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
- const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
- event.preventDefault()
- if (!codeChecked) return setError(pageCopy.errors.applyCode)
- if (!file) return setError(pageCopy.errors.photo)
- if (!selectedTemplates.length) return setError(pageCopy.errors.template)
- setSubmitting(true)
- setError('')
- try {
- const uploaded = await uploadMedia(file, 'customer-simulation-origin-image')
- const result = await createPublicSimulation({
- inviteCode: code,
- originImage: uploaded.url,
- templateIds: selectedTemplates,
- })
- window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, code)
- navigate(path(`/customer-simulation/result/${result.simulationId}`))
- } catch (err) {
- setError(err instanceof Error ? err.message : pageCopy.errors.submit)
- } finally {
- setSubmitting(false)
- }
- }
- return (
- <PublicLayout footer={false}>
- <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
- <main className="stylist-workbench">
- <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container">
- <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={path('/profile')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link><button type="submit" form="customer-simulation-form" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? pageCopy.submitting : pageCopy.viewResult}</button></div>
- <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px"><span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span><h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1><p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p></div>
- </div></section>
- <section className="pt-0"><div className="container"><form id="customer-simulation-form" onSubmit={handleSubmit}>
- <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4">
- <div className="simulation-step-heading"><span>1</span><div><span className="fs-14 fw-700 text-base-color">{pageCopy.step1.kicker}</span><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step1.title}</h2></div></div>
- <div className="simulation-choice-grid mt-25px mb-25px"><div className="simulation-choice-card is-static"><span className="simulation-choice-icon"><i className="feather icon-feather-gift"></i></span><strong>{pageCopy.step1.cardTitle}</strong><small>{pageCopy.step1.cardBody}</small></div></div>
- <div className="row g-3"><div className="col-md-8"><label className="form-label fw-700 text-dark-gray" htmlFor="stylist-credit-code">{pageCopy.step1.label}</label><input id="stylist-credit-code" type="text" className="form-control border-radius-8px text-uppercase" maxLength={8} placeholder={pageCopy.step1.placeholder} value={code} onChange={(event) => { setCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '')); setCodeChecked(false); setStylistName('Aitori Stylist'); setStylistTemplates([]); setSystemTemplates([]); setSelectedTemplates([]) }} required /></div><div className="col-md-4 d-flex align-items-end"><button type="button" disabled={applying} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 w-100" onClick={() => void handleApplyCode()}><i className="feather icon-feather-check-circle me-10px"></i>{applying ? pageCopy.step1.applying : codeChecked ? pageCopy.step1.applied : pageCopy.step1.apply}</button></div><div className="col-12"><div className="simulation-note-box"><i className="feather icon-feather-info"></i><span>{pageCopy.step1.noCode}<Link to={path('/beta-apply')}>{pageCopy.step1.contact}</Link></span></div></div></div>
- </div>
- <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4"><div className="simulation-step-heading"><span>2</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step2.title}</h2></div></div><label htmlFor="customer-simulation-photo-upload" className="simulation-photo-upload mt-25px"><input id="customer-simulation-photo-upload" type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] || null)} required /><img src={previewUrl} alt={pageCopy.step2.alt} /><span><i className="feather icon-feather-upload me-8px"></i>{pageCopy.step2.support}</span></label></div>
- <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
- <div className="simulation-step-heading"><span>3</span><div><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.step3.title}</h2>{codeChecked ? <small>{pageCopy.step3.stylist.replace('{{name}}', stylistName)}</small> : null}</div></div>
- {codeChecked ? <><div className="simulation-template-tabs mt-25px mb-20px"><label><input type="radio" name="template-source" checked={source === 'stylist'} onChange={() => setSource('stylist')} /><span><i className="feather icon-feather-grid"></i>{pageCopy.step3.stylistTab}</span></label><label><input type="radio" name="template-source" checked={source === 'system'} onChange={() => setSource('system')} /><span><i className="feather icon-feather-layers"></i>{pageCopy.step3.systemTab}</span></label></div><div className="d-flex align-items-center justify-content-end flex-wrap gap-2 mb-25px"><span className="simulation-count-badge">{pageCopy.step3.selected} <strong>{selectedTemplates.length}</strong></span></div>{templates.length ? <div className={`simulation-template-grid mb-30px${source === 'system' ? ' simulation-system-template-grid' : ''}`}>{templates.map((template) => { const active = selectedTemplates.includes(template.id); return <label key={template.id} className="simulation-template-card"><input type="checkbox" checked={active} disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT} onChange={() => toggleTemplate(template.id)} /><img src={template.coverUrl} alt={template.templateName} /><span>{template.templateName}</span></label> })}</div> : <p className="text-medium-gray">{pageCopy.step3.empty}</p>}</> : <div className="simulation-note-box mt-25px"><i className="feather icon-feather-lock"></i><span>{pageCopy.step3.codeRequired}</span></div>}
- </div>
- <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4"><div><strong>{pageCopy.action.title}</strong><span>{pageCopy.action.body}</span>{error ? <span className="text-red mt-5px" role="alert">{error}</span> : null}</div><div className="d-flex flex-wrap gap-2"><Link to={path('/profile')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link><button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-cpu me-10px"></i>{submitting ? pageCopy.submitting : pageCopy.viewResult}</button></div></div>
- </form></div></section>
- </main>
- </PublicLayout>
- )
- }
|