Przeglądaj źródła

feat: apply stylist site feedback 6 and add Meta Pixel stylist events

Profile and simulation fixes:
- Replace the profile badge with "Hairstyles to Try Before Your Visit" copy (ja/zh/en)
- Preserve line breaks in lead, bio and specialties
- Hide experience/rating/review stats that the stylist has not filled in
- Keep the lightbox action button on screen on short viewports
- Show the stylist display name on the customer simulation page
- Localize the 4-view system template and order home styling before 30/60 days
- Link simulation/result pages back to the originating stylist instead of the demo profile
- Preselect the template chosen from the profile lightbox and reuse the stored invite code

Meta Pixel:
- Add ViewContent, SelectHairstyleTemplate, StartSimulation, PhotoUploadComplete,
  SimulationComplete and BookingClick with dynamic stylist/locale params
- Tag events with environment by host so staging does not pollute production data

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zayen 8 godzin temu
rodzic
commit
1eff8a0f9e

+ 14 - 0
backend/prisma/migrations/20261026_localize_four_view_template/migration.sql

@@ -0,0 +1,14 @@
+UPDATE `hair_template`
+SET `template_name_i18n` = JSON_OBJECT(
+  'zh', '四视角展示',
+  'ja', '4方向ビュー',
+  'en', '4-View'
+)
+WHERE `is_system_template` = 'YES'
+  AND `is_deleted` = 0
+  AND (
+    LOWER(`template_name`) LIKE '%four-view%'
+    OR LOWER(`template_name`) LIKE '%four view%'
+    OR LOWER(`template_name`) LIKE '%4-view%'
+    OR `template_name` LIKE '%四视角%'
+  );

+ 33 - 5
backend/src/modules/simulations/simulations.service.ts

@@ -16,6 +16,32 @@ import { CreatePublicSimulationDto } from './dto/public-simulation.dto';
 import { randomBytes } from 'node:crypto';
 import { normalizeTemplateNameI18n } from '../templates/template-name-i18n';
 
