StylistSimulationPage.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import React, { useEffect, useState } from 'react'
  2. import { Link, useSearchParams } from 'react-router-dom'
  3. import { Helmet } from 'react-helmet-async'
  4. import { DashboardLayout } from '../../../layouts/DashboardLayout'
  5. import { listCustomers } from '../../../services/api/customers'
  6. import type { CustomerEntity } from '../../../services/api/customers'
  7. import type { SimulationTemplateOption } from '../../../services/api/contracts'
  8. import {
  9. createSimulation,
  10. listSimulationTemplates,
  11. uploadMedia,
  12. } from '../../../services/api/simulations'
  13. import {
  14. TEMPLATE_LIMIT,
  15. usePageContext,
  16. usePreviewUrl,
  17. } from '../../../shared/pageContext'
  18. import { DEFAULT_USER_AVATAR_URL } from '../../../shared/uiAssets'
  19. export function StylistSimulationPage() {
  20. const [searchParams] = useSearchParams()
  21. const { locale, navigate, path, copy } = usePageContext()
  22. const presetCustomerId = searchParams.get('customerId') ?? ''
  23. const pageCopy = copy.simulationCreatePage
  24. const [bindCustomer, setBindCustomer] = useState(Boolean(presetCustomerId))
  25. const [selectedCustomerId, setSelectedCustomerId] = useState(presetCustomerId)
  26. const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST')
  27. const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
  28. const [customers, setCustomers] = useState<CustomerEntity[]>([])
  29. const [templates, setTemplates] = useState<SimulationTemplateOption[]>([])
  30. const [file, setFile] = useState<File | null>(null)
  31. const [originImageUrl, setOriginImageUrl] = useState('')
  32. const [remark, setRemark] = useState('')
  33. const [uploading, setUploading] = useState(false)
  34. const [submitting, setSubmitting] = useState(false)
  35. const [error, setError] = useState('')
  36. const previewUrl = usePreviewUrl(file, originImageUrl || DEFAULT_USER_AVATAR_URL)
  37. const selectedCustomer = customers.find((item) => item.id === selectedCustomerId) || null
  38. useEffect(() => {
  39. void listCustomers().then((result) => {
  40. setCustomers(result.items)
  41. if (presetCustomerId && result.items.some((item) => item.id === presetCustomerId)) {
  42. setBindCustomer(true)
  43. setSelectedCustomerId(presetCustomerId)
  44. }
  45. })
  46. }, [presetCustomerId])
  47. useEffect(() => {
  48. void listSimulationTemplates({
  49. source: templateSource,
  50. page: 1,
  51. pageSize: 12,
  52. })
  53. .then((result) => {
  54. setTemplates(result.items)
  55. })
  56. .catch(() => {
  57. setTemplates([])
  58. })
  59. }, [templateSource])
  60. const toggleTemplate = (templateId: string) => {
  61. setSelectedTemplates((current) => {
  62. if (current.includes(templateId)) {
  63. return current.filter((item) => item !== templateId)
  64. }
  65. if (current.length >= TEMPLATE_LIMIT) {
  66. return current
  67. }
  68. return [...current, templateId]
  69. })
  70. }
  71. const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
  72. const nextFile = event.target.files?.[0] || null
  73. setFile(nextFile)
  74. setError('')
  75. if (!nextFile) {
  76. setOriginImageUrl('')
  77. return
  78. }
  79. setUploading(true)
  80. try {
  81. const uploaded = await uploadMedia(nextFile, 'ai-user-origin-image')
  82. setOriginImageUrl(uploaded.url)
  83. } catch (uploadError) {
  84. setOriginImageUrl('')
  85. setError(
  86. uploadError instanceof Error
  87. ? uploadError.message
  88. : pageCopy.errors.submitFailed,
  89. )
  90. } finally {
  91. setUploading(false)
  92. }
  93. }
  94. const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
  95. event.preventDefault()
  96. if (submitting) return
  97. if (bindCustomer && !selectedCustomerId) {
  98. setError(pageCopy.errors.customerRequired)
  99. return
  100. }
  101. if (!originImageUrl) {
  102. setError(pageCopy.errors.originRequired)
  103. return
  104. }
  105. if (selectedTemplates.length === 0) {
  106. setError(pageCopy.errors.templateRequired)
  107. return
  108. }
  109. setError('')
  110. setSubmitting(true)
  111. try {
  112. const response = await createSimulation({
  113. customerId: bindCustomer && selectedCustomerId ? selectedCustomerId : undefined,
  114. originImage: originImageUrl,
  115. templateIds: selectedTemplates,
  116. simulateRemark: remark,
  117. })
  118. navigate(path(`/simulation/result/${response.simulationId}`))
  119. } catch (submitError) {
  120. setError(
  121. submitError instanceof Error
  122. ? submitError.message
  123. : pageCopy.errors.submitFailed,
  124. )
  125. } finally {
  126. setSubmitting(false)
  127. }
  128. }
  129. return (
  130. <DashboardLayout>
  131. <Helmet>
  132. <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
  133. <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
  134. <meta name="description" content={pageCopy.description} />
  135. </Helmet>
  136. <section className="stylist-workbench-top ipad-top-space-margin mb-2">
  137. <div className="container">
  138. <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
  139. <Link
  140. to={path('/dashboard')}
  141. className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
  142. >
  143. <i className="feather icon-feather-arrow-left me-6px"></i>
  144. {pageCopy.back}
  145. </Link>
  146. <button
  147. type="submit"
  148. form="simulation-form"
  149. disabled={submitting || uploading}
  150. className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
  151. >
  152. <i className="feather icon-feather-cpu me-10px"></i>
  153. {submitting ? '...' : pageCopy.submit}
  154. </button>
  155. </div>
  156. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
  157. <span className="bg-base-color d-inline-block mb-15px fw-600 text-white border-radius-30px ps-20px pe-20px fs-12">
  158. {pageCopy.badge}
  159. </span>
  160. <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
  161. <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
  162. </div>
  163. </div>
  164. </section>
  165. <section className="pt-0">
  166. <div className="container">
  167. <form id="simulation-form" onSubmit={handleSubmit}>
  168. <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4">
  169. <div className="simulation-step-heading">
  170. <span>1</span>
  171. <div>
  172. <span className="fs-14 fw-700 text-base-color">{pageCopy.bindCustomer}</span>
  173. <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.bindCustomer}</h2>
  174. </div>
  175. </div>
  176. <div className="simulation-choice-grid mt-25px">
  177. <label className="simulation-choice-card">
  178. <input
  179. type="radio"
  180. name="customer-mode"
  181. aria-label={pageCopy.bindCustomer}
  182. checked={bindCustomer}
  183. onChange={() => setBindCustomer(true)}
  184. />
  185. <span className="simulation-choice-icon">
  186. <i className="feather icon-feather-user-check"></i>
  187. </span>
  188. <strong>{pageCopy.bindCustomer}</strong>
  189. <small>{pageCopy.customerPlaceholder}</small>
  190. </label>
  191. <label className="simulation-choice-card">
  192. <input
  193. type="radio"
  194. name="customer-mode"
  195. aria-label={pageCopy.noCustomer}
  196. checked={!bindCustomer}
  197. onChange={() => setBindCustomer(false)}
  198. />
  199. <span className="simulation-choice-icon">
  200. <i className="feather icon-feather-user-x"></i>
  201. </span>
  202. <strong>{pageCopy.noCustomer}</strong>
  203. <small>{pageCopy.noCustomer}</small>
  204. </label>
  205. </div>
  206. <div className="simulation-selected-customer mt-25px">
  207. <img
  208. src={selectedCustomer?.avatar || DEFAULT_USER_AVATAR_URL}
  209. alt={selectedCustomer?.name || pageCopy.customerLabel}
  210. />
  211. <div className="w-100">
  212. <label
  213. htmlFor="simulation-customer-select"
  214. className="fs-14 text-medium-gray d-block"
  215. >
  216. {pageCopy.customerLabel}
  217. </label>
  218. <select
  219. id="simulation-customer-select"
  220. aria-label={pageCopy.customerLabel}
  221. className="form-select customer-edit-control mt-10px"
  222. value={selectedCustomerId}
  223. disabled={!bindCustomer}
  224. onChange={(event) => setSelectedCustomerId(event.target.value)}
  225. >
  226. <option value="">{pageCopy.customerPlaceholder}</option>
  227. {customers.map((item) => (
  228. <option key={item.id} value={item.id}>
  229. {item.name}
  230. </option>
  231. ))}
  232. </select>
  233. </div>
  234. </div>
  235. </div>
  236. <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-4">
  237. <div className="simulation-step-heading">
  238. <span>2</span>
  239. <div>
  240. <span className="fs-14 fw-700 text-base-color">{pageCopy.upload}</span>
  241. <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.upload}</h2>
  242. </div>
  243. </div>
  244. <label htmlFor="simulation-photo-upload" className="simulation-photo-upload mt-25px">
  245. <input
  246. id="simulation-photo-upload"
  247. aria-label={pageCopy.upload}
  248. type="file"
  249. accept="image/*"
  250. onChange={handleUpload}
  251. />
  252. <img id="simulation-photo-preview" src={previewUrl} alt={pageCopy.upload} />
  253. <span>
  254. <i className="feather icon-feather-upload me-8px"></i>
  255. {uploading ? '...' : pageCopy.upload}
  256. </span>
  257. <small className="d-block mt-10px text-medium-gray">{pageCopy.uploadHint}</small>
  258. </label>
  259. </div>
  260. <div className="simulation-step workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
  261. <div className="simulation-step-heading">
  262. <span>3</span>
  263. <div>
  264. <span className="fs-14 fw-700 text-base-color">{pageCopy.myTemplates}</span>
  265. <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.myTemplates}</h2>
  266. </div>
  267. </div>
  268. <div className="simulation-choice-grid mt-25px mb-30px">
  269. <label className="simulation-choice-card">
  270. <input
  271. type="radio"
  272. name="template-source"
  273. aria-label={pageCopy.myTemplates}
  274. checked={templateSource === 'STYLIST'}
  275. onChange={() => setTemplateSource('STYLIST')}
  276. />
  277. <span className="simulation-choice-icon">
  278. <i className="feather icon-feather-scissors"></i>
  279. </span>
  280. <strong>{pageCopy.myTemplates}</strong>
  281. <small>{pageCopy.myTemplates}</small>
  282. </label>
  283. <label className="simulation-choice-card">
  284. <input
  285. type="radio"
  286. name="template-source"
  287. aria-label={pageCopy.systemTemplates}
  288. checked={templateSource === 'SYSTEM'}
  289. onChange={() => setTemplateSource('SYSTEM')}
  290. />
  291. <span className="simulation-choice-icon">
  292. <i className="feather icon-feather-layers"></i>
  293. </span>
  294. <strong>{pageCopy.systemTemplates}</strong>
  295. <small>{pageCopy.systemTemplates}</small>
  296. </label>
  297. </div>
  298. <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
  299. <p className="mb-0 fs-15 text-medium-gray">
  300. {pageCopy.selectedCount.replace('{{count}}', String(selectedTemplates.length))}
  301. </p>
  302. </div>
  303. <div className="simulation-template-grid">
  304. {templates.length === 0 ? (
  305. <p className="text-medium-gray mb-0">{pageCopy.emptyTemplates}</p>
  306. ) : (
  307. templates.map((template) => {
  308. const active = selectedTemplates.includes(template.id)
  309. return (
  310. <label key={template.id} className="simulation-template-card">
  311. <input
  312. type="checkbox"
  313. aria-label={template.templateName}
  314. checked={active}
  315. disabled={!active && selectedTemplates.length >= TEMPLATE_LIMIT}
  316. onChange={() => toggleTemplate(template.id)}
  317. />
  318. <img
  319. src={template.coverUrl || '/images/aitori-simulation-result.png'}
  320. alt={template.templateName}
  321. />
  322. <span>{template.templateName}</span>
  323. </label>
  324. )
  325. })
  326. )}
  327. </div>
  328. <div className="mt-25px">
  329. <label
  330. htmlFor="simulation-remark"
  331. className="fs-14 fw-700 text-dark-gray d-block mb-10px"
  332. >
  333. {pageCopy.remark}
  334. </label>
  335. <textarea
  336. id="simulation-remark"
  337. className="form-control"
  338. value={remark}
  339. placeholder={pageCopy.remarkPlaceholder}
  340. onChange={(event) => setRemark(event.target.value)}
  341. />
  342. </div>
  343. </div>
  344. <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
  345. <div>
  346. <strong>{pageCopy.submit}</strong>
  347. <span>{pageCopy.lead}</span>
  348. </div>
  349. <div className="d-flex flex-wrap gap-2 align-items-center">
  350. {error ? (
  351. <span className="fs-14 text-red me-2" role="alert">
  352. {error}
  353. </span>
  354. ) : null}
  355. <Link
  356. to={path('/dashboard')}
  357. className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
  358. >
  359. {copy.common.cancel}
  360. </Link>
  361. <button
  362. type="submit"
  363. disabled={submitting || uploading}
  364. className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
  365. >
  366. <i className="feather icon-feather-cpu me-10px"></i>
  367. {submitting ? '...' : pageCopy.submit}
  368. </button>
  369. </div>
  370. </div>
  371. </form>
  372. </div>
  373. </section>
  374. </DashboardLayout>
  375. )
  376. }