CustomerSimulationPage.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { useEffect, useState, type FormEvent } from 'react'
  2. import { Link, useSearchParams } from 'react-router-dom'
  3. import { Helmet } from 'react-helmet-async'
  4. import { PublicLayout } from '../../layouts/PublicLayout'
  5. import { TEMPLATE_LIMIT, usePageContext, usePreviewUrl } from '../../shared/pageContext'
  6. import type { SimulationTemplateOption } from '../../services/api/contracts'
  7. import {
  8. createPublicSimulation,
  9. listPublicSimulationTemplates,
  10. uploadMedia,
  11. } from '../../services/api/simulations'
  12. import { DEFAULT_USER_AVATAR_URL } from '../../shared/uiAssets'
  13. import { resolveLocalizedTemplateName } from '../../shared/demoContent'
  14. import {
  15. saveSimulationContext,
  16. templateParams,
  17. trackStylistEvent,
  18. type MetaStylist,
  19. } from '../../shared/metaPixel'
  20. export const CUSTOMER_SIMULATION_CODE_KEY = 'aitori-customer-simulation-code'
  21. export const CUSTOMER_SIMULATION_SLUG_KEY = 'aitori-customer-simulation-slug'
  22. // Links back to the stylist the customer came from; the bare /profile route is the demo stylist.
  23. export function customerProfilePath(path: (target: string) => string, slug?: string | null) {
  24. return slug ? path(`/profile/${encodeURIComponent(slug)}`) : path('/profile')
  25. }
  26. function readStoredCode() {
  27. try {
  28. return window.sessionStorage.getItem(CUSTOMER_SIMULATION_CODE_KEY) || ''
  29. } catch {
  30. return ''
  31. }
  32. }
  33. export function CustomerSimulationPage() {
  34. const { locale, navigate, path, copy } = usePageContext()
  35. const [searchParams] = useSearchParams()
  36. const pageCopy = copy.customerSimulationFlow
  37. const [code, setCode] = useState(() => (searchParams.get('code') || readStoredCode()).trim().toUpperCase())
  38. const [codeChecked, setCodeChecked] = useState(false)
  39. const [stylistName, setStylistName] = useState('Aitori Stylist')
  40. const [metaStylist, setMetaStylist] = useState<MetaStylist | null>(null)
  41. const [file, setFile] = useState<File | null>(null)
  42. const [source, setSource] = useState<'stylist' | 'system'>('stylist')
  43. const [stylistTemplates, setStylistTemplates] = useState<SimulationTemplateOption[]>([])
  44. const [systemTemplates, setSystemTemplates] = useState<SimulationTemplateOption[]>([])
  45. const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
  46. const [applying, setApplying] = useState(false)
  47. const [submitting, setSubmitting] = useState(false)
  48. const [error, setError] = useState('')
  49. const previewUrl = usePreviewUrl(file, DEFAULT_USER_AVATAR_URL)
  50. const templates = source === 'stylist' ? stylistTemplates : systemTemplates
  51. const profilePath = customerProfilePath(path, metaStylist?.slug)
  52. const toggleTemplate = (templateId: string) => {
  53. const adding = !selectedTemplates.includes(templateId) && selectedTemplates.length < TEMPLATE_LIMIT
  54. const template = templates.find((item) => item.id === templateId)
  55. if (adding && template && metaStylist) {
  56. trackStylistEvent('SelectHairstyleTemplate', metaStylist, locale, {
  57. template_id: template.id,
  58. template_name: template.templateName,
  59. template_source: source,
  60. entry_point: 'simulation_page',
  61. })
  62. }
  63. setSelectedTemplates((current) => current.includes(templateId)
  64. ? current.filter((item) => item !== templateId)
  65. : current.length >= TEMPLATE_LIMIT ? current : [...current, templateId])
  66. }
  67. const handleApplyCode = async (nextCode = code, initialTemplateId?: string | null) => {
  68. const normalizedCode = nextCode.trim().toUpperCase()
  69. if (normalizedCode.length !== 8) {
  70. setError(pageCopy.errors.codeLength)
  71. return
  72. }
  73. setApplying(true)
  74. setError('')
  75. try {
  76. const data = await listPublicSimulationTemplates(normalizedCode)
  77. setCode(normalizedCode)
  78. setCodeChecked(true)
  79. setStylistName(data.stylist.name)
  80. setMetaStylist({ id: data.stylist.id, slug: data.stylist.slug, displayName: data.stylist.name })
  81. setStylistTemplates(data.stylistTemplates.map((template) => ({
  82. ...template,
  83. templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale),
  84. })))
  85. setSystemTemplates(data.systemTemplates.map((template) => ({
  86. ...template,
  87. templateName: resolveLocalizedTemplateName(template.templateName, template.templateNameI18n, locale, copy.systemTemplateNames),
  88. })))
  89. // Coming from a template's "try this style" button: keep that template selected.
  90. const initialTemplate = initialTemplateId
  91. ? [...data.stylistTemplates, ...data.systemTemplates].find((template) => template.id === initialTemplateId)
  92. : undefined
  93. setSource(initialTemplate?.isSystemTemplate ? 'system' : 'stylist')
  94. setSelectedTemplates(initialTemplate ? [initialTemplate.id] : [])
  95. window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, normalizedCode)
  96. window.sessionStorage.setItem(CUSTOMER_SIMULATION_SLUG_KEY, data.stylist.slug)
  97. } catch (err) {
  98. setCodeChecked(false)
  99. setStylistName('Aitori Stylist')
  100. setMetaStylist(null)
  101. setStylistTemplates([])
  102. setSystemTemplates([])
  103. setSelectedTemplates([])
  104. setError(err instanceof Error ? err.message : pageCopy.errors.invalidCode)
  105. } finally {
  106. setApplying(false)
  107. }
  108. }
  109. useEffect(() => {
  110. const initialCode = (searchParams.get('code') || readStoredCode()).trim().toUpperCase()
  111. if (initialCode.length === 8) {
  112. void handleApplyCode(initialCode, searchParams.get('template'))
  113. }
  114. // Apply invite code from the public profile once on mount.
  115. // eslint-disable-next-line react-hooks/exhaustive-deps
  116. }, [])
  117. const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
  118. event.preventDefault()
  119. if (!codeChecked) return setError(pageCopy.errors.applyCode)
  120. if (!file) return setError(pageCopy.errors.photo)
  121. if (!selectedTemplates.length) return setError(pageCopy.errors.template)
  122. setSubmitting(true)
  123. setError('')
  124. try {
  125. const uploaded = await uploadMedia(file, 'customer-simulation-origin-image')
  126. const chosenTemplates = selectedTemplates.map((id) => {
  127. const template = [...stylistTemplates, ...systemTemplates].find((item) => item.id === id)
  128. return { id, name: template?.templateName ?? '' }
  129. })
  130. if (metaStylist) {
  131. trackStylistEvent('PhotoUploadComplete', metaStylist, locale, {
  132. template_id: templateParams(chosenTemplates).template_id,
  133. })
  134. }
  135. const result = await createPublicSimulation({
  136. inviteCode: code,
  137. originImage: uploaded.url,
  138. templateIds: selectedTemplates,
  139. })
  140. window.sessionStorage.setItem(CUSTOMER_SIMULATION_CODE_KEY, code)
  141. if (metaStylist) saveSimulationContext(result.simulationId, { stylist: metaStylist, templates: chosenTemplates })
  142. navigate(path(`/customer-simulation/result/${result.simulationId}`))
  143. } catch (err) {
  144. setError(err instanceof Error ? err.message : pageCopy.errors.submit)
  145. } finally {
  146. setSubmitting(false)
  147. }
  148. }
  149. return (
  150. <PublicLayout footer={false} variant="live" profilePath={profilePath}>
  151. <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title><meta name="description" content={pageCopy.description} /></Helmet>
  152. <main className="stylist-workbench">
  153. <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container">
  154. <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>
  155. <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>
  156. </div></section>
  157. <section className="pt-0"><div className="container"><form id="customer-simulation-form" onSubmit={handleSubmit}>
  158. <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4">
  159. <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>
  160. <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>
  161. <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>
  162. </div>
  163. <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>
  164. <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
  165. <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>
  166. {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>}
  167. </div>
  168. <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>
  169. </form></div></section>
  170. </main>
  171. </PublicLayout>
  172. )
  173. }