+const PUBLIC_STYLIST_SELECT = {
+  id: true,
+  username: true,
+  displayName: true,
+  slug: true,
+  email: true,
+  simulationInviteCode: true,
+} as const;
+
+// Home styling should read as a sequence: today → 30 days → 60 days.
+// Other system templates (e.g. 4-view) keep their newest-first position ahead of it.
+function systemTemplateRank(templateName: string): number {
+  const name = templateName.toLowerCase();
+  if (/60/.test(name)) return 3;
+  if (/30/.test(name)) return 2;
+  if (/居家|ホーム|home/.test(name)) return 1;
+  return 0;
+}
+
+function sortSystemTemplates<T extends { templateName: string }>(items: T[]): T[] {
+  return items
+    .map((item, index) => ({ item, index, rank: systemTemplateRank(item.templateName) }))
+    .sort((a, b) => a.rank - b.rank || a.index - b.index)
+    .map(({ item }) => item);
+}
+
 @Injectable()
 export class SimulationsService {
   private readonly logger = new Logger(SimulationsService.name);
@@ -89,7 +115,7 @@ export class SimulationsService {
     if (code) {
       const stylist = await this.prisma.hairstylistUser.findFirst({
         where: { simulationInviteCode: code, isDeleted: 0 },
-        select: { id: true, username: true, email: true, simulationInviteCode: true },
+        select: PUBLIC_STYLIST_SELECT,
       });
       if (!stylist) throw new NotFoundException('模拟代码无效,请向发型师确认');
       return stylist;
@@ -97,14 +123,14 @@ export class SimulationsService {
 
     const preferred = await this.prisma.hairstylistUser.findFirst({
       where: { email: 'stylist@aitori.jp', isDeleted: 0 },
-      select: { id: true, username: true, email: true, simulationInviteCode: true },
+      select: PUBLIC_STYLIST_SELECT,
     });
     if (preferred) return preferred;
 
     const fallback = await this.prisma.hairstylistUser.findFirst({
       where: { isDeleted: 0 },
       orderBy: { id: 'asc' },
-      select: { id: true, username: true, email: true, simulationInviteCode: true },
+      select: PUBLIC_STYLIST_SELECT,
     });
     if (!fallback) throw new NotFoundException('暂时没有可用的发型师模板');
     return fallback;
@@ -123,7 +149,9 @@ export class SimulationsService {
 
     return {
       stylist: {
-        name: stylist.username,
+        id: stylist.id.toString(),
+        slug: stylist.slug ?? '',
+        name: stylist.displayName.trim() || stylist.username,
         email: stylist.email,
       },
       stylistTemplates: stylistTemplates.items,
@@ -190,7 +218,7 @@ export class SimulationsService {
         isSystemTemplate: item.isSystemTemplate === 'YES',
       }));
     return {
-      items: mappedItems,
+      items: query.source === 'STYLIST' ? mappedItems : sortSystemTemplates(mappedItems),
       total,
       page: query.page,
       pageSize: query.pageSize,

+ 1 - 1
frontend/src/components/common/ImageLightbox.tsx

@@ -33,7 +33,7 @@ export function ImageLightbox({ image, alt = '', onClose, action }: ImageLightbo
   return (
     <div className="aitori-image-lightbox" role="dialog" aria-modal="true" aria-label={alt || translated('common.imagePreview', '图片预览')} onClick={onClose}>
       <button type="button" aria-label={translated('common.closeImagePreview', '关闭图片预览')} onClick={onClose}><i className="feather icon-feather-x"></i></button>
-      <div className="aitori-image-lightbox-content" onClick={(event) => event.stopPropagation()}>
+      <div className={`aitori-image-lightbox-content${action ? ' has-action' : ''}`} onClick={(event) => event.stopPropagation()}>
         <img src={image} alt={alt ? translated('common.enlargedImage', `${alt} 放大预览`, { name: alt }) : translated('common.enlargedImageFallback', '图片放大预览')} />
         {action ? <div className="aitori-image-lightbox-action">{action}</div> : null}
       </div>

+ 3 - 2
frontend/src/i18n/locales/en/translation.json

@@ -40,7 +40,8 @@
   "systemTemplateNames": {
     "homeStyling": "Home styling",
     "after30Days": "Home styling after 30 days",
-    "after60Days": "Home styling after 60 days"
+    "after60Days": "Home styling after 60 days",
+    "fourView": "4-View"
   },
   "profileManagePage": {
     "title": "Profile management",
@@ -1392,7 +1393,7 @@
     "title": "{{name}}'s public hairstyle page",
     "description": "View the stylist's profile and published templates, then book a visit or try an AI hairstyle simulation.",
     "photoAlt": "{{name}} profile photo",
-    "badgeSuffix": "'s hairstyle looks",
+    "badge": "Hairstyles to Try Before Your Visit",
     "book": "Book a consultation",
     "tryAi": "Try with my photo",
     "browse": "Browse templates first",

+ 3 - 2
frontend/src/i18n/locales/ja/translation.json

@@ -40,7 +40,8 @@
   "systemTemplateNames": {
     "homeStyling": "ホームスタイリング",
     "after30Days": "ホームスタイリング 30日後",
-    "after60Days": "ホームスタイリング 60日後"
+    "after60Days": "ホームスタイリング 60日後",
+    "fourView": "4方向ビュー"
   },
   "profileManagePage": {
     "title": "プロフィール管理",
@@ -1052,7 +1053,7 @@
     "title": "{{name}}の公開ヘアスタイルページ",
     "description": "スタイリストのプロフィールと公開テンプレートを確認し、予約や AI ヘアシミュレーションへ進めます。",
     "photoAlt": "{{name}}のプロフィール写真",
-    "badgeSuffix": "のヘアスタイル参考",
+    "badge": "来店前に試せるヘアスタイル",
     "book": "予約する",
     "tryAi": "自分の写真で試す",
     "browse": "先にテンプレートを見る",

+ 3 - 2
frontend/src/i18n/locales/zh/translation.json

@@ -40,7 +40,8 @@
   "systemTemplateNames": {
     "homeStyling": "居家造型",
     "after30Days": "居家造型 30 天后",
-    "after60Days": "居家造型 60 天后"
+    "after60Days": "居家造型 60 天后",
+    "fourView": "四视角展示"
   },
   "profileManagePage": {
     "title": "个人主页管理",
@@ -1051,7 +1052,7 @@
     "title": "{{name}} 的公开发型主页",
     "description": "查看发型师资料、已公开的发型模板,并进入预约或 AI 发型模拟。",
     "photoAlt": "{{name}} 的主页照片",
-    "badgeSuffix": "的发型参考",
+    "badge": "到店前即可试用的发型",
     "book": "预约咨询",
     "tryAi": "用我的照片试发型",
     "browse": "先看发型模板",

+ 7 - 0
frontend/src/index.css

@@ -63,6 +63,13 @@ textarea {
   gap: 1rem;
 }
 
+/* Reserve room for the action button so it never falls below the fold on short screens. */
+.aitori-image-lightbox-content.has-action img {
+  max-height: calc(100vh - 10rem);
+  max-height: calc(100dvh - 10rem);
+  object-fit: contain;
+}
+
 .aitori-image-lightbox-action .btn {
   color: #fff;
 }

+ 7 - 3
frontend/src/layouts/PublicLayout.tsx

@@ -4,6 +4,7 @@ import { Link, useLocation } from 'react-router-dom'
 import { useLegacyThemePage, usePageContext } from '../shared/pageContext'
 import { AppLogo } from '../components/common/AppLogo'
 import { LocaleSwitcher } from '../components/common/LocaleSwitcher'
+import { trackBookingClick, trackStartSimulation, type MetaStylist } from '../shared/metaPixel'
 
 type PublicLayoutProps = {
   children: ReactNode
@@ -12,6 +13,7 @@ type PublicLayoutProps = {
   profilePath?: string
   simulationPath?: string
   bookingUrl?: string
+  metaStylist?: MetaStylist | null
 }
 
 function PublicHeader({
@@ -19,6 +21,7 @@ function PublicHeader({
   profilePath: profilePathOverride,
   simulationPath,
   bookingUrl,
+  metaStylist,
 }: Omit<PublicLayoutProps, 'children' | 'footer'>) {
   const { locale, path, copy } = usePageContext()
   const location = useLocation()
@@ -55,9 +58,9 @@ function PublicHeader({
               <ul className="navbar-nav">
                 <li className="nav-item"><a href={`${profilePath}#profile`} className="nav-link">{navCopy.profile}</a></li>
                 <li className="nav-item"><a href={`${profilePath}#templates`} className="nav-link">{navCopy.templates}</a></li>
-                <li className="nav-item"><Link to={customerSimulationPath} className={`nav-link${isCustomerSimulation ? ' active' : ''}`}>{navCopy.simulation}</Link></li>
+                <li className="nav-item"><Link to={customerSimulationPath} onClick={() => metaStylist && !isCustomerSimulation && trackStartSimulation(metaStylist, locale, 'navigation')} className={`nav-link${isCustomerSimulation ? ' active' : ''}`}>{navCopy.simulation}</Link></li>
                 {variant === 'live' ? (
-                  bookingUrl ? <li className="nav-item"><a href={bookingUrl} className="nav-link" target="_blank" rel="noreferrer">{navCopy.booking}</a></li> : null
+                  bookingUrl ? <li className="nav-item"><a href={bookingUrl} className="nav-link" target="_blank" rel="noreferrer" onClick={() => metaStylist && trackBookingClick(metaStylist, locale, bookingUrl, 'header')}>{navCopy.booking}</a></li> : null
                 ) : (
                   <li className="nav-item"><a href={`${profilePath}#reviews`} className="nav-link">{navCopy.reviews}</a></li>
                 )}
@@ -80,13 +83,14 @@ export function PublicLayout({
   profilePath,
   simulationPath,
   bookingUrl,
+  metaStylist,
 }: PublicLayoutProps) {
   useLegacyThemePage({ bodyClassName: '', mobileNavStyle: 'classic' })
   const { copy } = usePageContext()
 
   return (
     <>
-      <PublicHeader variant={variant} profilePath={profilePath} simulationPath={simulationPath} bookingUrl={bookingUrl} />
+      <PublicHeader variant={variant} profilePath={profilePath} simulationPath={simulationPath} bookingUrl={bookingUrl} metaStylist={metaStylist} />
       {children}
       {footer ? (
         <footer className="p-0 footer-light position-relative">

+ 59 - 10
frontend/src/pages/customer/CustomerSimulationPage.tsx

@@ -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>

+ 12 - 4
frontend/src/pages/customer/CustomerSimulationResultPage.tsx

@@ -6,8 +6,9 @@ import { ImageLightbox } from '../../components/common/ImageLightbox'
 import type { AiHairSimulationDetail } from '../../services/api/contracts'
 import { getPublicSimulationById } from '../../services/api/simulations'
 import { usePageContext } from '../../shared/pageContext'
-import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
+import { CUSTOMER_SIMULATION_CODE_KEY, CUSTOMER_SIMULATION_SLUG_KEY, customerProfilePath } from './CustomerSimulationPage'
 import { resolveLocalizedTemplateName } from '../../shared/demoContent'
+import { trackSimulationCompleteOnce } from '../../shared/metaPixel'
 
 export function CustomerSimulationResultPage() {
   const { locale, path, copy } = usePageContext()
@@ -16,6 +17,7 @@ export function CustomerSimulationResultPage() {
   const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null)
   const [error, setError] = useState('')
   const [preview, setPreview] = useState<{ image: string; alt: string } | null>(null)
+  const profilePath = customerProfilePath(path, window.sessionStorage.getItem(CUSTOMER_SIMULATION_SLUG_KEY))
 
   useEffect(() => {
     let cancelled = false
@@ -57,15 +59,21 @@ export function CustomerSimulationResultPage() {
     })),
   ) ?? [], [copy.systemTemplateNames, detail, locale])
 
+  // Only report a finished simulation once results are on screen; failures never count.
+  const hasVisibleResults = Boolean(detail && detail.overallStatus !== 'PROCESSING' && detail.overallStatus !== 'FAILED' && results.length)
+  useEffect(() => {
+    if (hasVisibleResults && simulationId) trackSimulationCompleteOnce(simulationId, locale)
+  }, [hasVisibleResults, locale, simulationId])
+
   const createdAt = detail?.createdAt
     ? new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : locale === 'en' ? 'en-US' : 'zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(detail.createdAt))
     : '--'
 
   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></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('/customer-simulation')} 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.reselect}</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user me-10px"></i>{pageCopy.profile}</Link></div></div></section>
+        <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('/customer-simulation')} 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.reselect}</Link><Link to={profilePath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user me-10px"></i>{pageCopy.profile}</Link></div></div></section>
         <section className="pt-0"><div className="container">
           {error ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="alert"><p className="text-red mb-20px">{error}</p><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-base-color fw-700">{pageCopy.startAgain}</Link></div> : null}
           {!error && !detail ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px" role="status"><div className="template-task-generating-visual"><div className="template-task-generating-gif"><span></span><span></span><span></span></div><div><strong>{pageCopy.loading}</strong><p>{pageCopy.loadingBody}</p></div></div></div> : null}
@@ -80,7 +88,7 @@ export function CustomerSimulationResultPage() {
               <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px"><div><span className="fs-14 fw-700 text-base-color">AI Result</span><h1 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.title}</h1></div><span className="simulation-count-badge">{pageCopy.count.replace('{{count}}', String(results.length))}</span></div>
               {detail.processingTaskCount ? <div className="template-task-generating-visual"><div className="template-task-generating-gif"><span></span><span></span><span></span></div><div><strong>{pageCopy.generating}</strong><p>{pageCopy.progress.replace('{{done}}', String(detail.successTaskCount)).replace('{{total}}', String(detail.totalTaskCount))}</p></div></div> : null}
               {results.length ? <div className="simulation-result-grid">{results.map((item) => <article key={item.id} className="simulation-result-card"><button type="button" className="aitori-image-button" onClick={() => setPreview({ image: item.image, alt: item.title })}><img className="simulation-result-image" src={item.image} alt={item.title} /></button><div className="simulation-result-body"><h3>{item.title}</h3><span className="template-status-badge">{item.source === 'SYSTEM' ? pageCopy.systemTemplate : pageCopy.stylistTemplate}</span><p>{pageCopy.resultNote}</p></div></article>)}</div> : detail.overallStatus !== 'PROCESSING' ? <p className="text-medium-gray">{pageCopy.noResult}</p> : null}
-              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{pageCopy.likeTitle}</strong><span>{pageCopy.likeBody}</span></div><div className="d-flex flex-wrap gap-2"><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.tryAgain}</Link><Link to={path('/profile')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.profile}</Link></div></div>
+              <div className="customer-edit-actions border-radius-8px p-25px mt-4"><div><strong>{pageCopy.likeTitle}</strong><span>{pageCopy.likeBody}</span></div><div className="d-flex flex-wrap gap-2"><Link to={path('/customer-simulation')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.tryAgain}</Link><Link to={profilePath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.profile}</Link></div></div>
             </div>
           </div> : null}
         </div></section>

+ 56 - 19
frontend/src/pages/customer/StylistProfileLivePage.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
 import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
 import { PublicLayout } from '../../layouts/PublicLayout'
@@ -15,6 +15,13 @@ import {
 import { ApiError } from '../../services/http/client'
 import { isAuthenticated } from '../../services/http/session'
 import { CUSTOMER_SIMULATION_CODE_KEY } from './CustomerSimulationPage'
+import {
+  trackBookingClick,
+  trackStartSimulation,
+  trackStylistEvent,
+  trackStylistProfileView,
+  type MetaStylist,
+} from '../../shared/metaPixel'
 
 type LiveProfile = Omit<PublicStylistProfile, 'templates'> & {
   templates: StylistPublicTemplate[]
@@ -32,6 +39,7 @@ export function StylistProfileLivePage({
   const [profile, setProfile] = useState<LiveProfile | null>(null)
   const [error, setError] = useState('')
   const [preview, setPreview] = useState<StylistPublicTemplate | null>(null)
+  const viewTrackedKey = useRef('')
 
   useEffect(() => {
     let cancelled = false
@@ -59,6 +67,20 @@ export function StylistProfileLivePage({
     }
   }, [onAuthExpired, pageCopy.notFound, slug])
 
+  const metaStylist = useMemo<MetaStylist | null>(
+    () => (profile ? { id: profile.id, slug: profile.slug, displayName: profile.displayName } : null),
+    [profile],
+  )
+
+  // One ViewContent per stylist + locale; re-renders and StrictMode re-runs keep the same key.
+  useEffect(() => {
+    if (!metaStylist) return
+    const key = `${metaStylist.id}:${locale}`
+    if (viewTrackedKey.current === key) return
+    viewTrackedKey.current = key
+    trackStylistProfileView(metaStylist, locale)
+  }, [locale, metaStylist])
+
   const simulationPath = profile?.inviteCode
     ? path(`/customer-simulation?code=${encodeURIComponent(profile.inviteCode)}`)
     : path('/customer-simulation')
@@ -71,10 +93,24 @@ export function StylistProfileLivePage({
     [locale, profile],
   )
 
-  const experience = profile?.experienceYears
-    ? `${profile.experienceYears}${pageCopy.yearUnit}`
-    : '—'
+  // Hide stat cards the stylist has not filled in instead of showing a dash.
+  const stats = [
+    profile?.experienceYears ? { value: `${profile.experienceYears}${pageCopy.yearUnit}`, label: pageCopy.stats.experience } : null,
+    profile?.rating ? { value: profile.rating, label: pageCopy.stats.rating } : null,
+    profile?.reviewCount ? { value: String(profile.reviewCount), label: pageCopy.stats.reviews } : null,
+  ].filter((item): item is { value: string; label: string } => Boolean(item))
   const displayName = profile?.displayName || pageCopy.untitled
+
+  const handleSelectTemplate = (item: StylistPublicTemplate) => {
+    setPreview(item)
+    if (metaStylist) {
+      trackStylistEvent('SelectHairstyleTemplate', metaStylist, locale, {
+        template_id: item.id,
+        template_name: item.templateName,
+        entry_point: 'profile_template_gallery',
+      })
+    }
+  }
   const photo = profile?.photoUrl || DEFAULT_USER_AVATAR_URL
 
   if (error) {
@@ -101,6 +137,7 @@ export function StylistProfileLivePage({
       profilePath={profilePath}
       simulationPath={simulationPath}
       bookingUrl={profile.bookingUrl || undefined}
+      metaStylist={metaStylist}
     >
       <Helmet>
         <html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} />
@@ -112,28 +149,28 @@ export function StylistProfileLivePage({
         <div className="container"><div className="row align-items-center justify-content-center">
           <div className="col-lg-5 col-md-8 md-mb-40px"><div className="stylist-profile-photo public-profile-photo bg-white border-radius-8px box-shadow-quadruple-large p-15px"><img src={photo} alt={pageCopy.photoAlt.replace('{{name}}', displayName)} className="border-radius-6px w-100" /></div></div>
           <div className="col-lg-6 offset-lg-1">
-            <span className="bg-base-color d-inline-block mb-20px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{displayName}{pageCopy.badgeSuffix}</span>
+            <span className="bg-base-color d-inline-block mb-20px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{displayName}</h1>
-            {profile.lead ? <p className="fs-20 lh-32 text-dark-gray fw-600 mb-20px">{profile.lead}</p> : null}
-            {profile.bio ? <p className="fs-17 lh-30 mb-30px">{profile.bio}</p> : null}
+            {profile.lead ? <p className="fs-20 lh-32 text-dark-gray fw-600 mb-20px ws-pre-line">{profile.lead}</p> : null}
+            {profile.bio ? <p className="fs-17 lh-30 mb-30px ws-pre-line">{profile.bio}</p> : null}
             <div className="mb-30px d-flex flex-wrap gap-2">
-              {profile.bookingUrl ? <a href={profile.bookingUrl} target="_blank" rel="noreferrer" className="btn btn-extra-large btn-rounded btn-dark-gray btn-box-shadow fw-700"><i className="feather icon-feather-calendar me-10px"></i>{pageCopy.book}</a> : null}
-              <Link to={simulationPath} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-camera me-10px"></i>{pageCopy.tryAi}</Link>
+              {profile.bookingUrl ? <a href={profile.bookingUrl} target="_blank" rel="noreferrer" onClick={() => metaStylist && trackBookingClick(metaStylist, locale, profile.bookingUrl, 'profile_hero')} className="btn btn-extra-large btn-rounded btn-dark-gray btn-box-shadow fw-700"><i className="feather icon-feather-calendar me-10px"></i>{pageCopy.book}</a> : null}
+              <Link to={simulationPath} onClick={() => metaStylist && trackStartSimulation(metaStylist, locale, 'profile_hero')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-camera me-10px"></i>{pageCopy.tryAi}</Link>
               <a href="#templates" className="btn btn-extra-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.browse}</a>
               {isAuthenticated() ? <Link to={path('/profile/manage')} className="btn btn-extra-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-edit me-10px"></i>{pageCopy.edit}</Link> : null}
             </div>
-            <div className="row row-cols-3 g-3 text-center public-profile-stats">
-              <Stat value={experience} label={pageCopy.stats.experience} />
-              <Stat value={profile.rating || '—'} label={pageCopy.stats.rating} />
-              <Stat value={profile.reviewCount ? String(profile.reviewCount) : '—'} label={pageCopy.stats.reviews} />
-            </div>
+            {stats.length ? (
+              <div className={`row row-cols-${stats.length} g-3 text-center public-profile-stats`}>
+                {stats.map((item) => <Stat key={item.label} value={item.value} label={item.label} />)}
+              </div>
+            ) : null}
           </div>
         </div></div>
       </section>
 
       <section className="half-section public-profile-actions pt-0"><div className="container"><div className="row row-cols-1 row-cols-lg-3 row-cols-md-3 justify-content-center g-4">
         <div className="col"><a href="#templates" className="public-profile-action-card public-profile-action-gallery"><i className="feather icon-feather-grid"></i><span><strong>{pageCopy.actions[0].title}</strong><small>{pageCopy.actions[0].body}</small></span></a></div>
-        <div className="col"><Link to={simulationPath} className="public-profile-action-card public-profile-action-simulation"><i className="feather icon-feather-camera"></i><span><strong>{pageCopy.actions[1].title}</strong><small>{pageCopy.actions[1].body}</small></span></Link></div>
+        <div className="col"><Link to={simulationPath} onClick={() => metaStylist && trackStartSimulation(metaStylist, locale, 'action_card')} className="public-profile-action-card public-profile-action-simulation"><i className="feather icon-feather-camera"></i><span><strong>{pageCopy.actions[1].title}</strong><small>{pageCopy.actions[1].body}</small></span></Link></div>
         <div className="col"><a href="#stylist" className="public-profile-action-card public-profile-action-booking"><i className="feather icon-feather-user"></i><span><strong>{pageCopy.actions[2].title}</strong><small>{pageCopy.actions[2].body}</small></span></a></div>
       </div></div></section>
 
@@ -143,7 +180,7 @@ export function StylistProfileLivePage({
           <ul className="stylist-live-template-grid">
             {templates.map((item) => (
               <li key={item.id}>
-                <button type="button" className="stylist-live-template-card" onClick={() => setPreview(item)}>
+                <button type="button" className="stylist-live-template-card" onClick={() => handleSelectTemplate(item)}>
                   <img src={item.coverUrl || '/images/aitori-simulation-result.png'} alt={item.templateName} />
                   <div>
                     <h3>{item.templateName}</h3>
@@ -162,7 +199,7 @@ export function StylistProfileLivePage({
         <div className="col-lg-5 md-mb-35px">
           <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.salon.badge}</span>
           <h2 className="fw-700 text-dark-gray ls-minus-1px mb-20px">{pageCopy.salon.title}</h2>
-          {profile.specialties ? <p className="fs-18 lh-30 mb-0">{profile.specialties}</p> : null}
+          {profile.specialties ? <p className="fs-18 lh-30 mb-0 ws-pre-line">{profile.specialties}</p> : null}
         </div>
         <div className="col-lg-5 offset-lg-1"><div className="row row-cols-1 g-3">
           {profile.address ? <InfoCard label={pageCopy.salon.address} value={profile.address} /> : null}
@@ -170,8 +207,8 @@ export function StylistProfileLivePage({
         </div></div>
       </div></div></section>
 
-      <section className="half-section"><div className="container"><div className="public-profile-bottom-cta"><div><span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.cta.badge}</span><h2 className="fw-700 text-dark-gray fs-32 mb-10px">{pageCopy.cta.title}</h2><p className="mb-0">{pageCopy.cta.body}</p></div><Link to={simulationPath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.button}</Link></div></div></section>
-      <ImageLightbox image={preview?.coverUrl || null} alt={preview?.templateName} onClose={() => setPreview(null)} action={<Link to={simulationPath} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.lightbox}</Link>} />
+      <section className="half-section"><div className="container"><div className="public-profile-bottom-cta"><div><span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.cta.badge}</span><h2 className="fw-700 text-dark-gray fs-32 mb-10px">{pageCopy.cta.title}</h2><p className="mb-0">{pageCopy.cta.body}</p></div><Link to={simulationPath} onClick={() => metaStylist && trackStartSimulation(metaStylist, locale, 'bottom_cta')} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.button}</Link></div></div></section>
+      <ImageLightbox image={preview?.coverUrl || null} alt={preview?.templateName} onClose={() => setPreview(null)} action={<Link to={preview ? `${simulationPath}${simulationPath.includes('?') ? '&' : '?'}template=${encodeURIComponent(preview.id)}` : simulationPath} onClick={() => metaStylist && trackStartSimulation(metaStylist, locale, 'template_card', preview ? { id: preview.id, name: preview.templateName } : null)} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">{pageCopy.cta.lightbox}</Link>} />
     </PublicLayout>
   )
 }

+ 1 - 1
frontend/src/pages/customer/__tests__/StylistProfilePage.test.tsx

@@ -56,7 +56,7 @@ vi.mock('../../../shared/pageContext', () => ({
         title: '{{name}} 的公开发型主页',
         description: 'live description',
         photoAlt: '{{name}} 的主页照片',
-        badgeSuffix: '的发型参考',
+        badge: '到店前即可试用的发型',
         book: '预约咨询',
         tryAi: '用我的照片试发型',
         browse: '先看发型模板',

+ 1 - 1
frontend/src/services/api/simulations.ts

@@ -64,7 +64,7 @@ export type ListSimulationTemplatesParams = {
 }
 
 export type PublicSimulationTemplates = {
-  stylist: { name: string; email: string }
+  stylist: { id: string; slug: string; name: string; email: string }
   stylistTemplates: SimulationTemplateOption[]
   systemTemplates: SimulationTemplateOption[]
 }

+ 52 - 0
frontend/src/shared/__tests__/metaPixel.test.ts

@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  getBookingProvider,
+  saveSimulationContext,
+  trackBookingClick,
+  trackSimulationCompleteOnce,
+  trackStylistProfileView,
+} from '../metaPixel'
+
+const stylist = { id: '2', slug: 'ogawa', displayName: '小川泰明' }
+
+describe('metaPixel', () => {
+  afterEach(() => {
+    delete window.fbq
+  })
+
+  it('sends ViewContent with dynamic stylist params', () => {
+    const fbq = vi.fn()
+    window.fbq = fbq
+    trackStylistProfileView(stylist, 'ja')
+    expect(fbq).toHaveBeenCalledWith('track', 'ViewContent', expect.objectContaining({
+      content_type: 'stylist_profile',
+      content_ids: ['2'],
+      stylist_id: '2',
+      stylist_slug: 'ogawa',
+      locale: 'ja',
+    }))
+  })
+
+  it('reports booking provider and domain without the full URL', () => {
+    const fbq = vi.fn()
+    window.fbq = fbq
+    trackBookingClick(stylist, 'zh', 'https://yasuaki-ogawa.stores.jp/reserve/beauty?x=1', 'header')
+    const params = fbq.mock.calls[0][2]
+    expect(params).toMatchObject({ booking_provider: 'stores', booking_url_domain: 'yasuaki-ogawa.stores.jp', entry_point: 'header' })
+    expect(JSON.stringify(params)).not.toContain('/reserve')
+    expect(getBookingProvider('not a url')).toBe('unknown')
+  })
+
+  it('fires SimulationComplete only once per simulation and only with saved context', () => {
+    const fbq = vi.fn()
+    window.fbq = fbq
+    trackSimulationCompleteOnce('missing', 'en')
+    expect(fbq).not.toHaveBeenCalled()
+
+    saveSimulationContext('91', { stylist, templates: [{ id: '1', name: 'Home styling' }] })
+    trackSimulationCompleteOnce('91', 'en')
+    trackSimulationCompleteOnce('91', 'en')
+    expect(fbq).toHaveBeenCalledTimes(1)
+    expect(fbq).toHaveBeenCalledWith('trackCustom', 'SimulationComplete', expect.objectContaining({ template_id: '1', template_name: 'Home styling' }))
+  })
+})

+ 2 - 0
frontend/src/shared/__tests__/stylistProfileStore.test.ts

@@ -30,6 +30,7 @@ describe('system template localization', () => {
     homeStyling: 'Home styling',
     after30Days: 'After 30 days',
     after60Days: 'After 60 days',
+    fourView: '4-View',
   }
 
   it.each([
@@ -37,6 +38,7 @@ describe('system template localization', () => {
     ['ホームスタイリング', 'Home styling'],
     ['after-30-days', 'After 30 days'],
     ['居家造型60天后造型', 'After 60 days'],
+    ['4 four-view', '4-View'],
   ])('maps %s to its localized system name', (source, expected) => {
     expect(localizeSystemTemplateName(source, names)).toBe(expected)
   })

+ 2 - 0
frontend/src/shared/demoContent.ts

@@ -50,6 +50,7 @@ export type SystemTemplateNames = {
   homeStyling: string
   after30Days: string
   after60Days: string
+  fourView: string
 }
 
 export type TemplateNameI18n = Partial<Record<'zh' | 'ja' | 'en', string>>
@@ -57,6 +58,7 @@ export type TemplateNameI18n = Partial<Record<'zh' | 'ja' | 'en', string>>
 export function localizeSystemTemplateName(templateName: string, names?: SystemTemplateNames) {
   if (!names) return templateName
   const normalized = templateName.toLowerCase()
+  if (/four[- ]?view|4[- ]?view|4方向|四视角/.test(normalized)) return names.fourView
   if (/60|after-?60/.test(normalized)) return names.after60Days
   if (/30|after-?30/.test(normalized)) return names.after30Days
   if (/居家|ホーム|home/.test(normalized)) return names.homeStyling

+ 169 - 0
frontend/src/shared/metaPixel.ts

@@ -0,0 +1,169 @@
+// Meta Pixel (575352711459521) is installed through GTM and already sends PageView.
+// These helpers only add stylist-scoped events on top of it; never pass customer
+// photos, file names, image URLs or contact details in params.
+
+type Fbq = (command: 'track' | 'trackCustom', eventName: string, params?: Record<string, unknown>) => void
+
+declare global {
+  interface Window {
+    fbq?: Fbq
+  }
+}
+
+export type MetaStylist = {
+  id: string
+  slug: string
+  displayName: string
+}
+
+export type MetaTemplate = {
+  id: string
+  name: string
+}
+
+export type StartSimulationEntryPoint = 'profile_hero' | 'template_card' | 'bottom_cta' | 'navigation' | 'action_card'
+export type BookingEntryPoint = 'header' | 'profile_hero' | 'sticky_button' | 'bottom_cta'
+
+// Docker builds every environment in production mode, so tell them apart by host instead.
+const PRODUCTION_HOSTS = ['stylist.aitori.jp']
+
+function getEnvironment() {
+  const host = window.location.hostname
+  if (PRODUCTION_HOSTS.includes(host)) return 'production'
+  if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.localhost')) return 'development'
+  return 'staging'
+}
+
+const FBQ_WAIT_MS = 10_000
+const FBQ_POLL_MS = 250
+const SIMULATION_CONTEXT_PREFIX = 'aitori-meta-simulation:'
+const SIMULATION_COMPLETE_PREFIX = 'aitori-meta-simulation-complete:'
+
+let pending: Array<Parameters<Fbq>> = []
+let pollTimer: number | undefined
+
+function flushPending() {
+  if (typeof window.fbq !== 'function') return false
+  const queue = pending
+  pending = []
+  queue.forEach((args) => window.fbq?.(...args))
+  return true
+}
+
+// GTM loads the pixel asynchronously, so early events wait briefly for fbq instead of being dropped.
+function send(...args: Parameters<Fbq>) {
+  if (typeof window === 'undefined') return
+  if (typeof window.fbq === 'function') {
+    window.fbq(...args)
+    return
+  }
+  pending.push(args)
+  if (pollTimer !== undefined) return
+  const startedAt = Date.now()
+  pollTimer = window.setInterval(() => {
+    if (flushPending() || Date.now() - startedAt > FBQ_WAIT_MS) {
+      window.clearInterval(pollTimer)
+      pollTimer = undefined
+      pending = []
+    }
+  }, FBQ_POLL_MS)
+}
+
+export function getStylistMetaData(stylist: MetaStylist, locale: string) {
+  return {
+    stylist_id: stylist.id,
+    stylist_slug: stylist.slug,
+    stylist_name: stylist.displayName,
+    locale,
+    environment: getEnvironment(),
+  }
+}
+
+export function trackStylistEvent(
+  eventName: string,
+  stylist: MetaStylist,
+  locale: string,
+  extra: Record<string, unknown> = {},
+) {
+  send('trackCustom', eventName, { ...getStylistMetaData(stylist, locale), ...extra })
+}
+
+export function trackStylistProfileView(stylist: MetaStylist, locale: string) {
+  send('track', 'ViewContent', {
+    content_type: 'stylist_profile',
+    content_ids: [stylist.id],
+    content_name: stylist.displayName,
+    ...getStylistMetaData(stylist, locale),
+  })
+}
+
+export function templateParams(templates: MetaTemplate[]) {
+  return {
+    template_id: templates.length ? templates.map((item) => item.id).join(',') : null,
+    template_name: templates.length ? templates.map((item) => item.name).join(',') : null,
+  }
+}
+
+export function getUrlDomain(url: string) {
+  try {
+    return new URL(url).hostname
+  } catch {
+    return ''
+  }
+}
+
+export function getBookingProvider(url: string) {
+  const domain = getUrlDomain(url)
+  if (!domain) return 'unknown'
+  if (domain.includes('hotpepper')) return 'hotpepper_beauty'
+  if (domain.includes('stores.jp')) return 'stores'
+  if (domain.includes('minimodel') || domain.includes('minimo')) return 'minimo'
+  if (domain.includes('rakuten')) return 'rakuten_beauty'
+  if (domain.includes('line.me') || domain.includes('lin.ee')) return 'line'
+  return 'other'
+}
+
+export function trackBookingClick(stylist: MetaStylist, locale: string, bookingUrl: string, entryPoint: BookingEntryPoint) {
+  trackStylistEvent('BookingClick', stylist, locale, {
+    booking_provider: getBookingProvider(bookingUrl),
+    booking_url_domain: getUrlDomain(bookingUrl),
+    entry_point: entryPoint,
+  })
+}
+
+export function trackStartSimulation(
+  stylist: MetaStylist,
+  locale: string,
+  entryPoint: StartSimulationEntryPoint,
+  template?: MetaTemplate | null,
+) {
+  trackStylistEvent('StartSimulation', stylist, locale, {
+    ...templateParams(template ? [template] : []),
+    entry_point: entryPoint,
+  })
+}
+
+type SimulationContext = { stylist: MetaStylist; templates: MetaTemplate[] }
+
+// The result page has no stylist data of its own, so the submit step hands it over per simulation.
+export function saveSimulationContext(simulationId: string, context: SimulationContext) {
+  try {
+    window.sessionStorage.setItem(`${SIMULATION_CONTEXT_PREFIX}${simulationId}`, JSON.stringify(context))
+  } catch {
+    // Tracking must never block the simulation flow.
+  }
+}
+
+export function trackSimulationCompleteOnce(simulationId: string, locale: string) {
+  try {
+    const completeKey = `${SIMULATION_COMPLETE_PREFIX}${simulationId}`
+    if (window.sessionStorage.getItem(completeKey)) return
+    const raw = window.sessionStorage.getItem(`${SIMULATION_CONTEXT_PREFIX}${simulationId}`)
+    if (!raw) return
+    const context = JSON.parse(raw) as SimulationContext
+    window.sessionStorage.setItem(completeKey, '1')
+    trackStylistEvent('SimulationComplete', context.stylist, locale, templateParams(context.templates))
+  } catch {
+    // Ignore storage / parse failures.
+  }
+}