PageSourceSearch

https://www.01.ai/assets/app.1.9.28133220.js

js 01.ai collected 2026-10-02 03:43:03 UTC 74,267 bytes, 1,749 lines download raw bytes

1// 01.AI — shared interactions
2(() => {
3  const isEN = (document.documentElement.lang || '').toLowerCase().startsWith('en');
4  const NAV_TRANSITION_KEY = 'oai-nav-transition';
5  const fromNavTransition = (() => {
6    try {
7      const value = window.sessionStorage.getItem(NAV_TRANSITION_KEY);
8      if (value) window.sessionStorage.removeItem(NAV_TRANSITION_KEY);
9      return Boolean(value);
10    } catch {
11      return false;
12    }
13  })();
14  if (fromNavTransition) {
15    document.body.classList.add('page-transition-from-nav');
16  }
17  const rootRelativeHref = (targetPath) => {
18    const cleanTarget = targetPath.replace(/^\/+/, '');
19    const path = window.location.pathname || '';
20    const currentDir = path.endsWith('/') ? path : path.slice(0, path.lastIndexOf('/') + 1);
21    const rootDir = currentDir.replace(/^\/+|\/+$/g, '');
22    const depth = rootDir ? rootDir.split('/').filter(Boolean).length : 0;
23    return `${'../'.repeat(depth)}${cleanTarget}`;
24  };
25  const isLocalPreview = (() => {
26    const host = window.location.hostname;
27    return window.location.protocol === 'file:'
28      || host === 'localhost'
29      || host === '127.0.0.1'
30      || host === '::1';
31  })();
32  const localLanguageHref = (href) => {
33    if (!href) return href;
34    if (!isLocalPreview) return href;
35    if (href === 'https://www.01.ai') return rootRelativeHref('en/');
36    if (href === 'https://www.lingyiwanwu.com') return rootRelativeHref('');
37    if (href.startsWith('https://www.01.ai/')) {
38      // EN origin serves the /en/ folder at its root in production; locally the
39      // /en/ subdir still exists, so restore it for preview.
40      return rootRelativeHref('en/' + href.replace('https://www.01.ai/', ''));
41    }
42    if (href.startsWith('https://www.lingyiwanwu.com/')) {
43      return rootRelativeHref(href.replace('https://www.lingyiwanwu.com/', ''));
44    }
45    return href;
46  };
47  document.querySelectorAll('.nav-lang, .nav-drawer-lang').forEach((link) => {
48    const href = link.getAttribute('href');
49    const localHref = localLanguageHref(href);
50    if (localHref !== href) link.setAttribute('href', localHref);
51  });
52  const T = isEN
53    ? {
54        title: 'Book a Consultation',
55        sub: 'Share your objectives. Our enterprise team will respond within two business days.',
56        kicker: 'Enterprise AI Transformation Services',
57        pitchTitle: 'Start with the opportunity that matters most.',
58        pitchText: 'A focused conversation to clarify business outcomes, data readiness, deployment constraints, and a measurable first phase.',
59        pitchPoints: [
60          'Prioritize use cases against business outcomes',
61          'Plan around data, security, and deployment requirements',
62          'Receive a response within two business days'
63        ],
64        trust: 'For executives, business owners, and AI transformation leaders.',
65        investorTitle: 'Business Partnership',
66        investorSub: 'Share your cooperation focus and contact details. We will direct your request to the right team.',
67        investorKicker: 'Business Partnership',
68        investorPitchTitle: 'Tell us how you would like to collaborate.',
69        investorPitchText: 'Share your cooperation intent, company background, and the support you need so we can route your request to the right team.',
70        investorPitchPoints: [
71          'Industry alignment and ecosystem partnerships',
72          'Capital conversations and strategic investment',
73          'Joint solutions and enterprise go-to-market'
74        ],
75        investorTrust: 'For investors, industry partners, and ecosystem collaborators.',
76        investorMessage: 'Partnership focus *',
77        investorMessagePh: 'Please include your partnership context, focus, available resources, expected timeline, and preferred contact path.',
78        mediaTitle: 'Press inquiry',
79        mediaSub: 'Share your interview, op-ed, or media request. Our communications team will respond.',
80        mediaKicker: 'Press',
81        mediaPitchTitle: 'Clear context for accurate reporting.',
82        mediaPitchText: 'For interviews, fact checks, brand materials, or commentary requests, tell us 
82your outlet, topic, and deadline.',
83        mediaPitchPoints: [
84          'Interview requests and topic alignment',
85          'Facts, brand assets, and company background',
86          'Op-ed and commentary coordination'
87        ],
88        mediaTrust: 'For journalists, editors, analysts, and media partners.',
89        mediaMessage: 'Press request *',
90        mediaMessagePh: 'Please include your outlet, topic, deadline, and the materials or spokesperson you need.',
91        mediaInquiry: {
92          title: 'Media Inquiry',
93          sub: 'Complete the fields below and our communications team will follow up.',
94          kicker: 'Media Inquiry',
95          pitchTitle: 'Share the context for your request.',
96          pitchText: 'Tell us who you are, your outlet or professional profile, and the details needed for accurate follow-up.',
97          pitchPoints: [
98            'Provide your name, email, and phone number',
99            'Share your official website or LinkedIn profile',
100            'Describe your interview, material, or fact-check request'
101          ],
102          trust: 'For journalists, editors, analysts, researchers, and media partners.',
103          name: 'Name *',
104          namePh: 'Your name',
105          email: 'Email *',
106          emailPh: '[email protected]',
107          phone: 'Phone number *',
108          phonePh: 'Phone number',
109          website: 'Official website or LinkedIn *',
110          websitePh: 'Website or LinkedIn URL',
111          intro: 'Please provide an introduction *',
112          introPh: 'Please include your outlet or organization, topic, deadline, and the materials or spokesperson you need.'
113        },
114        businessPartnership: {
115          title: 'Business Partnership',
116          sub: 'Complete the fields below and our business partnership team will follow up.',
117          kicker: 'Business Partnership',
118          pitchTitle: 'Tell us how you would like to collaborate.',
119          pitchText: 'Share your cooperation intent, company background, and the support you need so we can route your request to the right team.',
120          pitchPoints: [
121            'Clarify service offers, partnership ideas, or other requests',
122            'Provide company, website or LinkedIn, and contact details',
123            'Tell us whether you need an online experience'
124          ],
125          trust: 'For solution partners, service providers, ecosystem collaborators, and enterprise contacts.',
126          purpose: 'Purpose of Reaching Out *',
127          purposeOptions: ['Service Offers', 'Partnership Ideas', 'Others'],
128          name: 'Name *',
129          namePh: 'Your name',
130          role: 'Job title *',
131          rolePh: 'Job title',
132          email: 'Email *',
133          emailPh: '[email protected]',
134          phone: 'Phone number *',
135          phonePh: 'Phone number',
136          company: 'Company name *',
137          companyPh: 'Company name',
138          website: 'Official website or LinkedIn *',
139          websitePh: 'Website or LinkedIn URL',
140          intro: 'Please provide an introduction *',
141          introPh: 'Briefly describe your cooperation intent, relevant background, and expected next step.',
142          online: 'Need an online experience? *',
143          onlineCheck: 'Need an online experience',
144          onlineOptions: ['Yes', 'No'],
145          source: 'How did you hear about us? *',
146          sourceOptions: ['News articles', 'Google', 'LinkedIn', 'Twitter', 'Referral', 'Others'],
147          selectPlaceholder: 'Select an option'
148        },
149        splitName: true,
150        firstName: 'First name *',
151        firstNamePh: 'First name',
152        lastName: 'Last name *',
153        lastNamePh: 'Last name',
154        company: 'Company name *',
155        companyPh: 'Company',
156        role: 'Job title *',
157        rolePh: 'Job title',
158        phone: 'Mobile number *',
159        phonePh: 'Mobile number',
160        smsCode: 'Verification code *',
161        smsCodePh: '6-digit code',
162        sendCode: 'Send code',
163        sendCodeWait: 'Resend in',
164        phoneInvalid: 'Please enter a valid mobile number.',
165        codeRequired: 'Please enter the 6-digit verification code.',
166        codeInvalid: 'Incorrect or expired verification code.',
167        codeSent: 'Verification code sent.',
168        sendCodeFail: 'Unable to send code. Please try again.',
169        sendCodeRateLimit: 'Too many requests. Please try again later.',
170        region: 'Country or region *',
171        regions: ['Mainland China', 'Hong Kong SAR', 'Singapore', 'United States', 'Other'],
172        message: 'Project details *',
173        messagePh: 'Please describe your use case, goals, data readiness, timeline, and deployment requirements.',
174        submit: 'Submit',
175        close: 'Close',
176        success: 'Received · we will respond within 2 business days',
177        emailInvalid: 'Please enter a valid email address.',
178        phoneDigits: 'Phone number must contain digits only.',
179        submitting: 'Submitting…',
180        submitError: 'Submission failed. Please try again later.',
181        successTitle: 'Submitted successfully',
182        successBody: 'Your request has been received. We will respond within two business days.',
183        assistantName: '01.AI Advisor',
184        assistantStatus: 'Online',
185        quickLabel: 'Suggested prompts',
186        quickPrompts: [
187          'Evaluate an enterprise AI scenario',
188          'Book a product walkthrough',
189          'Discuss private deployment'
190        ],
191        contactTitle: 'Contact card',
192        contactHint: 'The advisor only needs a few details to route this.',
193        send: 'Send to advisor'
194      }
195    : {
196        title: '预约咨询',
197        sub: '请留下您的需求,我们将在 2 个工作日内与您联系。',
198        kicker: '企业 AI 转型咨询服务',
199        pitchTitle: '让 AI 创造业务价值',
200        pitchText: '我们将与您梳理业务目标、数据基础和部署要求,明确可验证的首阶段方案。',
201        pitchPoints: [
202          '以业务结果确定优先场景',
203          '评估数据、安全与部署要求',
204          '2 个工作日内与您联系'
205        ],
206        trust: '适用于企业管理者、业务负责人及 AI 转型团队。',
207        investorTitle: '商务合作',
208        investorSub: '请留下合作方向与联系信息,我们会转交对应团队评估并跟进。',
209        investorKicker: '商务合作',
210        investorPitchTitle: '告诉我们您的合作意向',
211        investorPitchText: '请留下合作方向、公司背景和所需支持,我们会将需求转交给合适团队跟进。',
212        investorPitchPoints: [
213          '产业协同与生态合作',
214          '资本沟通与战略投资',
215          '联合方案与重点客户落地'
216        ],
217        investorTrust: '适用于投资机构、产业伙伴及生态合作方。',
218        investorMessage: '合作意向 *',
219        investorMessagePh: '请简述合作背景、合作方向、可提供资源、期望推进时间及联系人信息。',
220        mediaTitle: '媒体问询',
221        mediaSub: '请留下采访、约稿或资料需求,公关团队将与您联系。',
222        mediaKicker: '媒体沟通',
223        mediaPitchTitle: '为报道提供清晰、准确的信息',
224        mediaPitchText: '如需采访、品牌资料、事实核查或观点约稿,请说明媒体名称、报道方向与截止时间。',
225        mediaPitchPoints: [
226          '采访预约与议题沟通',
227          '品牌资料与事实核查',
228          '观点约稿与活动沟通'
229        ],
230        mediaTrust: '适用于媒体、研究机构及内容合作伙伴。',
231        mediaMessage: '媒体需求 *',
232        mediaMessagePh: '请简述媒体名称、采访或约稿主题、希望回复时间及所需资料。',
233        mediaInquiry: {
234          title: '媒体问询',
235          sub: '请填写以下信息,公关团队会尽快与您联系。',
236          kicker: '媒体问询',
237          pitchTitle: '请告诉我们您的媒体需求',
238          pitchText: '请留下您的姓名、单位机构、职务或专业主页,并说明采访、资料获取、信息核实或媒体合作需求,便于我们准确跟进。',
239          pitchPoints: [
240            '留下姓名、邮箱与手机号',
241            '提供微信号',
242            '说明采访、资料或事实核查需求'
243          ],
244          trust: '适用于记者、编辑、分析师、研究机构代表及媒体合作伙伴',
245          name: '姓名 *',
246          namePh: '您的姓名',
247          email: '邮箱 *',
248          emailPh: '请输入邮箱',
249          phone: '手机号 *',
250          phonePh: '请输入手机号',
251          website: '微信号 *',
252          websitePh: '请输入微信号',
253          intro: '简单介绍一下意向详情 *',
254          introPh: '请简要说明媒体名称、报道主题、截止时间及所需资料。'
255        },
256        businessPartnership: {
257          title: '商务合作',
258          sub: '请填写以下信息,商务合作团队会尽快与您联系。',
259          kicker: '商务合作',
260          pitchTitle: '告诉我们您的合作意向',
261          pitchText: '请留下合作方向、公司背景和所需支持,我们会将需求转交给合适团队跟进。',
262          pitchPoints: [
263            '明确提供服务、探索合作或其他意向',
264            '留下公司、网站或领英与联系信息',
265            '说明是否需要在线体验'
266          ],
267          trust: '适用于解决方案伙伴、服务商、生态合作方及企业联系人。',
268          purpose: '意向 *',
269          purposeOptions: ['提供服务', '探索合作', '其他'],
270          name: '姓名 *',
271          namePh: '您的姓名',
272          role: '职务 *',
273          rolePh: '您的职务',
274          email: '邮箱 *',
275          emailPh: '请输入邮箱',
276          phone: '手机号 *',
277          phonePh: '请输入手机号',
278          company: '公司名称 *',
279          companyPh: '公司名称',
280          website: '官网或领英 *',
281          websitePh: '官网或领英链接',
282          intro: '简单介绍一下意向详情 *',
283          introPh: '当前需引入AI应用解决的场景:\n待解决的问题:\n预期效果:\n可投入的预算:',
284          online: '是否需要在线体验?*',
285          onlineCheck: '需要在线体验',
286          onlineOptions: ['是', '否'],
287          source: '您从何种渠道了解到我们?*',
288          sourceOptions: ['小红书', '抖音', '视频号', '微博', '微信公众号', '线下峰会', '其他'],
289          selectPlaceholder: '请选择'
290        },
291        splitName: false,
292        fullName: '姓名 *',
293        fullNamePh: '您的姓名',
294        company: '公司名称 *',
295        companyPh: '公司名称',
296        role: '职位或部门 *',
297        rolePh: '您的职位或部门',
298        phone: '手机号 *',
299        phonePh: '请输入手机号',
300        smsCode: '验证码 *',
301        smsCodePh: '6 位验证码',
302        sendCode: '获取验证码',
303        sendCodeWait: '秒后重发',
304        phoneInvalid: '请输入正确的手机号。',
305        codeRequired: '请输入 6 位验证码。',
306        codeInvalid: '验证码错误或已过期,请重新获取。',
307        codeSent: '验证码已发送,请注意查收。',
308        sendCodeFail: '验证码发送失败,请稍后重试。',
309        sendCodeRateLimit: '请求过于频繁,请稍后重试。',
310        region: '国家或地区 *',
311        regions: ['中国大陆', '中国香港', '新加坡', '美国', '其他国家或地区'],
312        message: '项目概况 *',
313        messagePh: '请简述业务场景、目标、数据基础、计划启动时间和部署要求。',
314        submit: '提交',
315        close: '关闭',
316        success: '已提交 · 2 个工作日内与您联系',
317        emailInvalid: '请输入有效的邮箱地址。',
318        phoneDigits: '手机号请填写纯数字(不含空格、加号或其他符号)。',
319        submitting: '提交中…',
320        submitError: '提交失败,请稍后重试。',
321        successTitle: '提交成功',
322        successBody: '我们已收到您的信息,将在 2 个工作日内与您联系。',
323        assistantName: '01.AI 客服',
324        assistantStatus: '在线',
325        quickLabel: '快捷提问',
326        quickPrompts: [
327          '我想评估一个企业 AI 场景',
328          '我想预约产品演示',
329          '我想了解私有化部署'
330        ],
331        contactTitle: '联系人卡片',
332        contactHint: 'AI 客服只需要几项信息,方便顾问准确回访。',
333        send: '发送给 AI 顾问'
334      };
335
336  const reducePageMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
337
338  /* ─── Sticky nav state ──────────────────────────────────────────────── */
339  const nav = document.getElementById('nav');
340  if (nav) {
341    const onScroll = () => {
342      nav.classList.toggle('scrolled', window.scrollY > 20);
343    };
344    onScroll();
345    window.addEventListener('scroll', onScroll, { passive: true });
346  }
347
348  document.addEventListener('click', (event) => {
349    if (event.target.closest('.nav-dd > button.nav-trigger')) {
350      event.preventDefault();
351    }
352  });
353
354  document.addEventListener('click', (event) => {
355    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
356    const anchor = event.target.closest('.nav a[href], .nav-drawer a[href]');
357    if (!anchor || anchor.target && anchor.target !== '_self') return;
358    let url;
359    try {
360      url = new URL(anchor.getAttribute('href'), window.location.href);
361    } catch {
362      return;
363    }
364    const isSamePageHash = url.origin === window.location.origin
365      && url.pathname === window.location.pathname
366      && url.search === window.location.search
367      && url.hash;
368    if (!isSamePageHash) return;
369    const target = document.getElementById(decodeURIComponent(url.hash.slice(1)));
370    if (!target) return;
371    event.preventDefault();
372    target.scrollIntoView({ behavior: reducePageMotion.matches ? 'auto' : 'smooth', block: 'start' });
373    if (window.location.hash !== url.hash) {
374      window.history.pushState(null, '', url.hash);
375    }
376    const activeDrawer = document.getElementById('drawer');
377    if (activeDrawer) activeDrawer.classList.remove('open');
378    document.body.style.overflow = '';
379  });
380
381  /* Product megamenu — keep open while crossing the gap / adjacent nav items */
382  document.querySelectorAll('.product-nav-dd').forEach((dropdown) => {
383    const panel = dropdown.querySelector('.nav-product-panel');
384    if (!panel) return;
385
386    let closeTimer;
387    const open = () => {
388      clearTimeout(closeTimer);
389      dropdown.classList.add('is-open');
390    };
391    const scheduleClose = () => {
392      clearTimeout(closeTimer);
393      closeTimer = window.setTimeout(() => {
394        dropdown.classList.remove('is-open');
395      }, 140);
396    };
397
398    dropdown.addEventListener('mouseenter', open);
399    dropdown.addEventListener('mouseleave', scheduleClose);
400    dropdown.addEventListener('focusin', open);
401    dropdown.addEventListener('focusout', (event) => {
402      if (!dropdown.contains(event.relatedTarget)) scheduleClose();
403    });
404    panel.addEventListener('mouseenter', open);
405    panel.addEventListener('mouseleave', scheduleClose);
406  });
407
408  /* ─── Scroll reveal ─────────────────────────────────────────────────── */
409  const revealElements = Array.from(document.querySelectorAll('.reveal'));
410  const showReveal = (el) => {
411    el.classList.add('visible');
412  };
413  const isInRevealRange = (el) => {
414    const rect = el.getBoundingClientRect();
415    return rect.top < window.innerHeight && rect.bottom > 0;
416  };
417  const io = new IntersectionObserver((entries) => {
418    entries.forEach((e) => {
419      if (e.isIntersecting) {
420        showReveal(e.target);
421        io.unobserve(e.target);
422      }
423    });
424  }, { threshold: 0.01, rootMargin: '0px 0px -24px 0px' });
425  revealElements.forEach((el) => {
426    if (isInRevealRange(el)) {
427      showReveal(el);
428    } else {
429      io.observe(el);
430    }
431  });
432
433  /* ─── Mobile drawer ─────────────────────────────────────────────────── */
434  const ham = document.getElementById('ham');
435  const drawer = document.getElementById('drawer');
436  if (ham && drawer) {
437    ham.addEventListener('click', () => {
438      drawer.classList.toggle('open');
439      document.body.style.overflow = drawer.classList.contains('open') ? 'hidden' : '';
440    });
441    drawer.querySelectorAll('a').forEach((a) =>
442      a.addEventListener('click', () => {
443        drawer.classList.remove('open');
444        document.body.style.overflow = '';
445      })
446    );
447  }
448
449  /* ─── Page transition between nav tabs ─────────────────────────────── */
450  const markPageReady = () => {
451    document.body.classList.remove('page-transition-leaving');
452    document.body.classList.add('page-transition-ready');
453    if (fromNavTransition) {
454      window.setTimeout(() => {
455        document.body.classList.add('page-transition-nav-complete');
456      }, 1800);
457    }
458  };
459  window.addEventListener('pageshow', markPageReady);
460  requestAnimationFrame(markPageReady);
461
462  const isInternalNavigation = (anchor) => {
463    if (!anchor || reducePageMotion.matches) return false;
464    if (anchor.target && anchor.target !== '_self') return false;
465    if (anchor.hasAttribute('download')) return false;
466    if (anchor.closest('[data-quick], [data-open-contact], [data-business-partnership], [data-media-inquiry]')) return false;
467    const href = anchor.getAttribute('href');
468    if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return false;
469    if (/(?:^|\/)contact\.html#quick$/.test(href.trim())) return false;
470
471    let url;
472    try {
473      url = new URL(href, window.location.href);
474    } catch {
475      return false;
476    }
477    if (url.origin !== window.location.origin) return false;
478
479    const samePath = url.pathname === window.location.pathname;
480    const sameSearch = url.search === window.location.search;
481    if (samePath && sameSearch && url.hash) return false;
482    if (samePath && sameSearch && !url.hash) return false;
483
484    return Boolean(anchor.closest('.nav, .nav-drawer'));
485  };
486
487  document.addEventListener('click', (e) => {
488    if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
489    const anchor = e.target.closest('a[href]');
490    if (!isInternalNavigation(anchor)) return;
491    e.preventDefault();
492    try {
493      window.sessionStorage.setItem(NAV_TRANSITION_KEY, '1');
494    } catch {
495      /* Ignore storage restrictions; fade-out still applies on the current page. */
496    }
497    document.body.classList.add('page-transition-leaving');
498    window.setTimeout(() => {
499      window.location.href = anchor.href;
500    }, 180);
501  });
502
503  /* Cursor glow removed — Bento style stays still. */
504
505  /* ─── Home industry backdrop carousel ───────────────────────────────── */
506  const heroCarousel = document.querySelector('.home-hero');
507  if (heroCarousel) {
508    const slides = Array.from(heroCarousel.querySelectorAll('[data-hero-backdrop]'));
509    const tabs = Array.from(heroCarousel.querySelectorAll('[data-hero-slide]'));
510    const metrics = Array.from(heroCarousel.querySelectorAll('[data-hero-metrics]'));
511    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
512    let activeSlide = 0;
513    let rotation;
514    let rotationStartTimer;
515    let transitionTimer;
516    let transitionToken = 0;
517
518    const preloadSlide = (slide) => {
519      const img = slide && slide.querySelector('img');
520      if (!img || img.complete) return Promise.resolve();
521      if (typeof img.decode === 'function') {
522        return img.decode().catch(() => undefined);
523      }
524      return new Promise((resolve) => {
525        img.addEventListener('load', resolve, { once: true });
526        img.addEventListener('error', resolve, { once: true });
527      });
528    };
529    slides.forEach((slide) => preloadSlide(slide));
530
531    const showSlide = async (index) => {
532      if (index === activeSlide || !slides[index]) return;
533      const token = ++transitionToken;
534      const previous = slides[activeSlide];
535      const next = slides[index];
536      await preloadSlide(next);
537      if (token !== transitionToken) return;
538
539      window.clearTimeout(transitionTimer);
540      slides.forEach((slide, slideIndex) => {
541        slide.classList.toggle('is-active', slideIndex === activeSlide);
542        slide.classList.remove('is-leaving', 'is-next');
543      });
544      next.classList.add('is-next');
545      next.getBoundingClientRect();
546      next.classList.add('is-active');
547      if (previous) previous.classList.add('is-leaving');
548      activeSlide = index;
549      tabs.forEach((tab, tabIndex) => {
550        const active = tabIndex === index;
551        tab.classList.toggle('is-active', active);
552        tab.setAttribute('aria-selected', String(active));
553        tab.tabIndex = active ? 0 : -1;
554      });
555      metrics.forEach((card, cardIndex) => {
556        const active = cardIndex === index;
557        card.classList.toggle('is-active', active);
558        card.setAttribute('aria-hidden', String(!active));
559      });
560      transitionTimer = window.setTimeout(() => {
561        slides.forEach((slide, slideIndex) => {
562          slide.classList.toggle('is-active', slideIndex === activeSlide);
563          slide.classList.remove('is-leaving', 'is-next');
564        });
565      }, 1450);
566    };
567    const stopRotation = () => {
568      window.clearInterval(rotation);
569      window.clearTimeout(rotationStartTimer);
570    };
571    const startRotation = () => {
572      stopRotation();
573      if (!reduceMotion.matches && slides.length > 1) {
574        rotation = window.setInterval(() => showSlide((activeSlide + 1) % slides.length), 5600);
575      }
576    };
577    const startRotationAfterFirstSwitch = () => {
578      startRotation();
579      showSlide((activeSlide + 1) % slides.length);
580    };
581    const scheduleRotationStart = (delay = 5000) => {
582      stopRotation();
583      if (!reduceMotion.matches && slides.length > 1) {
584        rotationStartTimer = window.setTimeout(startRotationAfterFirstSwitch, delay);
585      }
586    };
587    const waitForHeroTitle = () => {
588      const titleLines = Array.from(heroCarousel.querySelectorAll('.hero-display .line'));
589      const lastLine = titleLines[titleLines.length - 1];
590      if (!lastLine || getComputedStyle(lastLine).animationName === 'none') {
591        scheduleRotationStart();
592        return;
593      }
594      const fallback = window.setTimeout(() => scheduleRotationStart(), 1700);
595      lastLine.addEventListener('animationend', () => {
596        window.clearTimeout(fallback);
597        scheduleRotationStart();
598      }, { once: true });
599    };
600
601    tabs.forEach((tab, index) => {
602      tab.addEventListener('click', () => {
603        showSlide(index);
604        startRotation();
605      });
606    });
607    document.addEventListener('visibilitychange', () => {
608      if (document.hidden) stopRotation();
609      else scheduleRotationStart();
610    });
611    reduceMotion.addEventListener('change', () => scheduleRotationStart());
612    waitForHeroTitle();
613  }
614
615  /* ─── Customer case carousel ───────────────────────────────────────── */
616  document.querySelectorAll('[data-customer-carousel]').forEach((carousel) => {
617    const track = carousel.querySelector('[data-customer-track]');
618    const prev = carousel.querySelector('[data-customer-prev]');
619    const next = carousel.querySelector('[data-customer-next]');
620    if (!track || !prev || !next) return;
621
622    const cardStep = () => {
623      const card = track.querySelector('.customer-card');
624      const gap = Number.parseFloat(getComputedStyle(track).columnGap || getComputedStyle(track).gap || '0') || 0;
625      return card ? card.getBoundingClientRect().width + gap : track.clientWidth * 0.82;
626    };
627    const updateControls = () => {
628      const maxScroll = track.scrollWidth - track.clientWidth;
629      prev.disabled = track.scrollLeft <= 4;
630      next.disabled = track.scrollLeft >= maxScroll - 4;
631    };
632    const move = (direction) => {
633      track.scrollBy({
634        left: direction * cardStep(),
635        behavior: reducePageMotion.matches ? 'auto' : 'smooth'
636      });
637      window.setTimeout(updateControls, reducePageMotion.matches ? 0 : 260);
638    };
639
640    prev.addEventListener('click', () => move(-1));
641    next.addEventListener('click', () => move(1));
642    track.addEventListener('scroll', updateControls, { passive: true });
643    window.addEventListener('resize', updateControls, { passive: true });
644    updateControls();
645  });
646
647  /* ─── Home solution industry tabs ──────────────────────────────────── */
648  document.querySelectorAll('[data-home-solution-tabs]').forEach((module) => {
649    const tabs = Array.from(module.querySelectorAll('[data-home-solution-tab]'));
650    const panels = Array.from(module.querySelectorAll('[data-home-solution-panel]'));
651    if (!tabs.length || !panels.length) return;
652    let activeIndex = Math.max(0, tabs.findIndex((item) => item.classList.contains('is-active')));
653    let rotation;
654
655    const keepTabVisible = (tab) => {
656      const track = tab.closest('.home-solution-tablist');
657      if (!track || track.scrollWidth <= track.clientWidth) return;
658      const tabLeft = tab.offsetLeft;
659      const tabRight = tabLeft + tab.offsetWidth;
660      const viewLeft = track.scrollLeft;
661      const viewRight = viewLeft + track.clientWidth;
662      let nextLeft = null;
663      if (tabLeft < viewLeft) nextLeft = tabLeft;
664      if (tabRight > viewRight) nextLeft = tabRight - track.clientWidth;
665      if (nextLeft === null) return;
666      track.scrollTo({
667        left: nextLeft,
668        behavior: reducePageMotion.matches ? 'auto' : 'smooth'
669      });
670    };
671
672    const activate = (index, focus = false) => {
673      if (!tabs[index] || !panels[index]) return;
674      activeIndex = index;
675      tabs.forEach((tab, tabIndex) => {
676        const active = tabIndex === index;
677        tab.classList.toggle('is-active', active);
678        tab.setAttribute('aria-selected', String(active));
679        tab.tabIndex = active ? 0 : -1;
680        if (active && focus) tab.focus({ preventScroll: true });
681        if (active && !focus) keepTabVisible(tab);
682      });
683      panels.forEach((panel, panelIndex) => {
684        const active = panelIndex === index;
685        panel.classList.toggle('is-active', active);
686        panel.hidden = !active;
687      });
688    };
689    const stopRotation = () => {
690      window.clearInterval(rotation);
691    };
692    const startRotation = () => {
693      stopRotation();
694      if (!reducePageMotion.matches && tabs.length > 1) {
695        rotation = window.setInterval(() => activate((activeIndex + 1) % tabs.length), 5200);
696      }
697    };
698
699    tabs.forEach((tab, index) => {
700      tab.addEventListener('click', () => {
701        activate(index);
702        startRotation();
703      });
704      tab.addEventListener('keydown', (event) => {
705        const current = tabs.findIndex((item) => item.classList.contains('is-active'));
706        let nextIndex = current;
707        if (event.key === 'ArrowRight') nextIndex = (current + 1) % tabs.length;
708        if (event.key === 'ArrowLeft') nextIndex = (current - 1 + tabs.length) % tabs.length;
709        if (event.key === 'Home') nextIndex = 0;
710        if (event.key === 'End') nextIndex = tabs.length - 1;
711        if (nextIndex !== current) {
712          event.preventDefault();
713          activate(nextIndex, true);
714          startRotation();
715        }
716      });
717    });
718    module.addEventListener('mouseenter', stopRotation);
719    module.addEventListener('mouseleave', startRotation);
720    module.addEventListener('focusin', stopRotation);
721    module.addEventListener('focusout', () => {
722      window.setTimeout(() => {
723        if (!module.contains(document.activeElement)) startRotation();
724      }, 0);
725    });
726    document.addEventListener('visibilitychange', () => {
727      if (document.hidden) stopRotation();
728      else startRotation();
729    });
730    reducePageMotion.addEventListener('change', startRotation);
731    startRotation();
732  });
733
734  /* ─── Contact modal (global) ────────────────────────────────────────── */
735  const businessPartnership = T.businessPartnership;
736  const optionTags = (options) => options.map((option) => `<option value="${option}">${option}</option>`).join('');
737  const phoneInputConstraints = isEN
738    ? 'inputmode="tel"'
739    : 'inputmode="numeric" pattern="1[3-9][0-9]{9}" minlength="11" maxlength="11"';
740  const smsVerificationField = (idPrefix = 'contact') => isEN ? '' : `
741          <div class="form-field form-field-sms-code">
742            <label for="${idPrefix}-sms-code">${T.smsCode}</label>
743            <div class="phone-code-input-wrap">
744              <input id="${idPrefix}-sms-code" type="text" name="smsCode" placeholder="${T.smsCodePh}" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required>
745              <button type="button" class="phone-verify-send">${T.sendCode}</button>
746            </div>
747            <p class="phone-verify-feedback" role="status" aria-live="polite" hidden></p>
748          </div>`;
749  const businessPartnershipFields = (topicValue, topicId = '') => {
750    const fieldId = (name) => `${topicValue}-${name}`;
751    return `
752          <input type="hidden" name="topic"${topicId ? ` id="${topicId}"` : ''} value="${topicValue}">
753          <div class="form-field">
754            <label for="${fieldId('name')}">${businessPartnership.name}</label>
755            <input id="${fieldId('name')}" type="text" name="name" placeholder="${businessPartnership.namePh}" autocomplete="name" required>
756          </div>
757          <div class="form-field">
758            <label for="${fieldId('role')}">${businessPartnership.role}</label>
759            <input id="${fieldId('role')}" type="text" name="role" placeholder="${businessPartnership.rolePh}" autocomplete="organization-title" required>
760          </div>
761          <div class="form-field">
762            <label for="${fieldId('email')}">${businessPartnership.email}</label>
763            <input id="${fieldId('email')}" type="email" name="email" placeholder="${businessPartnership.emailPh}" autocomplete="email" required>
764          </div>
765          <div class="form-field">
766            <label for="${fieldId('phone')}">${businessPartnership.phone}</label>
767            <input id="${fieldId('phone')}" type="tel" name="phone" placeholder="${businessPartnership.phonePh}" autocomplete="tel" ${phoneInputConstraints} required>
768          </div>
769          ${smsVerificationField(topicValue)}
770          <div class="form-field">
771            <label for="${fieldId('company')}">${businessPartnership.company}</label>
772            <input id="${fieldId('company')}" type="text" name="company" placeholder="${businessPartnership.companyPh}" autocomplete="organization" required>
773          </div>
774          <div class="form-field">
775            <label for="${fieldId('website')}">${businessPartnership.website}</label>
776            <input id="${fieldId('website')}" type="text" name="website" placeholder="${businessPartnership.websitePh}" autocomplete="url" required>
777          </div>
778          <div class="form-field">
779            <label for="${fieldId('purpose')}">${businessPartnership.purpose}</label>
780            <select id="${fieldId('purpose')}" name="purpose" required>
781              <option value="" selected disabled>${businessPartnership.selectPlaceholder}</option>
782              ${optionTags(businessPartnership.purposeOptions)}
783            </select>
784          </div>
785          <div class="form-field">
786            <label for="${fieldId('source')}">${businessPartnership.source}</label>
787            <select id="${fieldId('source')}" name="source" required>
788              <option value="" selected disabled>${businessPartnership.selectPlaceholder}</option>
789              ${optionTags(businessPartnership.sourceOptions)}
790            </select>
791          </div>
792          <div class="form-field full">
793            <label for="${fieldId('introduction')}">${businessPartnership.intro}</label>
794            <textarea id="${fieldId('introduction')}" name="introduction" placeholder="${businessPartnership.introPh}" rows="4" required></textarea>
795          </div>
796          <label class="modal-checkbox-field">
797            <input id="${fieldId('online-experience')}" type="checkbox" name="onlineExperience" value="yes">
798            <span>${businessPartnership.onlineCheck}</span>
799          </label>`;
800  };
801  const modal = document.createElement('div');
802  modal.className = 'modal modal-consult';
803  modal.setAttribute('aria-hidden', 'true');
804  modal.innerHTML = `
805    <div class="modal-backdrop" data-close></div>
806    <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="modal-title">
807      <button class="modal-close" type="button" data-close aria-label="${T.close}">
808        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
809          <path d="M6 6l12 12M6 18L18 6"/>
810        </svg>
811      </button>
812      <section class="modal-promise">
813        <div class="modal-kicker">${T.kicker}</div>
814        <h3 id="modal-pitch-title">${T.pitchTitle}</h3>
815        <p class="modal-pitch-text" id="modal-pitch-text">${T.pitchText}</p>
816        <ul class="modal-benefits">
817          ${T.pitchPoints.map((point) => `<li>${point}</li>`).join('')}
818        </ul>
819        <p class="modal-trust">${T.trust}</p>
820      </section>
821      <section class="modal-form-side">
822        <div class="modal-head">
823          <h2 id="modal-title">${T.title}</h2>
824          <p id="modal-sub">${T.sub}</p>
825        </div>
826        <form class="modal-form" id="modal-form" data-demo>
827          ${businessPartnershipFields('business', 'modal-topic')}
828          <div class="modal-actions full">
829            <button type="submit" class="btn btn-primary">
830              ${T.submit}
831            </button>
832          </div>
833        </form>
834      </section>
835    </div>`;
836  document.body.appendChild(modal);
837
838  // Build-time placeholder replaced by scripts/build-deploy.sh (FORM_API_BASE).
839  // Can also be overridden at runtime via window.OAI_FORM_API_BASE.
840  const FORM_API_BASE = String(window.OAI_FORM_API_BASE || 'https://www.wanzhi.com/app').replace(/\/+$/, '');
841  // Split so the build-time sed replacement never rewrites this guard token.
842  const FORM_API_PLACEHOLDER = '__FORM' + '_API_BASE__';
843  const HAS_FORM_API = Boolean(FORM_API_BASE && FORM_API_BASE !== FORM_API_PLACEHOLDER);
844  const FORM_ENDPOINT = HAS_FORM_API
845    ? `${FORM_API_BASE}/api/v1/landing/form_submit`
846    : null;
847  const SMS_ENDPOINT = window.OAI_SMS_ENDPOINT || (HAS_FORM_API
848    ? `${FORM_API_BASE}/api/v1/landing/send_verification_code`
849    : null);
850  const apiRequestHeaders = (endpoint) => {
851    const headers = { 'Content-Type': 'application/json' };
852    try {
853      if (new URL(endpoint, window.location.href).hostname === 'test-app.lingyiwanwu.net') {
854        headers['X-Target-Service-appdevhub'] = 'boe-landing';
855      }
856    } catch {
857      // A malformed runtime override will fail naturally when fetch is called.
858    }
859    return headers;
860  };
861  const parseApiResponse = async (response, errorPrefix, options = {}) => {
862    let data = null;
863    try {
864      data = await response.json();
865    } catch {
866      // Some successful gateway responses have no JSON body.
867    }
868
869    const hasBusinessCode = data && Object.prototype.hasOwnProperty.call(data, 'code');
870    const businessCode = hasBusinessCode ? Number(data.code) : null;
871    const businessFailed = hasBusinessCode && businessCode !== 200;
872    const missingBusinessCode = options.requireBusinessCode && !hasBusinessCode;
873    if (!response.ok || businessFailed || missingBusinessCode) {
874      const err = new Error(data?.message || `${errorPrefix}_${response.status}`);
875      err.status = response.status;
876      err.code = hasBusinessCode ? data.code : null;
877      err.apiMessage = data?.message || '';
878      err.businessError = businessFailed || missingBusinessCode;
879      throw err;
880    }
881    return data;
882  };
883
884  const SMS_CODE_TTL = 5 * 60 * 1000;
885  const SMS_RESEND_SECONDS = 60;
886  const smsStore = new Map();
887  const smsTimers = new WeakMap();
888
889  const normalizePhone = (value) => String(value || '').replace(/\s|-/g, '');
890  const isValidPhone = (phone) => (isEN
891    ? /^\+?\d{7,15}$/.test(normalizePhone(phone))
892    : /^1[3-9]\d{9}$/.test(String(phone || '').trim()));
893
894  const getSmsControls = (form) => ({
895    phoneInput: form?.querySelector('[name="phone"]'),
896    smsCodeInput: form?.querySelector('[name="smsCode"]'),
897    sendCodeBtn: form?.querySelector('.phone-verify-send'),
898    verifyFeedback: form?.querySelector('.phone-verify-feedback')
899  });
900
901  const setVerifyFeedback = (form, message, isError = false) => {
902    const { verifyFeedback }
902 = getSmsControls(form);
903    if (!verifyFeedback) return;
904    verifyFeedback.textContent = message || '';
905    verifyFeedback.hidden = !message;
906    verifyFeedback.classList.toggle('is-error', Boolean(message && isError));
907    verifyFeedback.classList.toggle('is-success', Boolean(message && !isError));
908  };
909
910  const clearSendCodeTimer = (form) => {
911    if (!form) return;
912    const state = smsTimers.get(form);
913    if (state?.timer) window.clearInterval(state.timer);
914    const { sendCodeBtn } = getSmsControls(form);
915    if (sendCodeBtn) {
916      sendCodeBtn.disabled = false;
917      sendCodeBtn.textContent = T.sendCode;
918    }
919    smsTimers.set(form, { timer: null });
920  };
921
922  const clearSmsVerification = (form) => {
923    clearSendCodeTimer(form);
924    setVerifyFeedback(form, '');
925  };
926
927  const startSendCodeCountdown = (form) => {
928    const { sendCodeBtn } = getSmsControls(form);
929    if (!sendCodeBtn) return;
930    let sendCodeRemaining = SMS_RESEND_SECONDS;
931    sendCodeBtn.disabled = true;
932    sendCodeBtn.textContent = isEN
933      ? `${T.sendCodeWait} ${sendCodeRemaining}s`
934      : `${sendCodeRemaining}${T.sendCodeWait}`;
935    const timer = window.setInterval(() => {
936      sendCodeRemaining -= 1;
937      if (sendCodeRemaining <= 0) {
938        clearSendCodeTimer(form);
939        return;
940      }
941      sendCodeBtn.textContent = isEN
942        ? `${T.sendCodeWait} ${sendCodeRemaining}s`
943        : `${sendCodeRemaining}${T.sendCodeWait}`;
944    }, 1000);
945    smsTimers.set(form, { timer });
946  };
947
948  const storeSmsCode = (phone, code) => {
949    smsStore.set(phone, { code, expiresAt: Date.now() + SMS_CODE_TTL });
950  };
951
952  const verifySmsCode = (phone, code) => {
953    const entry = smsStore.get(phone);
954    if (!entry || Date.now() > entry.expiresAt) return false;
955    return entry.code === code;
956  };
957
958  const requestSmsCode = async (phone) => {
959    if (SMS_ENDPOINT) {
960      const response = await fetch(SMS_ENDPOINT, {
961        method: 'POST',
962        headers: apiRequestHeaders(SMS_ENDPOINT),
963        body: JSON.stringify({ phone })
964      });
965      await parseApiResponse(response, 'send_failed', { requireBusinessCode: true });
966      return;
967    }
968
969    const code = String(Math.floor(100000 + Math.random() * 900000));
970    storeSmsCode(phone, code);
971    if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
972      console.info('[01.AI demo] SMS verification code:', code);
973    }
974  };
975
976  document.addEventListener('click', async (e) => {
977    const sendCodeBtn = e.target.closest('.phone-verify-send');
978    if (!sendCodeBtn) return;
979    const form = sendCodeBtn.closest('form[data-demo]');
980    const { phoneInput } = getSmsControls(form);
981    const rawPhone = phoneInput?.value || '';
982    const phone = normalizePhone(rawPhone);
983    if (!isValidPhone(rawPhone)) {
984      setVerifyFeedback(form, T.phoneInvalid, true);
985      phoneInput?.focus();
986      return;
987    }
988
989    setVerifyFeedback(form, '');
990    sendCodeBtn.disabled = true;
991    try {
992      await requestSmsCode(phone);
993      setVerifyFeedback(form, T.codeSent, false);
994      startSendCodeCountdown(form);
995    } catch (err) {
996      const rateLimited = /too many requests/i.test(err.apiMessage || '');
997      if (rateLimited) {
998        setVerifyFeedback(form, T.sendCodeRateLimit, true);
999        startSendCodeCountdown(form);
1000      } else {
1001        clearSendCodeTimer(form);
1002        setVerifyFeedback(form, T.sendCodeFail, true);
1003      }
1004    }
1005  });
1006
1007  document.addEventListener('input', (e) => {
1008    const form = e.target.closest('form[data-demo]');
1009    if (!form || !form.querySelector('[name="smsCode"]')) return;
1010
1011    if (e.target.name === 'phone') {
1012      if (!isEN) e.target.value = e.target.value.replace(/\D/g, '').slice(0, 11);
1013      const phone = normalizePhone(e.target.value);
1014      if (smsStore.has(phone)) return;
1015      setVerifyFeedback(form, '');
1016    }
1017    if (e.target.name === 'smsCode') {
1018      const { verifyFeedback } = getSmsControls(form);
1019      if (verifyFeedback?.classList.contains('is-error')) setVerifyFeedback(form, '');
1020    }
1021  });
1022
1023  const modalTopic = modal.querySelector('#modal-topic');
1024  const modalTitle = modal.querySelector('#modal-title');
1025  const modalSub = modal.querySelector('#modal-sub');
1026  const modalKicker = modal.querySelector('.modal-kicker');
1027  const modalPitchTitle = modal.querySelector('#modal-pitch-title');
1028  const modalPitchText = modal.querySelector('#modal-pitch-text');
1029  const modalBenefits = modal.querySelector('.modal-benefits');
1030  const modalTrust = modal.querySelector('.modal-trust');
1031  const modalIntro = modal.querySelector('[name="introduction"]');
1032  const VALID_TOPICS = ['business', 'investor', 'license', 'media', 'careers'];
1033  const topicContent = {
1034    business: {
1035      title: T.title,
1036      sub: T.sub,
1037      kicker: T.kicker,
1038      pitchTitle: T.pitchTitle,
1039      pitchText: T.pitchText,
1040      pitchPoints: T.pitchPoints,
1041      trust: T.trust,
1042      message: T.message,
1043      messagePh: T.messagePh
1044    },
1045    investor: {
1046      title: T.investorTitle,
1047      sub: T.investorSub,
1048      kicker: T.investorKicker,
1049      pitchTitle: T.investorPitchTitle,
1050      pitchText: T.investorPitchText,
1051      pitchPoints: T.investorPitchPoints,
1052      trust: T.investorTrust,
1053      message: T.investorMessage,
1054      messagePh: T.investorMessagePh
1055    },
1056    media: {
1057      title: T.mediaTitle,
1058      sub: T.mediaSub,
1059      kicker: T.mediaKicker,
1060      pitchTitle: T.mediaPitchTitle,
1061      pitchText: T.mediaPitchText,
1062      pitchPoints: T.mediaPitchPoints,
1063      trust: T.mediaTrust,
1064      message: T.mediaMessage,
1065      messagePh: T.mediaMessagePh
1066    }
1067  };
1068
1069  const resetSubmitSuccess = (scope) => {
1070    scope.classList.remove('is-submit-success');
1071    scope.querySelectorAll('.modal-submit-success').forEach((panel) => {
1072      panel.hidden = true;
1073    });
1074    scope.querySelectorAll('form[data-demo]').forEach((form) => {
1075      form.hidden = false;
1076      const btn = form.querySelector('button[type="submit"]');
1077      if (btn) {
1078        btn.disabled = false;
1079        btn.classList.remove('is-success');
1080      }
1081    });
1082  };
1083
1084  const showSubmitSuccess = (form) => {
1085    const modalScope = form.closest('.modal');
1086    const formSide = form.closest('.modal-form-side') || form.parentElement;
1087    let panel = formSide?.querySelector('.modal-submit-success');
1088    if (!panel) {
1089      panel = document.createElement('section');
1090      panel.className = 'modal-submit-success';
1091      panel.hidden = true;
1092      panel.setAttribute('role', 'status');
1093      panel.setAttribute('tabindex', '-1');
1094      panel.innerHTML = `
1095        <div class="modal-submit-check" aria-hidden="true">
1096          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
1097            <path d="M20 6L9 17l-5-5"/>
1098          </svg>
1099        </div>
1100        <h3>${T.successTitle}</h3>
1101        <p>${T.successBody}</p>
1102        <button type="button" class="btn btn-primary modal-submit-close" data-close>${T.close}</button>`;
1103      form.insertAdjacentElement('afterend', panel);
1104    }
1105    form.reset();
1106    form.hidden = true;
1107    modalScope?.classList.add('is-submit-success');
1108    panel.hidden = false;
1109    window.setTimeout(() => panel.focus({ preventScroll: true }), 30);
1110  };
1111
1112  const openModal = (topic) => {
1113    const nextTopic = topic && VALID_TOPICS.includes(topic) ? topic : 'business';
1114    const content = topicContent[nextTopic] || topicContent.business;
1115    resetSubmitSuccess(modal);
1116    modalTopic.value = nextTopic;
1117    modalTitle.textContent = content.title;
1118    modalSub.textContent = content.sub;
1119    if (modalKicker) modalKicker.textContent = content.kicker;
1120    if (modalPitchTitle) modalPitchTitle.textContent = content.pitchTitle;
1121    modalPitchText.textContent = content.pitchText;
1122    if (modalBenefits) modalBenefits.innerHTML = content.pitchPoints.map((point) => `<li>${point}</li>`).join('');
1123    if (modalTrust) modalTrust.textContent = content.trust;
1124    modal.classList.add('open');
1125    modal.setAttribute('aria-hidden', 'false');
1126    document.body.style.overflow = 'hidden';
1127    setTimeout(() => {
1128      modal.querySelector('[name="name"]')?.focus();
1129    }, 60);
1130  };
1131  const closeModal = () => {
1132    modal.classList.remove('open');
1133    modal.setAttribute('aria-hidden', 'true');
1134    document.body.style.overflow = '';
1135    resetSubmitSuccess(modal);
1136    clearSmsVerification(modal.querySelector('form[data-demo]'));
1137  };
1138
1139  const businessModal = document.createElement('div');
1140  businessModal.className = 'modal modal-consult modal-business-partnership';
1141  businessModal.setAttribute('aria-hidden', 'true');
1142  businessModal.innerHTML = `
1143    <div class="modal-backdrop" data-close></div>
1144    <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="business-modal-title">
1145      <button class="modal-close" type="button" data-close aria-label="${T.close}">
1146        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1147          <path d="M6 6l12 12M6 18L18 6"/>
1148        </svg>
1149      </button>
1150      <section class="modal-promise">
1151        <div class="modal-kicker">${businessPartnership.kicker}</div>
1152        <h3>${businessPartnership.pitchTitle}</h3>
1153        <p class="modal-pitch-text">${businessPartnership.pitchText}</p>
1154        <ul class="modal-benefits">
1155          ${businessPartnership.pitchPoints.map((point) => `<li>${point}</li>`).join('')}
1156        </ul>
1157        <p class="modal-trust">${businessPartnership.trust}</p>
1158      </section>
1159      <section class="modal-form-side">
1160        <div class="modal-head">
1161          <h2 id="business-modal-title">${businessPartnership.title}</h2>
1162          <p>${businessPartnership.sub}</p>
1163        </div>
1164        <form class="modal-form business-partnership-form" id="business-partnership-form" data-demo>
1165          ${businessPartnershipFields('business-partnership')}
1166          <div class="modal-actions full">
1167            <button type="submit" class="btn btn-primary">${T.submit}</button>
1168          </div>
1169        </form>
1170      </section>
1171    </div>`;
1172  document.body.appendChild(businessModal);
1173
1174  const mediaInquiry = T.mediaInquiry;
1175  const mediaModal = document.createElement('div');
1176  mediaModal.className = 'modal modal-consult modal-media-inquiry';
1177  mediaModal.setAttribute('aria-hidden', 'true');
1178  mediaModal.innerHTML = `
1179    <div class="modal-backdrop" data-close></div>
1180    <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="media-modal-title">
1181      <button class="modal-close" type="button" data-close aria-label="${T.close}">
1182        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
1183          <path d="M6 6l12 12M6 18L18 6"/>
1184        </svg>
1185      </button>
1186      <section class="modal-promise">
1187        <div class="modal-kicker">${mediaInquiry.kicker}</div>
1188        <h3>${mediaInquiry.pitchTitle}</h3>
1189        <p class="modal-pitch-text">${mediaInquiry.pitchText}</p>
1190        <p class="modal-trust">${mediaInquiry.trust}</p>
1191      </section>
1192      <section class="modal-form-side">
1193        <div class="modal-head">
1194          <h2 id="media-modal-title">${mediaInquiry.title}</h2>
1195          <p>${mediaInquiry.sub}</p>
1196        </div>
1197        <form class="modal-form media-inquiry-form" id="media-inquiry-form" data-demo>
1198          <input type="hidden" name="topic" value="media-inquiry">
1199          <div class="form-field">
1200            <label for="media-inquiry-name">${mediaInquiry.name}</label>
1201            <input id="media-inquiry-name" type="text" name="name" placeholder="${mediaInquiry.namePh}" autocomplete="name" required>
1202          </div>
1203          <div class="form-field">
1204            <label for="media-inquiry-website">${mediaInquiry.website}</label>
1205            <input id="media-inquiry-website" type="text" name="website" placeholder="${mediaInquiry.websitePh}" autocomplete="url" required>
1206          </div>
1207          <div class="form-field">
1208            <label for="media-inquiry-phone">${mediaInquiry.phone}</label>
1209            <input id="media-inquiry-phone" type="tel" name="phone" placeholder="${mediaInquiry.phonePh}" autocomplete="tel" ${phoneInputConstraints} required>
1210          </div>
1211          ${smsVerificationField('media-inquiry')}
1212          <div class="form-field">
1213            <label for="media-inquiry-email">${mediaInquiry.email}</label>
1214            <input id="media-inquiry-email" type="email" name="email" placeholder="${mediaInquiry.emailPh}" autocomplete="email" required>
1215          </div>
1216          <div class="form-field full">
1217            <label for="media-inquiry-introduction">${mediaInquiry.intro}</label>
1218            <textarea id="media-inquiry-introduction" name="introduction" placeholder="${mediaInquiry.introPh}" rows="5" required></textarea>
1219          </div>
1220          <div class="modal-actions full">
1221            <button type="submit" class="btn btn-primary">${T.submit}</button>
1222          </div>
1223        </form>
1224      </section>
1225    </div>`;
1226  document.body.appendChild(mediaModal);
1227
1228  const closeBusinessPartnershipModal = () => {
1229    businessModal.classList.remove('open');
1230    businessModal.setAttribute('aria-hidden', 'true');
1231    document.body.style.overflow = '';
1232    resetSubmitSuccess(businessModal);
1233    clearSmsVerification(businessModal.querySelector('form[data-demo]'));
1234  };
1235  const closeMediaInquiryModal = () => {
1236    mediaModal.classList.remove('open');
1237    mediaModal.setAttribute('aria-hidden', 'true');
1238    document.body.style.overflow = '';
1239    resetSubmitSuccess(mediaModal);
1240    clearSmsVerification(mediaModal.querySelector('form[data-demo]'));
1241  };
1242  const openBusinessPartnershipModal = () => {
1243    closeModal();
1244    closeMediaInquiryModal();
1245    resetSubmitSuccess(businessModal);
1246    businessModal.classList.add('open');
1247    businessModal.setAttribute('aria-hidden', 'false');
1248    document.body.style.overflow = 'hidden';
1249    setTimeout(() => {
1250      businessModal.querySelector('[name="name"]')?.focus();
1251    }, 60);
1252  };
1253  const openMediaInquiryModal = () => {
1254    closeModal();
1255    closeBusinessPartnershipModal();
1256    resetSubmitSuccess(mediaModal);
1257    mediaModal.classList.add('open');
1258    mediaModal.setAttribute('aria-hidden', 'false');
1259    document.body.style.overflow = 'hidden';
1260    setTimeout(() => {
1261      mediaModal.querySelector('[name="name"]')?.focus();
1262    }, 60);
1263  };
1264
1265  modal.addEventListener('click', (e) => {
1266    const quickPrompt = e.target.closest('[data-ai-prompt]');
1267    if (quickPrompt) {
1268      const prompt = quickPrompt.dataset.aiPrompt || quickPrompt.textContent.trim();
1269      if (modalIntro) {
1270        modalIntro.value = modalIntro.value ? `${modalIntro.value}\n${prompt}` : prompt;
1271        modalIntro.focus();
1272      }
1273      return;
1274    }
1275    if (e.target.closest('[data-close]')) closeModal();
1276  });
1277  businessModal.addEventListener('click', (e) => {
1278    if (e.target.closest('[data-close]')) closeBusinessPartnershipModal();
1279  });
1280  mediaModal.addEventListener('click', (e) => {
1281    if (e.target.closest('[data-close]')) closeMediaInquiryModal();
1282  });
1283  document.addEventListener('keydown', (e) => {
1284    if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
1285    if (e.key === 'Escape' && businessModal.classList.contains('open')) closeBusinessPartnershipModal();
1286    if (e.key === 'Escape' && mediaModal.classList.contains('open')) closeMediaInquiryModal();
1287  });
1288
1289  /* Bind triggers:
1290     - [data-quick="<topic>
1290"] buttons (on contact cards)
1291     - [data-open-contact] explicit triggers
1292     - Any <a> pointing at contact.html#quick, ../contact.html#quick, or #quick on the contact page */
1293  const wantsContactModal = (href) => {
1294    if (!href) return false;
1295    // Strip fragment normalization
1296    const h = href.trim();
1297    return /(?:^|\/)contact\.html#quick$/.test(h) || h === '#quick';
1298  };
1299  document.addEventListener('click', (e) => {
1300    const businessPartnershipBtn = e.target.closest('[data-business-partnership]');
1301    if (businessPartnershipBtn) {
1302      e.preventDefault();
1303      openBusinessPartnershipModal();
1304      return;
1305    }
1306    const mediaInquiryBtn = e.target.closest('[data-media-inquiry]');
1307    if (mediaInquiryBtn) {
1308      e.preventDefault();
1309      openMediaInquiryModal();
1310      return;
1311    }
1312    const quickBtn = e.target.closest('[data-quick]');
1313    if (quickBtn) {
1314      e.preventDefault();
1315      if (quickBtn.dataset.quick === 'investor') {
1316        openBusinessPartnershipModal();
1317        return;
1318      }
1319      if (quickBtn.dataset.quick === 'media') {
1320        openMediaInquiryModal();
1321        return;
1322      }
1323      openModal(quickBtn.dataset.quick);
1324      return;
1325    }
1326    const openBtn = e.target.closest('[data-open-contact]');
1327    if (openBtn) {
1328      e.preventDefault();
1329      openModal(openBtn.dataset.openContact || 'business');
1330      return;
1331    }
1332    const a = e.target.closest('a[href]');
1333    if (a && wantsContactModal(a.getAttribute('href'))) {
1334      e.preventDefault();
1335      openModal('business');
1336    }
1337  });
1338
1339  /* ─── Form submit (landing/form_submit) ──────────────────────────────── */
1340  const FORM_TYPE_BY_ID = {
1341    'modal-form': 'consultation',
1342    'business-partnership-form': 'business_cooperation',
1343    'media-inquiry-form': 'media_inquiry'
1344  };
1345
1346  const fieldValue = (form, name) => {
1347    const el = form.querySelector(`[name="${name}"]`);
1348    return el ? String(el.value || '').trim() : '';
1349  };
1350
1351  // Frontend validation mirroring the server rules:
1352  //   email  -> ^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
1353  //   phone  -> ^\d+$ (digits only, after stripping spaces/dashes)
1354  const EMAIL_PATTERN = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
1355  const isValidEmail = (value) => EMAIL_PATTERN.test(value);
1356  const isDigitsOnlyPhone = (value) => /^\d+$/.test(value);
1357
1358  // Returns the first field that fails validation (with a native message set),
1359  // or null when the form passes. Only checks fields the form actually has.
1360  const validateContactFields = (form) => {
1361    const emailEl = form.querySelector('[name="email"]');
1362    if (emailEl) {
1363      const value = String(emailEl.value || '').trim();
1364      emailEl.setCustomValidity('');
1365      if ((emailEl.required || value) && !isValidEmail(value)) {
1366        emailEl.setCustomValidity(T.emailInvalid);
1367        return emailEl;
1368      }
1369    }
1370    const phoneEl = form.querySelector('[name="phone"]');
1371    if (phoneEl) {
1372      const value = normalizePhone(phoneEl.value);
1373      phoneEl.setCustomValidity('');
1374      if ((phoneEl.required || value) && !isDigitsOnlyPhone(value)) {
1375        phoneEl.setCustomValidity(T.phoneDigits);
1376        return phoneEl;
1377      }
1378    }
1379    return null;
1380  };
1381
1382  const buildFormPayload = (form) => {
1383    const formType = FORM_TYPE_BY_ID[form.id] || 'consultation';
1384    const payload = { form_type: formType };
1385
1386    const name = fieldValue(form, 'name');
1387    const email = fieldValue(form, 'email');
1388    const phone = normalizePhone(fieldValue(form, 'phone'));
1389    const verificationCode = fieldValue(form, 'smsCode');
1390    const website = fieldValue(form, 'website');
1391    const intro = fieldValue(form, 'introduction');
1392    if (name) payload.name = name;
1393    if (email) payload.email = email;
1394    if (phone) payload.phone = phone;
1395    if (verificationCode) payload.verification_code = verificationCode;
1396    if (website) payload.website_or_linkedin = website;
1397    if (intro) payload.intention_details = intro;
1398
1399    if (formType === 'media_inquiry') return payload;
1400
1401    const role = fieldValue(form, 'role');
1402    const company = fieldValue(form, 'company');
1403    const purpose = fieldValue(form, 'purpose');
1404    const source = fieldValue(form, 'source');
1405    if (role) payload.job_title = role;
1406    if (company) payload.company_name = company;
1407    if (purpose) payload.intention = purpose;
1408    if (source) payload.source = source;
1409    payload.online_experience_requested = Boolean(form.querySelector('[name="onlineExperience"]')?.checked);
1410    return payload;
1411  };
1412
1413  const submitFormPayload = async (payload) => {
1414    if (!FORM_ENDPOINT) {
1415      // No endpoint configured (local dev / unbuilt source): keep demo behavior.
1416      await new Promise((resolve) => setTimeout(resolve, 400));
1417      return;
1418    }
1419    const response = await fetch(FORM_ENDPOINT, {
1420      method: 'POST',
1421      headers: apiRequestHeaders(FORM_ENDPOINT),
1422      body: JSON.stringify(payload)
1423    });
1424    await parseApiResponse(response, 'form_submit_failed');
1425  };
1426
1427  const isVerificationCodeError = (err) => Number(err?.code) === 4057;
1428  const formSubmitErrorMessage = (err) =>
1429    isVerificationCodeError(err)
1430      ? T.codeInvalid
1431      : T.submitError;
1432
1433  document.querySelectorAll('form[data-demo]').forEach((form) => {
1434    form.addEventListener('submit', async (e) => {
1435      e.preventDefault();
1436      const btn = form.querySelector('button[type="submit"]');
1437      if (!btn || btn.disabled) return;
1438
1439      let verifiedPhone = '';
1440      if (form.querySelector('[name="smsCode"]')) {
1441        const { phoneInput, smsCodeInput } = getSmsControls(form);
1442        const rawPhone = phoneInput?.value || '';
1443        const phone = normalizePhone(rawPhone);
1444        const code = (smsCodeInput?.value || '').trim();
1445
1446        if (!isValidPhone(rawPhone)) {
1447          setVerifyFeedback(form, T.phoneInvalid, true);
1448          phoneInput?.focus();
1449          return;
1450        }
1451        if (!/^\d{6}$/.test(code)) {
1452          setVerifyFeedback(form, T.codeRequired, true);
1453          smsCodeInput?.focus();
1454          return;
1455        }
1456        // Built deployments let the backend verify the submitted code. The
1457        // in-memory comparison is only for unbuilt local demo previews.
1458        if (!SMS_ENDPOINT && !verifySmsCode(phone, code)) {
1459          setVerifyFeedback(form, T.codeInvalid, true);
1460          smsCodeInput?.focus();
1461          return;
1462        }
1463        verifiedPhone = phone;
1464      }
1465
1466      // Enforce native constraints first (required fields, input types),
1467      // then the stricter server-aligned email/phone checks.
1468      if (!form.reportValidity()) return;
1469
1470      const invalidField = validateContactFields(form);
1471      if (invalidField) {
1472        invalidField.reportValidity?.();
1473        invalidField.focus();
1474        return;
1475      }
1476
1477      const original = btn.innerHTML;
1478      btn.disabled = true;
1479      btn.classList.remove('is-success', 'is-error');
1480      btn.innerHTML = T.submitting;
1481
1482      try {
1483        await submitFormPayload(buildFormPayload(form));
1484      } catch (err) {
1485        const businessError = Boolean(err?.businessError);
1486        const verificationCodeError = isVerificationCodeError(err);
1487        const errorText = formSubmitErrorMessage(err);
1488        btn.innerHTML = businessError ? original : errorText;
1489        btn.classList.add('is-error');
1490        if (form.querySelector('[name="smsCode"]')) {
1491          setVerifyFeedback(form, errorText, true);
1492          if (verificationCodeError) getSmsControls(form).smsCodeInput?.focus();
1493        }
1494        setTimeout(() => {
1495          btn.innerHTML = original;
1496          btn.classList.remove('is-error');
1497          btn.disabled = false;
1498        }, 2600);
1499        return;
1500      }
1501
1502      // Success: show the confirmation panel (demo UI) over the real API result.
1503      btn.innerHTML = original;
1504      showSubmitSuccess(form);
1505      btn.disabled = false;
1506      if (form.querySelector('[name="smsCode"]')) {
1507        clearSmsVerification(form);
1508        if (verifiedPhone) smsStore.delete(verifiedPhone);
1509      }
1510    });
1511
1512    // Clear any custom validity message once the user edits the field again.
1513    form.querySelectorAll('[name="email"], [name="phone"]').forEach((el) => {
1514      el.addEventListener('input', () => el.setCustomValidity(''));
1515    });
1516  });
1517
1518  // Open modal if user lands with #quick hash directly.
1519  if (window.location.hash === '#quick') {
1520    setTimeout(() => openModal('business'), 200);
1521    history.replaceState(null, '', window.location.pathname);
1522  }
1523
1524  const oneMapBody = document.querySelector('.one-project-page .one-project-map-body');
1525  if (oneMapBody) {
1526    const svg = oneMapBody.querySelector('.one-project-map-flow');
1527    const linesGroup = svg?.querySelector('.one-project-map-flow-lines');
1528    const dotsGroup = svg?.querySelector('.one-project-map-flow-dots');
1529    const hub = oneMapBody.querySelector('.one-project-map-hub');
1530    const leftCards = oneMapBody.querySelectorAll('.one-project-map-col:not(.is-right) .one-project-map-card');
1531    const rightCards = oneMapBody.querySelectorAll('.one-project-map-col.is-right .one-project-map-card');
1532
1533    const circlePoint = (cx, cy, r, tx, ty) => {
1534      const dx = tx - cx;
1535      const dy = ty - cy;
1536      const len = Math.hypot(dx, dy) || 1;
1537      return { x: cx + (dx / len) * r, y: cy + (dy / len) * r };
1538    };
1539
1540    const linkPath = (x1, y1, x2, y2, side) => {
1541      const mx = (x1 + x2) / 2;
1542      const my = (y1 + y2) / 2;
1543      const bend = side === 'left' ? 18 : -18;
1544      return `M ${x1} ${y1} Q ${mx + bend} ${my} ${x2} ${y2}`;
1545    };
1546
1547    const addDot = (x, y) => {
1548      const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1549      dot.setAttribute('cx', String(x));
1550      dot.setAttribute('cy', String(y));
1551      dot.setAttribute('r', '3.5');
1552      dotsGroup.appendChild(dot);
1553    };
1554
1555    const layoutOneProjectMapLinks = () => {
1556      if (!svg || !linesGroup || !dotsGroup || !hub) return;
1557
1558      const bodyRect = oneMapBody.getBoundingClientRect();
1559      if (bodyRect.width < 1 || bodyRect.height < 1) return;
1560
1561      svg.setAttribute('viewBox', `0 0 ${bodyRect.width} ${bodyRect.height}`);
1562      linesGroup.replaceChildren();
1563      dotsGroup.replaceChildren();
1564
1565      const hubRect = hub.getBoundingClientRect();
1566      const cx = hubRect.left + hubRect.width / 2 - bodyRect.left;
1567      const cy = hubRect.top + hubRect.height / 2 - bodyRect.top;
1568      const r = hubRect.width / 2;
1569
1570      const drawLinks = (cards, side) => {
1571        cards.forEach((card) => {
1572          const cardRect = card.getBoundingClientRect();
1573          const sx = side === 'left'
1574            ? cardRect.right - bodyRect.left
1575            : cardRect.left - bodyRect.left;
1576          const sy = cardRect.top + cardRect.height / 2 - bodyRect.top;
1577          const end = circlePoint(cx, cy, r, sx, sy);
1578          const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1579          path.setAttribute('d', linkPath(sx, sy, end.x, end.y, side));
1580          linesGroup.appendChild(path);
1581          addDot(sx, sy);
1582          addDot(end.x, end.y);
1583        });
1584      };
1585
1586      drawLinks(leftCards, 'left');
1587      drawLinks(rightCards, 'right');
1588    };
1589
1590    let linkFrame = 0;
1591    const scheduleOneProjectMapLinks = () => {
1592      cancelAnimationFrame(linkFrame);
1593      linkFrame = requestAnimationFrame(layoutOneProjectMapLinks);
1594    };
1595
1596    scheduleOneProjectMapLinks();
1597    window.addEventListener('resize', scheduleOneProjectMapLinks, { passive: true });
1598    window.addEventListener('load', scheduleOneProjectMapLinks);
1599    if (document.fonts?.ready) document.fonts.ready.then(scheduleOneProjectMapLinks);
1600
1601    const cover = oneMapBody.closest('.article-cover');
1602    cover?.addEventListener('transitionend', scheduleOneProjectMapLinks);
1603    if (cover && typeof MutationObserver !== 'undefined') {
1604      const coverObserver = new MutationObserver(scheduleOneProjectMapLinks);
1605      coverObserver.observe(cover, { attributes: true, attributeFilter: ['class'] });
1606    }
1607
1608    if (typeof ResizeObserver !== 'undefined') {
1609      const observer = new ResizeObserver(scheduleOneProjectMapLinks);
1610      observer.observe(oneMapBody);
1611      observer.observe(hub);
1612    }
1613  }
1614
1615  const driLayout = document.querySelector('.one-project-page .one-dri-layout');
1616  if (driLayout) {
1617    const svg = driLayout.querySelector('.one-dri-connectors');
1618    const linesGroup = svg?.querySelector('.one-dri-connectors-lines');
1619    const core = driLayout.querySelector('.one-dri-core');
1620    const skills = driLayout.querySelectorAll('.one-dri-skill');
1621
1622    const circlePoint = (cx, cy, r, tx, ty) => {
1623      const dx = tx - cx;
1624      const dy = ty - cy;
1625      const len = Math.hypot(dx, dy) || 1;
1626      return { x: cx + (dx / len) * r, y: cy + (dy / len) * r };
1627    };
1628
1629    const layoutDriLinks = () => {
1630      if (!svg || !linesGroup || !core || !skills.length) return;
1631
1632      const layoutRect = driLayout.getBoundingClientRect();
1633      if (layoutRect.width < 1 || layoutRect.height < 1) return;
1634
1635      svg.setAttribute('viewBox', `0 0 ${layoutRect.width} ${layoutRect.height}`);
1636      linesGroup.replaceChildren();
1637
1638      const coreRect = core.getBoundingClientRect();
1639      const ccx = coreRect.left + coreRect.width / 2 - layoutRect.left;
1640      const ccy = coreRect.top + coreRect.height / 2 - layoutRect.top;
1641      const cr = coreRect.width / 2;
1642
1643      skills.forEach((skill) => {
1644        const skillRect = skill.getBoundingClientRect();
1645        const scx = skillRect.left + skillRect.width / 2 - layoutRect.left;
1646        const scy = skillRect.top + skillRect.height / 2 - layoutRect.top;
1647        const sr = skillRect.width / 2;
1648        const start = circlePoint(scx, scy, sr, ccx, ccy);
1649        const end = circlePoint(ccx, ccy, cr, scx, scy);
1650        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1651        path.setAttribute('d', `M ${start.x} ${start.y} L ${end.x} ${end.y}`);
1652        linesGroup.appendChild(path);
1653      });
1654    };
1655
1656    let driFrame = 0;
1657    const scheduleDriLinks = () => {
1658      cancelAnimationFrame(driFrame);
1659      driFrame = requestAnimationFrame(layoutDriLinks);
1660    };
1661
1662    scheduleDriLinks();
1663    window.addEventListener('resize', scheduleDriLinks, { passive: true });
1664    window.addEventListener('load', scheduleDriLinks);
1665    if (document.fonts?.ready) document.fonts.ready.then(scheduleDriLinks);
1666
1667    const driFigure = driLayout.closest('.one-method-figure');
1668    driFigure?.addEventListener('transitionend', scheduleDriLinks);
1669    if (driFigure && typeof MutationObserver !== 'undefined') {
1670      const driObserver = new MutationObserver(scheduleDriLinks);
1671      driObserver.observe(driFigure, { attributes: true, attributeFilter: ['class'] });
1672    }
1673
1674    if (typeof ResizeObserver !== 'undefined') {
1675      const driResizeObserver = new ResizeObserver(scheduleDriLinks);
1676      driResizeObserver.observe(driLayout);
1677      driResizeObserver.observe(core);
1678      skills.forEach((skill) => driResizeObserver.observe(skill));
1679    }
1680  }
1681
1682  document.querySelectorAll('[data-one-case-carousel]').forEach((carousel) => {
1683    const slides = Array.from(carousel.querySelectorAll('[data-one-case-slide]'));
1684    const dots = Array.from(carousel.querySelectorAll('[data-one-case-dot]'));
1685    const prev = carousel.querySelector('[data-one-case-prev]');
1686    const next = carousel.querySelector('[data-one-case-next]');
1687    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
1688    let activeIndex = Math.max(0, slides.findIndex((slide) => slide.classList.contains('is-active')));
1689    let timer = 0;
1690
1691    if (slides.length < 2) return;
1692
1693    const showSlide = (index) => {
1694      activeIndex = (index + slides.length) % slides.length;
1695      slides.forEach((slide, slideIndex) => {
1696        const isActive = slideIndex === activeIndex;
1697        slide.classList.toggle('is-active', isActive);
1698        slide.setAttribute('aria-hidden', isActive ? 'false' : 'true');
1699      });
1700      dots.forEach((dot, dotIndex) => {
1701        dot.classList.toggle('is-active', dotIndex === activeIndex);
1702      });
1703    };
1704
1705    const stop = () => {
1706      if (timer) window.clearInterval(timer);
1707      timer = 0;
1708    };
1709
1710    const start = () => {
1711      stop();
1712      if (!reduceMotion.matches) {
1713        timer = window.setInterval(() => showSlide(activeIndex + 1), 4200);
1714      }
1715    };
1716
1717    prev?.addEventListener('click', () => {
1718      showSlide(activeIndex - 1);
1719      start();
1720    });
1721
1722    next?.addEventListener('click', () => {
1723      showSlide(activeIndex + 1);
1724      start();
1725    });
1726
1727    dots.forEach((dot, dotIndex) => {
1728      dot.addEventListener('click', () => {
1729        showSlide(dotIndex);
1730        start();
1731      });
1732    });
1733
1734    carousel.addEventListener('mouseenter', stop);
1735    carousel.addEventListener('mouseleave', start);
1736    carousel.addEventListener('focusin', stop);
1737    carousel.addEventListener('focusout', () => {
1738      if (!carousel.contains(document.activeElement)) start();
1739    });
1740    document.addEventListener('visibilitychange', () => {
1741      if (document.hidden) stop();
1742      else start();
1743    });
1744    reduceMotion.addEventListener?.('change', start);
1745
1746    showSlide(activeIndex);
1747    start();
1748  });
1749})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.