StylistSimulationPage.tsx 15 KB

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