|
|
@@ -11,16 +11,37 @@ import {
|
|
|
} from '../../services/api/simulations'
|
|
|
import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
|
|
|
import { resolveLocalizedTemplateName } from '../../shared/demoContent'
|
|
|
+import {
|
|
|
+ saveSimulationContext,
|
|
|
+ templateParams,
|
|
|
+ trackStylistEvent,
|
|
|
+ type MetaStylist,
|
|
|
+} from '../../shared/metaPixel'
|
|
|
|
|
|
export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
|
|
|
+export const CUSTOMER_SIMULATION_SLUG_KEY = 'aitori-customer-simulation-slug'
|
|
|
+
|
|
|
+// Links back to the stylist the customer came from; the bare /profile route is the demo stylist.
|
|
|
+export function customerProfilePath(path: (target: string) => string, slug?: string | null) {
|
|
|
+ return slug ? path(`/profile/${encodeURIComponent(slug)}`) : path('/profile')
|
|
|
+}
|
|
|
+
|
|
|
+function readStoredCode() {
|
|
|
+ try {
|
|
|
+ return window.sessionStorage.getItem(CUSTOMER_SIMULATION_CODE_KEY) || ''
|
|
|
+ } catch {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
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 [code, setCode] = useState(() => (searchParams.get('code') || readStoredCode()).trim().toUpperCase())
|
|
|
const [codeChecked, setCodeChecked] = useState(false)
|
|
|
const [stylistName, setStylistName] = useState('Aitori Stylist')
|
|
|
+ const [metaStylist, setMetaStylist] = useState<MetaStylist | null>(null)
|
|
|
const [file, setFile] = useState<File | null>(null)
|
|
|
const [source, setSource] = useState<'stylist' | 'system'>('stylist')
|
|
|
const [stylistTemplates, setStylistTemplates] = useState<SimulationTemplateOption[]>([])
|
|
|
@@ -31,14 +52,25 @@ export function CustomerSimulationPage() {
|
|
|
const [error, setError] = useState('')
|
|
|
const previewUrl = usePreviewUrl(file, DEFAULT_USER_AVATAR_URL)
|
|
|
const templates = source === 'stylist' ? stylistTemplates : systemTemplates
|
|
|
+ const profilePath = customerProfilePath(path, metaStylist?.slug)
|
|
|
|
|
|
const toggleTemplate = (templateId: string) => {
|
|
|
+ const adding = !selectedTemplates.includes(templateId) && selectedTemplates.length < TEMPLATE_LIMIT
|
|
|
+ const template = templates.find((item) => item.id === templateId)
|
|
|
+ if (adding && template && metaStylist) {
|
|
|
+ trackStylistEvent('SelectHairstyleTemplate', metaStylist, locale, {
|
|
|
+ template_id: template.id,
|
|
|
+ template_name: template.templateName,
|
|
|
+ template_source: source,
|
|
|
+ entry_point: 'simulation_page',
|
|
|
+ })
|
|
|
+ }
|
|
|
setSelectedTemplates((current) => current.includes(templateId)
|
|
|
? current.filter((item) => item !== templateId)
|
|
|
: current.length >= TEMPLATE_LIMIT ? current : [...current, templateId])
|
|
|
}
|
|
|
|
|
|
- const handleApplyCode = async (nextCode = code) => {
|
|
|
+ const handleApplyCode = async (nextCode = code, initialTemplateId?: string | null) => {
|
|
|
const normalizedCode = nextCode.trim().toUpperCase()
|
|
|
if (normalizedCode.length !== 8) {
|
|
|
setError(pageCopy.errors.codeLength)
|
|
|
@@ -51,6 +83,7 @@ export function CustomerSimulationPage() {
|
|
|
setCode(normalizedCode)
|
|
|
setCodeChecked(true)
|
|
|
setStylistName(data.stylist.name)
|
|
|
+ setMetaStylist({ id: data.stylist.id, slug: data.stylist.slug, displayName: data.stylist.name })
|
|
|
setStylistTemplates(data.stylistTemplates.map((template) => ({
|
|
|
...template,
|
|
|
templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale),
|
|
|
@@ -59,12 +92,18 @@ export function CustomerSimulationPage() {
|
|
|
...template,
|
|
|
templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale, copy.systemTemplateNames),
|
|
|
})))
|
|
|
- setSource('stylist')
|
|
|
- setSelectedTemplates([])
|
|
|
+ // Coming from a template's "try this style" button: keep that template selected.
|
|
|
+ const initialTemplate = initialTemplateId
|
|
|
+ ? [...data.stylistTemplates, ...data.systemTemplates].find((template) => template.id === initialTemplateId)
|
|
|
+ : undefined
|
|
|
+ setSource(initialTemplate?.isSystemTemplate ? 'system' : 'stylist')
|
|
|
+ setSelectedTemplates(initialTemplate ? [initialTemplate.id] : [])
|
|
|
window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, normalizedCode)
|
|
|
+ window.sessionStorage.setItem(CUSTOMER_SIMULATION_SLUG_KEY, data.stylist.slug)
|
|
|
} catch (err) {
|
|
|
setCodeChecked(false)
|
|
|
setStylistName('Aitori Stylist')
|
|
|
+ setMetaStylist(null)
|
|
|
setStylistTemplates([])
|
|
|
setSystemTemplates([])
|
|
|
setSelectedTemplates([])
|
|
|
@@ -75,9 +114,9 @@ export function CustomerSimulationPage() {
|
|
|
}
|
|
|
|
|
|
useEffect(() => {
|
|
|
- const initialCode = (searchParams.get('code') || '').trim().toUpperCase()
|
|
|
+ const initialCode = (searchParams.get('code') || readStoredCode()).trim().toUpperCase()
|
|
|
if (initialCode.length === 8) {
|
|
|
- void handleApplyCode(initialCode)
|
|
|
+ void handleApplyCode(initialCode, searchParams.get('template'))
|
|
|
}
|
|
|
// Apply invite code from the public profile once on mount.
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
@@ -93,12 +132,22 @@ export function CustomerSimulationPage() {
|
|
|
setError('')
|
|
|
try {
|
|
|
const uploaded = await uploadMedia(file, 'customer-simulation-origin-image')
|
|
|
+ const chosenTemplates = selectedTemplates.map((id) => {
|
|
|
+ const template = [...stylistTemplates, ...systemTemplates].find((item) => item.id === id)
|
|
|
+ return { id, name: template?.templateName ?? '' }
|
|
|
+ })
|
|
|
+ if (metaStylist) {
|
|
|
+ trackStylistEvent('PhotoUploadComplete', metaStylist, locale, {
|
|
|
+ template_id: templateParams(chosenTemplates).template_id,
|
|
|
+ })
|
|
|
+ }
|
|
|
const result = await createPublicSimulation({
|
|
|
inviteCode: code,
|
|
|
originImage: uploaded.url,
|
|
|
templateIds: selectedTemplates,
|
|
|
})
|
|
|
window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, code)
|
|
|
+ if (metaStylist) saveSimulationContext(result.simulationId, { stylist: metaStylist, templates: chosenTemplates })
|
|
|
navigate(path(`/customer-simulation/result/${result.simulationId}`))
|
|
|
} catch (err) {
|
|
|
setError(err instanceof Error ? err.message : pageCopy.errors.submit)
|
|
|
@@ -108,25 +157,25 @@ export function CustomerSimulationPage() {
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
- <PublicLayout footer={false}>
|
|
|
+ <PublicLayout footer={false} variant="live" profilePath={profilePath}>
|
|
|
<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="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={profilePath} 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 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'); setMetaStylist(null); 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>
|
|
|
+ <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={profilePath} 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>
|