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.