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 = () => isEN ? '' : ` 741 <div class="form-field form-field-sms-code"> 742 <label>${T.smsCode}</label> 743 <div class="phone-code-input-wrap"> 744 <input 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 <input type="hidden" name="topic"${topicId ? ` id="${topicId}"` : ''} value="${topicValue}"> 751 <div class="form-field"> 752 <label>${businessPartnership.name}</label> 753 <input type="text" name="name" placeholder="${businessPartnership.namePh}" autocomplete="name" required> 754 </div> 755 <div class="form-field"> 756 <label>${businessPartnership.role}</label> 757 <input type="text" name="role" placeholder="${businessPartnership.rolePh}" autocomplete="organization-title" required> 758 </div> 759 <div class="form-field"> 760 <label>${businessPartnership.email}</label> 761 <input type="email" name="email" placeholder="${businessPartnership.emailPh}" autocomplete="email" required> 762 </div> 763 <div class="form-field"> 764 <label>${businessPartnership.phone}</label> 765 <input type="tel" name="phone" placeholder="${businessPartnership.phonePh}" autocomplete="tel" ${phoneInputConstraints} required> 766 </div> 767 ${smsVerificationField()} 768 <div class="form-field"> 769 <label>${businessPartnership.company}</label> 770 <input type="text" name="company" placeholder="${businessPartnership.companyPh}" autocomplete="organization" required> 771 </div> 772 <div class="form-field"> 773 <label>${businessPartnership.website}</label> 774 <input type="text" name="website" placeholder="${businessPartnership.websitePh}" autocomplete="url" required> 775 </div> 776 <div class="form-field"> 777 <label>${businessPartnership.purpose}</label> 778 <select name="purpose" required> 779 <option value="" selected disabled>${businessPartnership.selectPlaceholder}</option> 780 ${optionTags(businessPartnership.purposeOptions)} 781 </select> 782 </div> 783 <div class="form-field"> 784 <label>${businessPartnership.source}</label> 785 <select name="source" required> 786 <option value="" selected disabled>${businessPartnership.selectPlaceholder}</option> 787 ${optionTags(businessPartnership.sourceOptions)} 788 </select> 789 </div> 790 <div class="form-field full"> 791 <label>${businessPartnership.intro}</label> 792 <textarea name="introduction" placeholder="${businessPartnership.introPh}" rows="4" required></textarea> 793 </div> 794 <label class="modal-checkbox-field"> 795 <input type="checkbox" name="onlineExperience" value="yes">
796 <span>${businessPartnership.onlineCheck}</span> 797 </label>`; 798 const modal = document.createElement('div'); 799 modal.className = 'modal modal-consult'; 800 modal.setAttribute('aria-hidden', 'true'); 801 modal.innerHTML = ` 802 <div class="modal-backdrop" data-close></div> 803 <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="modal-title"> 804 <button class="modal-close" type="button" data-close aria-label="${T.close}"> 805 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 806 <path d="M6 6l12 12M6 18L18 6"/> 807 </svg> 808 </button> 809 <section class="modal-promise"> 810 <div class="modal-kicker">${T.kicker}</div> 811 <h3 id="modal-pitch-title">${T.pitchTitle}</h3> 812 <p class="modal-pitch-text" id="modal-pitch-text">${T.pitchText}</p> 813 <ul class="modal-benefits"> 814 ${T.pitchPoints.map((point) => `<li>${point}</li>`).join('')} 815 </ul> 816 <p class="modal-trust">${T.trust}</p> 817 </section> 818 <section class="modal-form-side"> 819 <div class="modal-head"> 820 <h2 id="modal-title">${T.title}</h2> 821 <p id="modal-sub">${T.sub}</p> 822 </div> 823 <form class="modal-form" id="modal-form" data-demo> 824 ${businessPartnershipFields('business', 'modal-topic')} 825 <div class="modal-actions full"> 826 <button type="submit" class="btn btn-primary"> 827 ${T.submit} 828 </button> 829 </div> 830 </form> 831 </section> 832 </div>`; 833 document.body.appendChild(modal); 834 835 // Build-time placeholder replaced by scripts/build-deploy.sh (FORM_API_BASE). 836 // Can also be overridden at runtime via window.OAI_FORM_API_BASE. 837 const FORM_API_BASE = String(window.OAI_FORM_API_BASE || 'https://www.wanzhi.com/app').replace(/\/+$/, ''); 838 // Split so the build-time sed replacement never rewrites this guard token. 839 const FORM_API_PLACEHOLDER = '__FORM' + '_API_BASE__'; 840 const HAS_FORM_API = Boolean(FORM_API_BASE && FORM_API_BASE !== FORM_API_PLACEHOLDER); 841 const FORM_ENDPOINT = HAS_FORM_API 842 ? `${FORM_API_BASE}/api/v1/landing/form_submit` 843 : null; 844 const SMS_ENDPOINT = window.OAI_SMS_ENDPOINT || (HAS_FORM_API 845 ? `${FORM_API_BASE}/api/v1/landing/send_verification_code` 846 : null); 847 const apiRequestHeaders = (endpoint) => { 848 const headers = { 'Content-Type': 'application/json' }; 849 try { 850 if (new URL(endpoint, window.location.href).hostname === 'test-app.lingyiwanwu.net') { 851 headers['X-Target-Service-appdevhub'] = 'boe-landing'; 852 } 853 } catch { 854 // A malformed runtime override will fail naturally when fetch is called. 855 } 856 return headers; 857 }; 858 const parseApiResponse = async (response, errorPrefix, options = {}) => { 859 let data = null; 860 try { 861 data = await response.json(); 862 } catch { 863 // Some successful gateway responses have no JSON body. 864 } 865 866 const hasBusinessCode = data && Object.prototype.hasOwnProperty.call(data, 'code'); 867 const businessCode = hasBusinessCode ? Number(data.code) : null; 868 const businessFailed = hasBusinessCode && businessCode !== 200; 869 const missingBusinessCode = options.requireBusinessCode && !hasBusinessCode; 870 if (!response.ok || businessFailed || missingBusinessCode) { 871 const err = new Error(data?.message || `${errorPrefix}_${response.status}`); 872 err.status = response.status; 873 err.code = hasBusinessCode ? data.code : null; 874 err.apiMessage = data?.message || ''; 875 err.businessError = businessFailed || missingBusinessCode; 876 throw err; 877 } 878 return data; 879 }; 880 881 const SMS_CODE_TTL = 5 * 60 * 1000; 882 const SMS_RESEND_SECONDS = 60; 883 const smsStore = new Map(); 884 const smsTimers = new WeakMap(); 885 886 const normalizePhone = (value) => String(value || '').replace(/\s|-/g, ''); 887 const isValidPhone = (phone) => (isEN 888 ? /^\+?\d{7,15}$/.test(normalizePhone(phone)) 889 : /^1[3-9]\d{9}$/.test(String(phone || '').trim())); 890 891 const getSmsControls = (form) => ({ 892 phoneInput: form?.querySelector('[name="phone"]'), 893 smsCodeInput: form?.querySelector('[name="smsCode"]'), 894 sendCodeBtn: form?.querySelector('.phone-verify-send'), 895 verifyFeedback: form?.querySelector('.phone-verify-feedback') 896 }); 897 898 const setVerifyFeedback = (form, message, isError = false) => { 899 const { verifyFeedback }
899 = getSmsControls(form); 900 if (!verifyFeedback) return; 901 verifyFeedback.textContent = message || ''; 902 verifyFeedback.hidden = !message; 903 verifyFeedback.classList.toggle('is-error', Boolean(message && isError)); 904 verifyFeedback.classList.toggle('is-success', Boolean(message && !isError)); 905 }; 906 907 const clearSendCodeTimer = (form) => { 908 if (!form) return; 909 const state = smsTimers.get(form); 910 if (state?.timer) window.clearInterval(state.timer); 911 const { sendCodeBtn } = getSmsControls(form); 912 if (sendCodeBtn) { 913 sendCodeBtn.disabled = false; 914 sendCodeBtn.textContent = T.sendCode; 915 } 916 smsTimers.set(form, { timer: null }); 917 }; 918 919 const clearSmsVerification = (form) => { 920 clearSendCodeTimer(form); 921 setVerifyFeedback(form, ''); 922 }; 923 924 const startSendCodeCountdown = (form) => { 925 const { sendCodeBtn } = getSmsControls(form); 926 if (!sendCodeBtn) return; 927 let sendCodeRemaining = SMS_RESEND_SECONDS; 928 sendCodeBtn.disabled = true; 929 sendCodeBtn.textContent = isEN 930 ? `${T.sendCodeWait} ${sendCodeRemaining}s` 931 : `${sendCodeRemaining}${T.sendCodeWait}`; 932 const timer = window.setInterval(() => { 933 sendCodeRemaining -= 1; 934 if (sendCodeRemaining <= 0) { 935 clearSendCodeTimer(form); 936 return; 937 } 938 sendCodeBtn.textContent = isEN 939 ? `${T.sendCodeWait} ${sendCodeRemaining}s` 940 : `${sendCodeRemaining}${T.sendCodeWait}`; 941 }, 1000); 942 smsTimers.set(form, { timer }); 943 }; 944 945 const storeSmsCode = (phone, code) => { 946 smsStore.set(phone, { code, expiresAt: Date.now() + SMS_CODE_TTL }); 947 }; 948 949 const verifySmsCode = (phone, code) => { 950 const entry = smsStore.get(phone); 951 if (!entry || Date.now() > entry.expiresAt) return false; 952 return entry.code === code; 953 }; 954 955 const requestSmsCode = async (phone) => { 956 if (SMS_ENDPOINT) { 957 const response = await fetch(SMS_ENDPOINT, { 958 method: 'POST', 959 headers: apiRequestHeaders(SMS_ENDPOINT), 960 body: JSON.stringify({ phone }) 961 }); 962 await parseApiResponse(response, 'send_failed', { requireBusinessCode: true }); 963 return; 964 } 965 966 const code = String(Math.floor(100000 + Math.random() * 900000)); 967 storeSmsCode(phone, code); 968 if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { 969 console.info('[01.AI demo] SMS verification code:', code); 970 } 971 }; 972 973 document.addEventListener('click', async (e) => { 974 const sendCodeBtn = e.target.closest('.phone-verify-send'); 975 if (!sendCodeBtn) return; 976 const form = sendCodeBtn.closest('form[data-demo]'); 977 const { phoneInput } = getSmsControls(form); 978 const rawPhone = phoneInput?.value || ''; 979 const phone = normalizePhone(rawPhone); 980 if (!isValidPhone(rawPhone)) { 981 setVerifyFeedback(form, T.phoneInvalid, true); 982 phoneInput?.focus(); 983 return; 984 } 985 986 setVerifyFeedback(form, ''); 987 sendCodeBtn.disabled = true; 988 try { 989 await requestSmsCode(phone); 990 setVerifyFeedback(form, T.codeSent, false); 991 startSendCodeCountdown(form); 992 } catch (err) { 993 const rateLimited = /too many requests/i.test(err.apiMessage || ''); 994 if (rateLimited) { 995 setVerifyFeedback(form, T.sendCodeRateLimit, true); 996 startSendCodeCountdown(form); 997 } else { 998 clearSendCodeTimer(form); 999 setVerifyFeedback(form, T.sendCodeFail, true); 1000 } 1001 } 1002 }); 1003 1004 document.addEventListener('input', (e) => { 1005 const form = e.target.closest('form[data-demo]'); 1006 if (!form || !form.querySelector('[name="smsCode"]')) return; 1007 1008 if (e.target.name === 'phone') { 1009 if (!isEN) e.target.value = e.target.value.replace(/\D/g, '').slice(0, 11); 1010 const phone = normalizePhone(e.target.value); 1011 if (smsStore.has(phone)) return; 1012 setVerifyFeedback(form, ''); 1013 } 1014 if (e.target.name === 'smsCode') { 1015 const { verifyFeedback } = getSmsControls(form); 1016 if (verifyFeedback?.classList.contains('is-error')) setVerifyFeedback(form, ''); 1017 } 1018 }); 1019
1020 const modalTopic = modal.querySelector('#modal-topic'); 1021 const modalTitle = modal.querySelector('#modal-title'); 1022 const modalSub = modal.querySelector('#modal-sub'); 1023 const modalKicker = modal.querySelector('.modal-kicker'); 1024 const modalPitchTitle = modal.querySelector('#modal-pitch-title'); 1025 const modalPitchText = modal.querySelector('#modal-pitch-text'); 1026 const modalBenefits = modal.querySelector('.modal-benefits'); 1027 const modalTrust = modal.querySelector('.modal-trust'); 1028 const modalIntro = modal.querySelector('[name="introduction"]'); 1029 const VALID_TOPICS = ['business', 'investor', 'license', 'media', 'careers']; 1030 const topicContent = { 1031 business: { 1032 title: T.title, 1033 sub: T.sub, 1034 kicker: T.kicker, 1035 pitchTitle: T.pitchTitle, 1036 pitchText: T.pitchText, 1037 pitchPoints: T.pitchPoints, 1038 trust: T.trust, 1039 message: T.message, 1040 messagePh: T.messagePh 1041 }, 1042 investor: { 1043 title: T.investorTitle, 1044 sub: T.investorSub, 1045 kicker: T.investorKicker, 1046 pitchTitle: T.investorPitchTitle, 1047 pitchText: T.investorPitchText, 1048 pitchPoints: T.investorPitchPoints, 1049 trust: T.investorTrust, 1050 message: T.investorMessage, 1051 messagePh: T.investorMessagePh 1052 }, 1053 media: { 1054 title: T.mediaTitle, 1055 sub: T.mediaSub, 1056 kicker: T.mediaKicker, 1057 pitchTitle: T.mediaPitchTitle, 1058 pitchText: T.mediaPitchText, 1059 pitchPoints: T.mediaPitchPoints, 1060 trust: T.mediaTrust, 1061 message: T.mediaMessage, 1062 messagePh: T.mediaMessagePh 1063 } 1064 }; 1065 1066 const resetSubmitSuccess = (scope) => { 1067 scope.classList.remove('is-submit-success'); 1068 scope.querySelectorAll('.modal-submit-success').forEach((panel) => { 1069 panel.hidden = true; 1070 }); 1071 scope.querySelectorAll('form[data-demo]').forEach((form) => { 1072 form.hidden = false; 1073 const btn = form.querySelector('button[type="submit"]'); 1074 if (btn) { 1075 btn.disabled = false; 1076 btn.classList.remove('is-success'); 1077 } 1078 }); 1079 }; 1080 1081 const showSubmitSuccess = (form) => { 1082 const modalScope = form.closest('.modal'); 1083 const formSide = form.closest('.modal-form-side') || form.parentElement; 1084 let panel = formSide?.querySelector('.modal-submit-success'); 1085 if (!panel) { 1086 panel = document.createElement('section'); 1087 panel.className = 'modal-submit-success'; 1088 panel.hidden = true; 1089 panel.setAttribute('role', 'status'); 1090 panel.setAttribute('tabindex', '-1'); 1091 panel.innerHTML = ` 1092 <div class="modal-submit-check" aria-hidden="true"> 1093 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> 1094 <path d="M20 6L9 17l-5-5"/> 1095 </svg> 1096 </div> 1097 <h3>${T.successTitle}</h3> 1098 <p>${T.successBody}</p> 1099 <button type="button" class="btn btn-primary modal-submit-close" data-close>${T.close}</button>`; 1100 form.insertAdjacentElement('afterend', panel); 1101 } 1102 form.reset(); 1103 form.hidden = true; 1104 modalScope?.classList.add('is-submit-success'); 1105 panel.hidden = false; 1106 window.setTimeout(() => panel.focus({ preventScroll: true }), 30); 1107 }; 1108 1109 const openModal = (topic) => { 1110 const nextTopic = topic && VALID_TOPICS.includes(topic) ? topic : 'business'; 1111 const content = topicContent[nextTopic] || topicContent.business; 1112 resetSubmitSuccess(modal); 1113 modalTopic.value = nextTopic; 1114 modalTitle.textContent = content.title; 1115 modalSub.textContent = content.sub; 1116 if (modalKicker) modalKicker.textContent = content.kicker; 1117 if (modalPitchTitle) modalPitchTitle.textContent = content.pitchTitle; 1118 modalPitchText.textContent = content.pitchText; 1119 if (modalBenefits) modalBenefits.innerHTML = content.pitchPoints.map((point) => `<li>${point}</li>`).join(''); 1120 if (modalTrust) modalTrust.textContent = content.trust; 1121 modal.classList.add('open'); 1122 modal.setAttribute('aria-hidden', 'false'); 1123 document.body.style.overflow = 'hidden'; 1124 setTimeout(() => { 1125 modal.querySelector('[name="name"]')?.focus(); 1126 }, 60); 1127 }; 1128 const closeModal = () => { 1129 modal.classList.remove('open'); 1130 modal.setAttribute('aria-hidden', 'true'); 1131 document.body.style.overflow = ''; 1132 resetSubmitSuccess(modal); 1133 clearSmsVerification(modal.querySelector('form[data-demo]')); 1134 }; 1135 1136 const businessModal = document.createElement('div'); 1137 businessModal.className = 'modal modal-consult modal-business-partnership'; 1138 businessModal.setAttribute('aria-hidden', 'true'); 1139 businessModal.innerHTML = ` 1140 <div class="modal-backdrop" data-close></div> 1141 <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="business-modal-title"> 1142 <button class="modal-close" type="button" data-close aria-label="${T.close}"> 1143 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 1144 <path d="M6 6l12 12M6 18L18 6"/> 1145 </svg> 1146 </button> 1147 <section class="modal-promise"> 1148 <div class="modal-kicker">${businessPartnership.kicker}</div> 1149 <h3>${businessPartnership.pitchTitle}</h3> 1150 <p class="modal-pitch-text">${businessPartnership.pitchText}</p> 1151 <ul class="modal-benefits">
1152 ${businessPartnership.pitchPoints.map((point) => `<li>${point}</li>`).join('')} 1153 </ul> 1154 <p class="modal-trust">${businessPartnership.trust}</p> 1155 </section> 1156 <section class="modal-form-side"> 1157 <div class="modal-head"> 1158 <h2 id="business-modal-title">${businessPartnership.title}</h2> 1159 <p>${businessPartnership.sub}</p> 1160 </div> 1161 <form class="modal-form business-partnership-form" id="business-partnership-form" data-demo> 1162 ${businessPartnershipFields('business-partnership')} 1163 <div class="modal-actions full"> 1164 <button type="submit" class="btn btn-primary">${T.submit}</button> 1165 </div> 1166 </form> 1167 </section> 1168 </div>`; 1169 document.body.appendChild(businessModal); 1170 1171 const mediaInquiry = T.mediaInquiry; 1172 const mediaModal = document.createElement('div'); 1173 mediaModal.className = 'modal modal-consult modal-media-inquiry'; 1174 mediaModal.setAttribute('aria-hidden', 'true'); 1175 mediaModal.innerHTML = ` 1176 <div class="modal-backdrop" data-close></div> 1177 <div class="modal-dialog modal-demo" role="dialog" aria-modal="true" aria-labelledby="media-modal-title"> 1178 <button class="modal-close" type="button" data-close aria-label="${T.close}"> 1179 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 1180 <path d="M6 6l12 12M6 18L18 6"/> 1181 </svg> 1182 </button> 1183 <section class="modal-promise"> 1184 <div class="modal-kicker">${mediaInquiry.kicker}</div> 1185 <h3>${mediaInquiry.pitchTitle}</h3> 1186 <p class="modal-pitch-text">${mediaInquiry.pitchText}</p> 1187 <p class="modal-trust">${mediaInquiry.trust}</p> 1188 </section> 1189 <section class="modal-form-side"> 1190 <div class="modal-head"> 1191 <h2 id="media-modal-title">${mediaInquiry.title}</h2> 1192 <p>${mediaInquiry.sub}</p> 1193 </div> 1194 <form class="modal-form media-inquiry-form" id="media-inquiry-form" data-demo> 1195 <input type="hidden" name="topic" value="media-inquiry"> 1196 <div class="form-field"> 1197 <label>${mediaInquiry.name}</label> 1198 <input type="text" name="name" placeholder="${mediaInquiry.namePh}" autocomplete="name" required> 1199 </div> 1200 <div class="form-field"> 1201 <label>${mediaInquiry.website}</label> 1202 <input type="text" name="website" placeholder="${mediaInquiry.websitePh}" autocomplete="url" required> 1203 </div> 1204 <div class="form-field"> 1205 <label>${mediaInquiry.phone}</label> 1206 <input type="tel" name="phone" placeholder="${mediaInquiry.phonePh}" autocomplete="tel" ${phoneInputConstraints} required> 1207 </div> 1208 ${smsVerificationField()} 1209 <div class="form-field"> 1210 <label>${mediaInquiry.email}</label> 1211 <input type="email" name="email" placeholder="${mediaInquiry.emailPh}" autocomplete="email" required> 1212 </div> 1213 <div class="form-field full"> 1214 <label>${mediaInquiry.intro}</label> 1215 <textarea name="introduction" placeholder="${mediaInquiry.introPh}" rows="5" required></textarea> 1216 </div> 1217 <div class="modal-actions full"> 1218 <button type="submit" class="btn btn-primary">${T.submit}</button> 1219 </div> 1220 </form> 1221 </section> 1222 </div>`; 1223 document.body.appendChild(mediaModal); 1224 1225 const closeBusinessPartnershipModal = () => { 1226 businessModal.classList.remove('open'); 1227 businessModal.setAttribute('aria-hidden', 'true'); 1228 document.body.style.overflow = ''; 1229 resetSubmitSuccess(businessModal); 1230 clearSmsVerification(businessModal.querySelector('form[data-demo]')); 1231 }; 1232 const closeMediaInquiryModal = () => { 1233 mediaModal.classList.remove('open'); 1234 mediaModal.setAttribute('aria-hidden', 'true'); 1235 document.body.style.overflow = ''; 1236 resetSubmitSuccess(mediaModal); 1237 clearSmsVerification(mediaModal.querySelector('form[data-demo]')); 1238 }; 1239 const openBusinessPartnershipModal = () => { 1240 closeModal(); 1241 closeMediaInquiryModal(); 1242 resetSubmitSuccess(businessModal); 1243 businessModal.classList.add('open'); 1244 businessModal.setAttribute('aria-hidden', 'false'); 1245 document.body.style.overflow = 'hidden'; 1246 setTimeout(() => { 1247 businessModal.querySelector('[name="name"]')?.focus(); 1248 }, 60); 1249 }; 1250 const openMediaInquiryModal = () => { 1251 closeModal(); 1252 closeBusinessPartnershipModal(); 1253 resetSubmitSuccess(mediaModal); 1254 mediaModal.classList.add('open'); 1255 mediaModal.setAttribute('aria-hidden', 'false'); 1256 document.body.style.overflow = 'hidden'; 1257 setTimeout(() => { 1258 mediaModal.querySelector('[name="name"]')?.focus(); 1259 }, 60); 1260 }; 1261 1262 modal.addEventListener('click', (e) => { 1263 const quickPrompt = e.target.closest('[data-ai-prompt]'); 1264 if (quickPrompt) { 1265 const prompt = quickPrompt.dataset.aiPrompt || quickPrompt.textContent.trim(); 1266 if (modalIntro) { 1267 modalIntro.value = modalIntro.value ? `${modalIntro.value}\n${prompt}` : prompt; 1268 modalIntro.focus(); 1269 } 1270 return; 1271 } 1272 if (e.target.closest('[data-close]')) closeModal(); 1273 }); 1274 businessModal.addEventListener('click', (e) => { 1275 if (e.target.closest('[data-close]')) closeBusinessPartnershipModal(); 1276 }); 1277 mediaModal.addEventListener('click', (e) => { 1278 if (e.target.closest('[data-close]')) closeMediaInquiryModal(); 1279 }); 1280 document.addEventListener('keydown', (e) => { 1281 if (e.key === 'Escape' && modal.classList.contains('open')) closeModal(); 1282 if (e.key === 'Escape' && businessModal.classList.contains('open')) closeBusinessPartnershipModal(); 1283 if (e.key === 'Escape' && mediaModal.classList.contains('open')) closeMediaInquiryModal(); 1284 }); 1285 1286 /* Bind triggers: 1287 - [data-quick="<topic>
1287"] buttons (on contact cards) 1288 - [data-open-contact] explicit triggers 1289 - Any <a> pointing at contact.html#quick, ../contact.html#quick, or #quick on the contact page */ 1290 const wantsContactModal = (href) => { 1291 if (!href) return false; 1292 // Strip fragment normalization 1293 const h = href.trim(); 1294 return /(?:^|\/)contact\.html#quick$/.test(h) || h === '#quick'; 1295 }; 1296 document.addEventListener('click', (e) => { 1297 const businessPartnershipBtn = e.target.closest('[data-business-partnership]'); 1298 if (businessPartnershipBtn) { 1299 e.preventDefault(); 1300 openBusinessPartnershipModal(); 1301 return; 1302 } 1303 const mediaInquiryBtn = e.target.closest('[data-media-inquiry]'); 1304 if (mediaInquiryBtn) { 1305 e.preventDefault(); 1306 openMediaInquiryModal(); 1307 return; 1308 } 1309 const quickBtn = e.target.closest('[data-quick]'); 1310 if (quickBtn) { 1311 e.preventDefault(); 1312 if (quickBtn.dataset.quick === 'investor') { 1313 openBusinessPartnershipModal(); 1314 return; 1315 } 1316 if (quickBtn.dataset.quick === 'media') { 1317 openMediaInquiryModal(); 1318 return; 1319 } 1320 openModal(quickBtn.dataset.quick); 1321 return; 1322 } 1323 const openBtn = e.target.closest('[data-open-contact]'); 1324 if (openBtn) { 1325 e.preventDefault(); 1326 openModal(openBtn.dataset.openContact || 'business'); 1327 return; 1328 } 1329 const a = e.target.closest('a[href]'); 1330 if (a && wantsContactModal(a.getAttribute('href'))) { 1331 e.preventDefault(); 1332 openModal('business'); 1333 } 1334 }); 1335 1336 /* âââ Form submit (landing/form_submit) ââââââââââââââââââââââââââââââââ */ 1337 const FORM_TYPE_BY_ID = { 1338 'modal-form': 'consultation', 1339 'business-partnership-form': 'business_cooperation', 1340 'media-inquiry-form': 'media_inquiry' 1341 }; 1342 1343 const fieldValue = (form, name) => { 1344 const el = form.querySelector(`[name="${name}"]`); 1345 return el ? String(el.value || '').trim() : ''; 1346 }; 1347 1348 // Frontend validation mirroring the server rules: 1349 // email -> ^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$ 1350 // phone -> ^\d+$ (digits only, after stripping spaces/dashes) 1351 const EMAIL_PATTERN = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; 1352 const isValidEmail = (value) => EMAIL_PATTERN.test(value); 1353 const isDigitsOnlyPhone = (value) => /^\d+$/.test(value); 1354 1355 // Returns the first field that fails validation (with a native message set), 1356 // or null when the form passes. Only checks fields the form actually has. 1357 const validateContactFields = (form) => { 1358 const emailEl = form.querySelector('[name="email"]'); 1359 if (emailEl) { 1360 const value = String(emailEl.value || '').trim(); 1361 emailEl.setCustomValidity(''); 1362 if ((emailEl.required || value) && !isValidEmail(value)) { 1363 emailEl.setCustomValidity(T.emailInvalid); 1364 return emailEl; 1365 } 1366 } 1367 const phoneEl = form.querySelector('[name="phone"]'); 1368 if (phoneEl) { 1369 const value = normalizePhone(phoneEl.value); 1370 phoneEl.setCustomValidity(''); 1371 if ((phoneEl.required || value) && !isDigitsOnlyPhone(value)) { 1372 phoneEl.setCustomValidity(T.phoneDigits); 1373 return phoneEl; 1374 } 1375 } 1376 return null; 1377 }; 1378 1379 const buildFormPayload = (form) => { 1380 const formType = FORM_TYPE_BY_ID[form.id] || 'consultation'; 1381 const payload = { form_type: formType }; 1382 1383 const name = fieldValue(form, 'name'); 1384 const email = fieldValue(form, 'email'); 1385 const phone = normalizePhone(fieldValue(form, 'phone')); 1386 const verificationCode = fieldValue(form, 'smsCode'); 1387 const website = fieldValue(form, 'website'); 1388 const intro = fieldValue(form, 'introduction'); 1389 if (name) payload.name = name; 1390 if (email) payload.email = email; 1391 if (phone) payload.phone = phone; 1392 if (verificationCode) payload.verification_code = verificationCode; 1393 if (website) payload.website_or_linkedin = website; 1394 if (intro) payload.intention_details = intro; 1395 1396 if (formType === 'media_inquiry') return payload; 1397 1398 const role = fieldValue(form, 'role'); 1399 const company = fieldValue(form, 'company');
1400 const purpose = fieldValue(form, 'purpose'); 1401 const source = fieldValue(form, 'source'); 1402 if (role) payload.job_title = role; 1403 if (company) payload.company_name = company; 1404 if (purpose) payload.intention = purpose; 1405 if (source) payload.source = source; 1406 payload.online_experience_requested = Boolean(form.querySelector('[name="onlineExperience"]')?.checked); 1407 return payload; 1408 }; 1409 1410 const submitFormPayload = async (payload) => { 1411 if (!FORM_ENDPOINT) { 1412 // No endpoint configured (local dev / unbuilt source): keep demo behavior. 1413 await new Promise((resolve) => setTimeout(resolve, 400)); 1414 return; 1415 } 1416 const response = await fetch(FORM_ENDPOINT, { 1417 method: 'POST', 1418 headers: apiRequestHeaders(FORM_ENDPOINT), 1419 body: JSON.stringify(payload) 1420 }); 1421 await parseApiResponse(response, 'form_submit_failed'); 1422 }; 1423 1424 const isVerificationCodeError = (err) => Number(err?.code) === 4057; 1425 const formSubmitErrorMessage = (err) => 1426 isVerificationCodeError(err) 1427 ? T.codeInvalid 1428 : T.submitError; 1429 1430 document.querySelectorAll('form[data-demo]').forEach((form) => { 1431 form.addEventListener('submit', async (e) => { 1432 e.preventDefault(); 1433 const btn = form.querySelector('button[type="submit"]'); 1434 if (!btn || btn.disabled) return; 1435 1436 let verifiedPhone = ''; 1437 if (form.querySelector('[name="smsCode"]')) { 1438 const { phoneInput, smsCodeInput } = getSmsControls(form); 1439 const rawPhone = phoneInput?.value || ''; 1440 const phone = normalizePhone(rawPhone); 1441 const code = (smsCodeInput?.value || '').trim(); 1442 1443 if (!isValidPhone(rawPhone)) { 1444 setVerifyFeedback(form, T.phoneInvalid, true); 1445 phoneInput?.focus(); 1446 return; 1447 } 1448 if (!/^\d{6}$/.test(code)) { 1449 setVerifyFeedback(form, T.codeRequired, true); 1450 smsCodeInput?.focus(); 1451 return; 1452 } 1453 // Built deployments let the backend verify the submitted code. The 1454 // in-memory comparison is only for unbuilt local demo previews. 1455 if (!SMS_ENDPOINT && !verifySmsCode(phone, code)) { 1456 setVerifyFeedback(form, T.codeInvalid, true); 1457 smsCodeInput?.focus(); 1458 return; 1459 } 1460 verifiedPhone = phone; 1461 } 1462 1463 // Enforce native constraints first (required fields, input types), 1464 // then the stricter server-aligned email/phone checks. 1465 if (!form.reportValidity()) return; 1466 1467 const invalidField = validateContactFields(form); 1468 if (invalidField) { 1469 invalidField.reportValidity?.(); 1470 invalidField.focus(); 1471 return; 1472 } 1473 1474 const original = btn.innerHTML; 1475 btn.disabled = true; 1476 btn.classList.remove('is-success', 'is-error'); 1477 btn.innerHTML = T.submitting; 1478 1479 try { 1480 await submitFormPayload(buildFormPayload(form)); 1481 } catch (err) { 1482 const businessError = Boolean(err?.businessError); 1483 const verificationCodeError = isVerificationCodeError(err); 1484 const errorText = formSubmitErrorMessage(err); 1485 btn.innerHTML = businessError ? original : errorText; 1486 btn.classList.add('is-error'); 1487 if (form.querySelector('[name="smsCode"]')) { 1488 setVerifyFeedback(form, errorText, true); 1489 if (verificationCodeError) getSmsControls(form).smsCodeInput?.focus(); 1490 } 1491 setTimeout(() => { 1492 btn.innerHTML = original; 1493 btn.classList.remove('is-error'); 1494 btn.disabled = false; 1495 }, 2600); 1496 return; 1497 } 1498 1499 // Success: show the confirmation panel (demo UI) over the real API result. 1500 btn.innerHTML = original; 1501 showSubmitSuccess(form); 1502 btn.disabled = false; 1503 if (form.querySelector('[name="smsCode"]')) { 1504 clearSmsVerification(form); 1505 if (verifiedPhone) smsStore.delete(verifiedPhone); 1506 } 1507 }); 1508 1509 // Clear any custom validity message once the user edits the field again. 1510 form.querySelectorAll('[name="email"], [name="phone"]').forEach((el) => { 1511 el.addEventListener('input', () => el.setCustomValidity('')); 1512 }); 1513 }); 1514 1515 // Open modal if user lands with #quick hash directly. 1516 if (window.location.hash === '#quick') { 1517 setTimeout(() => openModal('business'), 200); 1518 history.replaceState(null, '', window.location.pathname); 1519 } 1520 1521 const oneMapBody = document.querySelector('.one-project-page .one-project-map-body');
1522 if (oneMapBody) { 1523 const svg = oneMapBody.querySelector('.one-project-map-flow'); 1524 const linesGroup = svg?.querySelector('.one-project-map-flow-lines'); 1525 const dotsGroup = svg?.querySelector('.one-project-map-flow-dots'); 1526 const hub = oneMapBody.querySelector('.one-project-map-hub'); 1527 const leftCards = oneMapBody.querySelectorAll('.one-project-map-col:not(.is-right) .one-project-map-card'); 1528 const rightCards = oneMapBody.querySelectorAll('.one-project-map-col.is-right .one-project-map-card'); 1529 1530 const circlePoint = (cx, cy, r, tx, ty) => { 1531 const dx = tx - cx; 1532 const dy = ty - cy; 1533 const len = Math.hypot(dx, dy) || 1; 1534 return { x: cx + (dx / len) * r, y: cy + (dy / len) * r }; 1535 }; 1536 1537 const linkPath = (x1, y1, x2, y2, side) => { 1538 const mx = (x1 + x2) / 2; 1539 const my = (y1 + y2) / 2; 1540 const bend = side === 'left' ? 18 : -18; 1541 return `M ${x1} ${y1} Q ${mx + bend} ${my} ${x2} ${y2}`; 1542 }; 1543 1544 const addDot = (x, y) => { 1545 const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 1546 dot.setAttribute('cx', String(x)); 1547 dot.setAttribute('cy', String(y)); 1548 dot.setAttribute('r', '3.5'); 1549 dotsGroup.appendChild(dot); 1550 }; 1551 1552 const layoutOneProjectMapLinks = () => { 1553 if (!svg || !linesGroup || !dotsGroup || !hub) return; 1554 1555 const bodyRect = oneMapBody.getBoundingClientRect(); 1556 if (bodyRect.width < 1 || bodyRect.height < 1) return; 1557 1558 svg.setAttribute('viewBox', `0 0 ${bodyRect.width} ${bodyRect.height}`); 1559 linesGroup.replaceChildren(); 1560 dotsGroup.replaceChildren(); 1561 1562 const hubRect = hub.getBoundingClientRect(); 1563 const cx = hubRect.left + hubRect.width / 2 - bodyRect.left; 1564 const cy = hubRect.top + hubRect.height / 2 - bodyRect.top; 1565 const r = hubRect.width / 2; 1566 1567 const drawLinks = (cards, side) => { 1568 cards.forEach((card) => { 1569 const cardRect = card.getBoundingClientRect(); 1570 const sx = side === 'left' 1571 ? cardRect.right - bodyRect.left 1572 : cardRect.left - bodyRect.left; 1573 const sy = cardRect.top + cardRect.height / 2 - bodyRect.top; 1574 const end = circlePoint(cx, cy, r, sx, sy); 1575 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 1576 path.setAttribute('d', linkPath(sx, sy, end.x, end.y, side)); 1577 linesGroup.appendChild(path); 1578 addDot(sx, sy); 1579 addDot(end.x, end.y); 1580 }); 1581 }; 1582 1583 drawLinks(leftCards, 'left'); 1584 drawLinks(rightCards, 'right'); 1585 }; 1586 1587 let linkFrame = 0; 1588 const scheduleOneProjectMapLinks = () => { 1589 cancelAnimationFrame(linkFrame); 1590 linkFrame = requestAnimationFrame(layoutOneProjectMapLinks); 1591 }; 1592 1593 scheduleOneProjectMapLinks(); 1594 window.addEventListener('resize', scheduleOneProjectMapLinks, { passive: true }); 1595 window.addEventListener('load', scheduleOneProjectMapLinks); 1596 if (document.fonts?.ready) document.fonts.ready.then(scheduleOneProjectMapLinks); 1597 1598 const cover = oneMapBody.closest('.article-cover'); 1599 cover?.addEventListener('transitionend', scheduleOneProjectMapLinks); 1600 if (cover && typeof MutationObserver !== 'undefined') { 1601 const coverObserver = new MutationObserver(scheduleOneProjectMapLinks); 1602 coverObserver.observe(cover, { attributes: true, attributeFilter: ['class'] }); 1603 } 1604 1605 if (typeof ResizeObserver !== 'undefined') { 1606 const observer = new ResizeObserver(scheduleOneProjectMapLinks); 1607 observer.observe(oneMapBody); 1608 observer.observe(hub); 1609 } 1610 } 1611 1612 const driLayout = document.querySelector('.one-project-page .one-dri-layout'); 1613 if (driLayout) { 1614 const svg = driLayout.querySelector('.one-dri-connectors'); 1615 const linesGroup = svg?.querySelector('.one-dri-connectors-lines'); 1616 const core = driLayout.querySelector('.one-dri-core'); 1617 const skills = driLayout.querySelectorAll('.one-dri-skill'); 1618 1619 const circlePoint = (cx, cy, r, tx, ty) => { 1620 const dx = tx - cx; 1621 const dy = ty - cy; 1622 const len = Math.hypot(dx, dy) || 1; 1623 return { x: cx + (dx / len) * r, y: cy + (dy / len) * r }; 1624 }; 1625 1626 const layoutDriLinks = () => { 1627 if (!svg || !linesGroup || !core || !skills.length) return; 1628 1629 const layoutRect = driLayout.getBoundingClientRect(); 1630 if (layoutRect.width < 1 || layoutRect.height < 1) return; 1631 1632 svg.setAttribute('viewBox', `0 0 ${layoutRect.width} ${layoutRect.height}`); 1633 linesGroup.replaceChildren(); 1634 1635 const coreRect = core.getBoundingClientRect(); 1636 const ccx = coreRect.left + coreRect.width / 2 - layoutRect.left; 1637 const ccy = coreRect.top + coreRect.height / 2 - layoutRect.top; 1638 const cr = coreRect.width / 2; 1639
1640 skills.forEach((skill) => { 1641 const skillRect = skill.getBoundingClientRect(); 1642 const scx = skillRect.left + skillRect.width / 2 - layoutRect.left; 1643 const scy = skillRect.top + skillRect.height / 2 - layoutRect.top; 1644 const sr = skillRect.width / 2; 1645 const start = circlePoint(scx, scy, sr, ccx, ccy); 1646 const end = circlePoint(ccx, ccy, cr, scx, scy); 1647 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 1648 path.setAttribute('d', `M ${start.x} ${start.y} L ${end.x} ${end.y}`); 1649 linesGroup.appendChild(path); 1650 }); 1651 }; 1652 1653 let driFrame = 0; 1654 const scheduleDriLinks = () => { 1655 cancelAnimationFrame(driFrame); 1656 driFrame = requestAnimationFrame(layoutDriLinks); 1657 }; 1658 1659 scheduleDriLinks(); 1660 window.addEventListener('resize', scheduleDriLinks, { passive: true }); 1661 window.addEventListener('load', scheduleDriLinks); 1662 if (document.fonts?.ready) document.fonts.ready.then(scheduleDriLinks); 1663 1664 const driFigure = driLayout.closest('.one-method-figure'); 1665 driFigure?.addEventListener('transitionend', scheduleDriLinks); 1666 if (driFigure && typeof MutationObserver !== 'undefined') { 1667 const driObserver = new MutationObserver(scheduleDriLinks); 1668 driObserver.observe(driFigure, { attributes: true, attributeFilter: ['class'] }); 1669 } 1670 1671 if (typeof ResizeObserver !== 'undefined') { 1672 const driResizeObserver = new ResizeObserver(scheduleDriLinks); 1673 driResizeObserver.observe(driLayout); 1674 driResizeObserver.observe(core); 1675 skills.forEach((skill) => driResizeObserver.observe(skill)); 1676 } 1677 } 1678 1679 document.querySelectorAll('[data-one-case-carousel]').forEach((carousel) => { 1680 const slides = Array.from(carousel.querySelectorAll('[data-one-case-slide]')); 1681 const dots = Array.from(carousel.querySelectorAll('[data-one-case-dot]')); 1682 const prev = carousel.querySelector('[data-one-case-prev]'); 1683 const next = carousel.querySelector('[data-one-case-next]'); 1684 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 1685 let activeIndex = Math.max(0, slides.findIndex((slide) => slide.classList.contains('is-active'))); 1686 let timer = 0; 1687 1688 if (slides.length < 2) return; 1689 1690 const showSlide = (index) => { 1691 activeIndex = (index + slides.length) % slides.length; 1692 slides.forEach((slide, slideIndex) => { 1693 const isActive = slideIndex === activeIndex; 1694 slide.classList.toggle('is-active', isActive); 1695 slide.setAttribute('aria-hidden', isActive ? 'false' : 'true'); 1696 }); 1697 dots.forEach((dot, dotIndex) => { 1698 dot.classList.toggle('is-active', dotIndex === activeIndex); 1699 }); 1700 }; 1701 1702 const stop = () => { 1703 if (timer) window.clearInterval(timer); 1704 timer = 0; 1705 }; 1706 1707 const start = () => { 1708 stop(); 1709 if (!reduceMotion.matches) { 1710 timer = window.setInterval(() => showSlide(activeIndex + 1), 4200); 1711 } 1712 }; 1713 1714 prev?.addEventListener('click', () => { 1715 showSlide(activeIndex - 1); 1716 start(); 1717 }); 1718 1719 next?.addEventListener('click', () => { 1720 showSlide(activeIndex + 1); 1721 start(); 1722 }); 1723 1724 dots.forEach((dot, dotIndex) => { 1725 dot.addEventListener('click', () => { 1726 showSlide(dotIndex); 1727 start(); 1728 }); 1729 }); 1730 1731 carousel.addEventListener('mouseenter', stop); 1732 carousel.addEventListener('mouseleave', start); 1733 carousel.addEventListener('focusin', stop); 1734 carousel.addEventListener('focusout', () => { 1735 if (!carousel.contains(document.activeElement)) start(); 1736 }); 1737 document.addEventListener('visibilitychange', () => { 1738 if (document.hidden) stop(); 1739 else start(); 1740 }); 1741 reduceMotion.addEventListener?.('change', start); 1742 1743 showSlide(activeIndex); 1744 start(); 1745 }); 1746})();
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.