diff --git a/public/images/about-team.png b/public/images/about-team.png new file mode 100644 index 0000000..fb32c1e Binary files /dev/null and b/public/images/about-team.png differ diff --git a/public/images/abstract-business.svg b/public/images/abstract-business.svg new file mode 100644 index 0000000..0cce38a --- /dev/null +++ b/public/images/abstract-business.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/abstract-creative.svg b/public/images/abstract-creative.svg new file mode 100644 index 0000000..f41a633 --- /dev/null +++ b/public/images/abstract-creative.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/abstract-dev.svg b/public/images/abstract-dev.svg new file mode 100644 index 0000000..d95be15 --- /dev/null +++ b/public/images/abstract-dev.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/abstract-photo.svg b/public/images/abstract-photo.svg new file mode 100644 index 0000000..1689155 --- /dev/null +++ b/public/images/abstract-photo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/abstract-tutor.svg b/public/images/abstract-tutor.svg new file mode 100644 index 0000000..74a3adf --- /dev/null +++ b/public/images/abstract-tutor.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/artist.png b/public/images/artist.png new file mode 100644 index 0000000..7788cc8 Binary files /dev/null and b/public/images/artist.png differ diff --git a/public/images/auth-company-1.jpg b/public/images/auth-company-1.jpg new file mode 100644 index 0000000..16ef5f3 Binary files /dev/null and b/public/images/auth-company-1.jpg differ diff --git a/public/images/auth-company-2.jpg b/public/images/auth-company-2.jpg new file mode 100644 index 0000000..5044493 Binary files /dev/null and b/public/images/auth-company-2.jpg differ diff --git a/public/images/expert-1.jpg b/public/images/expert-1.jpg new file mode 100644 index 0000000..ee0231f Binary files /dev/null and b/public/images/expert-1.jpg differ diff --git a/public/images/expert-2.jpg b/public/images/expert-2.jpg new file mode 100644 index 0000000..354a394 Binary files /dev/null and b/public/images/expert-2.jpg differ diff --git a/public/images/expert-3.jpg b/public/images/expert-3.jpg new file mode 100644 index 0000000..5f0aa52 Binary files /dev/null and b/public/images/expert-3.jpg differ diff --git a/public/images/expert-4.jpg b/public/images/expert-4.jpg new file mode 100644 index 0000000..4d7d3ca Binary files /dev/null and b/public/images/expert-4.jpg differ diff --git a/public/images/fallback-pfp.png b/public/images/fallback-pfp.png new file mode 100644 index 0000000..84401c7 Binary files /dev/null and b/public/images/fallback-pfp.png differ diff --git a/public/images/hero-left.png b/public/images/hero-left.png new file mode 100644 index 0000000..8908184 Binary files /dev/null and b/public/images/hero-left.png differ diff --git a/public/images/hero-right.png b/public/images/hero-right.png new file mode 100644 index 0000000..446628b Binary files /dev/null and b/public/images/hero-right.png differ diff --git a/public/images/how-it-works.png b/public/images/how-it-works.png new file mode 100644 index 0000000..2a5f64f Binary files /dev/null and b/public/images/how-it-works.png differ diff --git a/public/images/howITWorks.png b/public/images/howITWorks.png new file mode 100644 index 0000000..b329b2a Binary files /dev/null and b/public/images/howITWorks.png differ diff --git a/public/images/landing-hero.png b/public/images/landing-hero.png new file mode 100644 index 0000000..7adc77e Binary files /dev/null and b/public/images/landing-hero.png differ diff --git a/public/images/landing-hero1.png b/public/images/landing-hero1.png new file mode 100644 index 0000000..71fd5f8 Binary files /dev/null and b/public/images/landing-hero1.png differ diff --git a/public/images/landing-servie-women.png b/public/images/landing-servie-women.png new file mode 100644 index 0000000..ab61abe Binary files /dev/null and b/public/images/landing-servie-women.png differ diff --git a/public/images/photo.png b/public/images/photo.png new file mode 100644 index 0000000..045ae8b Binary files /dev/null and b/public/images/photo.png differ diff --git a/public/images/photographer.png b/public/images/photographer.png new file mode 100644 index 0000000..b8642db Binary files /dev/null and b/public/images/photographer.png differ diff --git a/public/images/study.png b/public/images/study.png new file mode 100644 index 0000000..533c62e Binary files /dev/null and b/public/images/study.png differ diff --git a/public/images/tutor.png b/public/images/tutor.png new file mode 100644 index 0000000..da070ae Binary files /dev/null and b/public/images/tutor.png differ diff --git a/src/app.css b/src/app.css index 64a9455..7a26f65 100644 --- a/src/app.css +++ b/src/app.css @@ -12,10 +12,18 @@ box-sizing: border-box; } +button, +input, +textarea, +select { + font: inherit; +} + body { margin: 0; font-family: 'Exo 2', sans-serif; color: var(--ink); + scrollbar-gutter: stable; background: radial-gradient(120% 90% at 0% 0%, rgba(253, 98, 22, 0.22), transparent 52%), radial-gradient(100% 80% at 100% 0%, rgba(26, 54, 93, 0.16), transparent 56%), @@ -23,7 +31,7 @@ body { } .container { - width: min(1140px, calc(100% - 32px)); + width: min(1260px, calc(100% - 32px)); margin: 0 auto; } @@ -297,6 +305,305 @@ body { font-weight: 700; } +.contact-hero-panel { + border-radius: 30px; +} + +.contact-form-card { + padding: 24px; + border-radius: 24px; + box-shadow: 0 18px 34px -24px rgba(2, 6, 23, 0.44); +} + +.contact-side-card { + padding: 24px; + border-radius: 24px; + box-shadow: 0 24px 42px -28px rgba(2, 6, 23, 0.82); +} + +.contact-pill-row { + margin-top: 18px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.contact-pill { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.1); + padding: 6px 12px; + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.9); +} + +.contact-upload { + margin-top: 4px; + display: flex; + align-items: center; + gap: 8px; + border-radius: 12px; + border: 1px solid rgba(16, 11, 47, 0.16); + background: #fff; + padding: 10px 12px; + cursor: pointer; +} + +.contact-upload-icon { + color: #fd6216; + font-size: 14px; +} + +.contact-upload-text { + color: #334155; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.contact-upload-input { + display: none; +} + +.contact-detail { + display: flex; + align-items: center; + gap: 8px; +} + +.contact-icon { + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fd6216; +} + +.contact-icon svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.contact-mini-faq-grid { + margin-top: 16px; + display: grid; + gap: 12px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.contact-mini-faq-card { + border-radius: 16px; + border: 1px solid rgba(16, 11, 47, 0.14); + background: rgba(255, 255, 255, 0.94); + padding: 18px; + box-shadow: 0 12px 24px -20px rgba(2, 6, 23, 0.36); +} + +.contact-mini-faq-card h3 { + margin: 0; + color: #100b2f; + font-size: 16px; +} + +.contact-mini-faq-card p { + margin: 8px 0 0; + color: #334155; + font-size: 14px; + line-height: 1.55; +} + +.help-hero-panel { + border-radius: 30px; + box-shadow: 0 24px 50px -36px rgba(2, 6, 23, 0.9); +} + +.help-section-lg { + padding-top: 40px; + padding-bottom: 48px; +} + +.help-section-mid { + padding-top: 32px; + padding-bottom: 40px; +} + +.help-search-grid { + margin-top: 20px; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) 220px; +} + +.help-search-btn { + justify-self: start; +} + +.help-solid-section { + background: #fff; +} + +.help-article-list { + grid-template-columns: 1fr; + margin: 0; +} + +.help-empty-card { + border-radius: 16px; + border: 1px dashed rgba(16, 11, 47, 0.2); + background: #f8fafc; + padding: 32px; + color: #475569; + font-size: 14px; +} + +.help-cta-panel { + border-radius: 28px; + box-shadow: 0 24px 50px -36px rgba(2, 6, 23, 0.9); +} + +.help-category-head { + margin-top: 24px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.help-category-kicker { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #64748b; +} + +.help-clear-filter { + color: #fd6216; + font-size: 12px; + font-weight: 700; + text-decoration: underline; +} + +.help-category-row { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.help-category-pill { + border-radius: 999px; + border: 1px solid rgba(16, 11, 47, 0.16); + background: #fff; + padding: 8px 14px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #100b2f; + text-decoration: none; +} + +.help-category-pill-active { + border-color: rgba(253, 98, 22, 0.45); + background: #fff4ec; + color: #fd6216; +} + +.help-article-card { + border-radius: 18px; + border: 1px solid rgba(16, 11, 47, 0.12); + background: #fff; + padding: 22px; + box-shadow: 0 18px 36px -30px rgba(2, 6, 23, 0.55); +} + +.help-article-link { + color: #100b2f; + text-decoration: none; +} + +.help-article-link:hover { + color: #fd6216; +} + +.help-article-summary { + margin: 8px 0 0; + font-size: 14px; + line-height: 1.7; + color: #475569; +} + +.help-article-tags { + margin-top: 14px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.help-article-tag { + border-radius: 999px; + border: 1px solid rgba(16, 11, 47, 0.12); + background: #f8fafc; + padding: 4px 10px; + color: #334155; + font-size: 12px; +} + +.help-article-meta { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: #64748b; +} + +.help-read-link { + color: #fd6216; + font-size: 13px; + font-weight: 700; + text-decoration: none; +} + +.help-read-link:hover { + text-decoration: underline; +} + +.help-article-body { + margin-top: 18px; + border-radius: 16px; + border: 1px solid rgba(16, 11, 47, 0.12); + background: #fff; + padding: 18px; +} + +@media (max-width: 900px) { + .help-search-grid { + grid-template-columns: 1fr; + } + + .help-section-lg { + padding-top: 32px; + padding-bottom: 36px; + } + + .help-section-mid { + padding-top: 24px; + padding-bottom: 28px; + } +} + .step-pill { display: inline-block; font-size: 12px; @@ -403,38 +710,214 @@ body { .public-header { position: sticky; top: 0; - z-index: 60; + z-index: 50; border-bottom: 1px solid rgba(16, 11, 47, 0.1); - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(14px); + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(24px); + transition: box-shadow 300ms ease, border-color 300ms ease, background-color 300ms ease; } .public-header-scrolled { - box-shadow: 0 12px 28px -24px rgba(2, 6, 23, 0.5); + box-shadow: 0 12px 26px -24px rgba(2, 8, 23, 0.28); +} + +.public-header .container { + width: min(1260px, calc(100% - 32px)); +} + +.public-header .nav-row { + min-height: 76px; + padding: 14px 0; +} + +.public-header-scrolled .nav-row { + padding: 14px 0; +} + +.public-header .brand-logo { + height: 48px; +} + +.public-header .nav-links { + gap: 24px; +} + +.public-header .nav-links a { + position: relative; + text-decoration: none; + font-size: 14px; + color: #100b2f; + font-weight: 500; + transition: color 180ms ease; +} + +.public-header .nav-links a::after { + content: ''; + position: absolute; + left: 0; + bottom: -4px; + width: 100%; + height: 2px; + transform: scaleX(0); + transform-origin: left; + background: #fd6216; + transition: transform 260ms ease; +} + +.public-header .nav-links a:hover { + color: #fd6216; +} + +.public-header .nav-links a:hover::after { + transform: scaleX(1); +} + +.public-header .nav-links a.active, +.public-header .nav-links a[aria-current='page'] { + color: #fd6216; +} + +.public-header .nav-links a.active::after, +.public-header .nav-links a[aria-current='page']::after { + transform: scaleX(0); +} + +.nav-auth-btn { + min-width: 112px; + min-height: 42px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + text-decoration: none; + font-size: 14px; + font-weight: 600; + transition: transform 180ms ease, box-shadow 220ms ease, border-color 220ms ease, background-color 220ms ease; +} + +.nav-auth-btn:hover { + transform: translateY(-1px); + box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.13), 0 12px 22px -14px rgba(2, 6, 23, 0.55); +} + +.nav-auth-secondary { + border: 1px solid rgba(16, 11, 47, 0.2); + background: rgba(255, 255, 255, 0.85); + color: #100b2f; +} + +.nav-auth-secondary:hover { + background: #fff; +} + +.nav-auth-primary { + border: 1px solid rgba(253, 98, 22, 0.72); + background: #fd6216; + color: #fff; + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.4) inset, 0 12px 24px -16px rgba(253, 98, 22, 0.9); +} + +.nav-auth-primary:hover { + background: #eb5b14; } .desktop-only { display: flex; } -.mobile-menu { +.mobile-menu-toggle { display: none; + height: 40px; + width: 40px; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid rgba(16, 11, 47, 0.18); + background: rgba(255, 255, 255, 0.9); + color: #100b2f; +} + +.mobile-bars { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mobile-bars span { + display: block; + width: 20px; + height: 2px; + background: #100b2f; } .mobile-nav { display: none; - padding-bottom: 10px; - gap: 10px; + border-top: 1px solid rgba(16, 11, 47, 0.1); + background: rgba(255, 255, 255, 0.95); + padding: 16px 0; } -.mobile-nav a { - display: block; - padding: 8px 0; +.mobile-nav-links { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mobile-nav-links a { + border-radius: 8px; + padding: 8px; text-decoration: none; + font-size: 14px; + font-weight: 500; color: #100b2f; +} + +.mobile-nav-links a:hover { + background: rgba(16, 11, 47, 0.05); +} + +.mobile-nav-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.mobile-login, +.mobile-signup { + flex: 1; + border-radius: 12px; + padding: 8px 16px; + text-align: center; + text-decoration: none; + font-size: 14px; font-weight: 600; } +.mobile-login { + border: 1px solid rgba(16, 11, 47, 0.2); + background: #fff; + color: #100b2f; +} + +.mobile-signup { + border: 1px solid rgba(253, 98, 22, 0.72); + background: #fd6216; + color: #fff; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .scene-dark { background: transparent; } @@ -447,14 +930,16 @@ body { content: ''; position: absolute; inset: 8px 0; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.2)); + background: + radial-gradient(36% 54% at 14% 0%, rgba(253, 98, 22, 0.14), transparent 72%), + linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.2)); border-top: 1px solid rgba(255, 255, 255, 0.28); border-bottom: 1px solid rgba(255, 255, 255, 0.16); z-index: -1; } .public-hero { - padding: 22px 0 16px; + padding: 16px 0 40px; } .hero-grid-2 { @@ -488,7 +973,15 @@ body { } .public-section { - padding: 16px 0; + padding: 28px 0; +} + +.lp-section-hero { + padding: 24px 0 32px; +} + +.lp-section { + padding: 28px 0 36px; } .panel { @@ -541,58 +1034,296 @@ body { color: rgba(255, 255, 255, 0.78); } +.choose-path-panel { + border-color: #e4e6f0; + background: #fff; + padding: 20px; + box-shadow: 0 22px 44px -32px rgba(2, 6, 23, 0.6); +} + +.choose-path-panel .section-head h2 { + color: #100b2f; + font-size: 30px; + font-weight: 700; + line-height: 1.1; +} + +.choose-path-panel .sub { + color: #334155; + margin-top: 8px; + font-size: 16px; +} + .filter-row { + margin-top: 20px; display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; } .chip-btn { - border: 1px solid rgba(253, 98, 22, 0.4); - background: rgba(253, 98, 22, 0.08); - color: #9a3412; + border: 1px solid #d7dceb; + background: #fff; + color: #24314d; border-radius: 999px; - padding: 6px 10px; - font-weight: 700; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; cursor: pointer; + transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease, box-shadow 220ms ease; +} + +.chip-btn:hover { + border-color: rgba(253, 98, 22, 0.7); } .chip-btn.active { + border-color: #fd6216; background: #fd6216; color: #fff; + box-shadow: 0 8px 18px -10px rgba(253, 98, 22, 0.8); } .path-grid { margin-top: 14px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 16px; } .path-card { + position: relative; overflow: hidden; border-radius: 16px; - border: 1px solid rgba(148, 163, 184, 0.34); - background: #fff; + border: 1px solid rgba(16, 11, 47, 0.12); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(14px); + transition: transform 240ms ease, border-color 240ms ease, box-shadow 240ms ease; +} + +.path-card::before { + content: ''; + position: absolute; + inset: -30%; + background: radial-gradient(circle at 50% 50%, rgba(253, 98, 22, 0.18), transparent 60%); + opacity: 0; + transition: opacity 240ms ease; + pointer-events: none; + z-index: 0; +} + +.path-card:hover { + transform: translateY(-4px); + border-color: rgba(253, 98, 22, 0.65); + box-shadow: 0 22px 42px -30px rgba(253, 98, 22, 0.85); +} + +.path-card:hover::before { + opacity: 1; +} + +.path-media { + position: relative; + overflow: hidden; + z-index: 1; } .path-card img { width: 100%; - height: 170px; + height: 112px; object-fit: cover; + transition: transform 500ms ease; +} + +.path-card:hover img { + transform: scale(1.08); +} + +.path-media-overlay { + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.24), transparent); } .path-body { padding: 12px; + position: relative; + z-index: 1; } -.path-body h3 { - margin: 8px 0; +.path-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.path-icon { + color: #fd6216; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 300ms ease; +} + +.path-icon svg { + width: 20px; + height: 20px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.path-chip-row { + display: flex; + justify-content: flex-end; +} + +.path-chip { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + border: 1px solid rgba(16, 11, 47, 0.15); + background: rgba(255, 255, 255, 0.68); + color: #1e293b; + padding: 4px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.path-chip svg { + width: 14px; + height: 14px; + stroke: #fd6216; + fill: none; + stroke-width: 2; +} + +.path-secondary-btn { + margin-top: 16px; + display: inline-flex; + width: 100%; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 1px solid rgba(16, 11, 47, 0.2); + background: rgba(255, 255, 255, 0.5); + padding: 10px 14px; + font-size: 14px; + font-weight: 600; + color: #100b2f; + text-decoration: none; + transition: border-color 180ms ease, color 180ms ease; +} + +.path-secondary-btn:hover { + border-color: rgba(253, 98, 22, 0.7); color: #100b2f; } +.lp-path-controls { + align-items: center; + gap: 8px; +} + +.lp-path-arrow { + display: inline-flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(253, 98, 22, 0.5); + background: #fff; + color: #fd6216; + font-size: 16px; + cursor: pointer; +} + +.lp-path-arrow:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.path-carousel-shell { + margin-top: 18px; + overflow-x: hidden; + overflow-y: visible; + padding: 12px 0; +} + +.path-carousel-track { + display: flex; + transition: transform 500ms ease-out; +} + +.path-carousel-track-hover .path-card { + transform: translateY(-2px); + box-shadow: 0 16px 30px -26px rgba(253, 98, 22, 0.55); + border-color: rgba(253, 98, 22, 0.35); +} + +.path-page { + width: 100%; + flex-shrink: 0; +} + +.path-grid-1 { + grid-template-columns: 1fr; +} + +.path-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.path-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.lp-path-dots { + margin-top: 16px; + display: flex; + justify-content: center; + gap: 8px; +} + +.lp-path-dot { + width: 10px; + height: 10px; + border: 0; + border-radius: 999px; + background: #c9cedf; +} + +.lp-path-dot.active { + width: 28px; + background: #fd6216; +} + +.path-body h3 { + margin: 8px 0 0; + color: #100b2f; + font-size: 16px; + font-weight: 600; +} + .path-body p { + margin: 4px 0 0; color: #475569; + font-size: 14px; + line-height: 1.45; + min-height: 44px; +} + +.path-card:hover .path-icon { + transform: rotate(7deg); } .benefit-grid { @@ -683,21 +1414,34 @@ body { max-width: 980px; } +.faq-wrap h2 { + font-size: 24px; +} + +.faq-wrap .sub { + margin-top: 8px; + font-size: 14px; +} + .faq-list { - margin-top: 12px; + margin-top: 24px; display: grid; - gap: 8px; + gap: 10px; } .faq-item { - border: 1px solid rgba(255, 255, 255, 0.24); - border-radius: 14px; - background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 16px; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(8px); overflow: hidden; + transition: border-color 220ms ease, background-color 220ms ease, box-shadow 220ms ease; } .faq-item.open { border-color: rgba(253, 98, 22, 0.66); + background: rgba(255, 255, 255, 0.14); + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.24) inset; } .faq-q { @@ -705,17 +1449,44 @@ body { border: 0; background: transparent; color: #fff; - padding: 12px; + padding: 16px 20px; font-weight: 700; + font-size: 14px; display: flex; justify-content: space-between; + align-items: center; + gap: 10px; cursor: pointer; } +.faq-q-icon { + display: inline-flex; + font-size: 18px; + line-height: 1; + color: rgba(255, 255, 255, 0.74); + transform: rotate(0deg); + transition: transform 220ms ease, color 220ms ease; +} + +.faq-q-icon.open { + transform: rotate(180deg); + color: #fd6216; +} + .faq-a { margin: 0; - padding: 0 12px 12px; + padding: 0 20px 16px; color: rgba(255, 255, 255, 0.82); + font-size: 13px; + line-height: 1.65; +} + +.landing-hiw-section { + padding-top: 18px; +} + +.landing-faq-section { + padding-top: 28px; } .cta-row { @@ -726,10 +1497,87 @@ body { flex-wrap: wrap; } +.cta-panel { + position: relative; + overflow: hidden; +} + +.cta-glow { + position: absolute; + inset: -24% -14%; + background: + radial-gradient(46% 54% at 12% 8%, rgba(253, 98, 22, 0.28), transparent 72%), + radial-gradient(30% 32% at 82% 74%, rgba(255, 255, 255, 0.12), transparent 72%); + pointer-events: none; +} + +.cta-copy, +.cta-actions { + position: relative; + z-index: 1; +} + +.cta-copy .sub { + max-width: 620px; +} + +.cta-actions { + margin-top: 16px; +} + +.pulse { + animation: ctaPulse 2.4s ease-in-out infinite; +} + .public-footer { border-top: 1px solid rgba(16, 11, 47, 0.1); - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(12px); + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(24px); +} + +.public-footer .footer-row { + min-height: auto; + display: grid; + grid-template-columns: 1fr; + align-items: center; + gap: 8px; + padding: 12px 0; + color: #334155; + font-size: 14px; +} + +.public-footer .footer-row p { + margin: 0; + text-align: center; + color: #334155; +} + +.public-footer .footer-links { + justify-content: center; + gap: 16px; +} + +.public-footer .footer-links a { + color: #100b2f; +} + +.public-footer .footer-links a:hover { + color: #fd6216; +} + +@media (min-width: 640px) { + .public-footer .footer-row { + grid-template-columns: auto 1fr auto; + gap: 16px; + } + + .public-footer .footer-row p { + text-align: center; + } + + .public-footer .footer-links { + justify-content: flex-end; + } } .ghost-dark { @@ -750,7 +1598,7 @@ body { display: none; } - .mobile-menu { + .mobile-menu-toggle { display: inline-flex; } @@ -762,6 +1610,50 @@ body { .flow-card { grid-template-columns: 1fr; } + + .whyHeroCard { + min-height: 300px; + } + + .whyCardContent { + padding: 20px 18px 72px; + } + + .whyControls { + right: 12px; + bottom: -8px; + } + + .whyOrbitalNav { + gap: 8px; + } + + .whyOrbitalBtn { + height: 34px; + width: 34px; + } + + .hiwCodeCard { + grid-template-columns: 1fr; + } + + .hiwCodeMedia { + min-height: 260px; + border-right: none; + border-bottom: 1px solid #e1e9f3; + } + + .hiwCodeBigRect { + min-height: 210px; + } + + .hiwCodeBody { + padding: 16px; + } + + .hiwCodeTitle { + font-size: 22px; + } } @media (max-width: 640px) { @@ -769,6 +1661,34 @@ body { .benefit-grid { grid-template-columns: 1fr; } + + .hiwCodeTitle { + font-size: 19px; + } + + .hiwCodeDesc { + font-size: 12px; + } + + .hiwCodeStep { + grid-template-columns: 26px 1fr; + gap: 8px; + padding: 9px 10px; + } + + .hiwCodeStepNum { + width: 22px; + height: 22px; + font-size: 11px; + } + + .hiwCodeStepTitle { + font-size: 13px; + } + + .hiwCodeStepDesc { + font-size: 12px; + } } .marketing-page { @@ -844,6 +1764,8 @@ body { radial-gradient(34% 30% at 82% 14%, rgba(253, 98, 22, 0.2), transparent 74%), radial-gradient(40% 32% at 66% 80%, rgba(2, 6, 23, 0.4), transparent 74%); opacity: 0.58; + will-change: transform; + animation: none; } .lp-ribbon { @@ -851,7 +1773,9 @@ body { inset: -18% -6%; background: linear-gradient(120deg, transparent 35%, rgba(253, 98, 22, 0.18) 55%, transparent 74%); filter: blur(14px); - opacity: 0.38; + opacity: 0.54; + will-change: transform; + animation: none; } .lp-noise { @@ -866,6 +1790,8 @@ body { .lp-chips { position: absolute; inset: 0; + will-change: transform; + animation: lpIconLayerSway 10.5s ease-in-out infinite; } .lp-chip { @@ -873,14 +1799,10 @@ body { display: inline-flex; align-items: center; justify-content: center; - min-width: 44px; - height: 44px; border-radius: 999px; border: 1px solid rgba(253, 98, 22, 0.46); background: rgba(255, 255, 255, 0.14); color: #fd6216; - font-size: 12px; - font-weight: 700; backdrop-filter: blur(14px); box-shadow: 0 18px 36px -22px rgba(2, 6, 23, 0.8), @@ -888,6 +1810,11 @@ body { opacity: 0.52; } +.lp-chip svg { + width: 16px; + height: 16px; +} + .lp-chip-slow { animation: lp-float-slow 8s ease-in-out infinite; } @@ -908,8 +1835,9 @@ body { .lp-hero-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 20px; + gap: 48px; align-items: center; + padding: 48px 0 64px; } .lp-hero-title { @@ -927,6 +1855,79 @@ body { line-height: 1.6; } +.lp-primary-btn, +.lp-ghost-btn { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + padding: 12px 20px; + font-size: 14px; + font-weight: 600; + text-decoration: none; + transition: transform 180ms ease, border-color 180ms ease, box-shadow 220ms ease, background-color 220ms ease; +} + +.lp-primary-btn { + border: 1px solid rgba(253, 98, 22, 0.72); + background: #fd6216; + color: #fff; + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.4) inset, 0 12px 24px -16px rgba(253, 98, 22, 0.9); +} + +.lp-primary-btn:hover { + transform: translateY(-1px); + background: #eb5b14; + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.6) inset, 0 0 0 4px rgba(253, 98, 22, 0.16), 0 18px 30px -16px rgba(253, 98, 22, 0.95); +} + +.lp-ghost-btn { + border: 1px solid rgba(16, 11, 47, 0.22); + background: rgba(255, 255, 255, 0.5); + color: #100b2f; + backdrop-filter: blur(12px); + box-shadow: 0 10px 20px -16px rgba(2, 6, 23, 0.82); +} + +.lp-ghost-btn:hover { + transform: translateY(-1px); + border-color: rgba(253, 98, 22, 0.7); + box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.14), 0 14px 24px -14px rgba(2, 6, 23, 0.78); +} + +.lp-ghost-btn-dark { + border-color: rgba(255, 255, 255, 0.34); + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.lp-ghost-btn-dark:hover { + background: rgba(255, 255, 255, 0.16); +} + +.lp-primary-btn::before, +.lp-ghost-btn::before { + content: ''; + position: absolute; + inset: -160% auto -160% -35%; + width: 45%; + transform: rotate(18deg); + background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.42), transparent); + transition: transform 380ms ease; +} + +.lp-primary-btn:hover::before, +.lp-ghost-btn:hover::before { + transform: translateX(220%) rotate(18deg); +} + +.lp-hero-graph { + width: 100%; + max-width: 520px; +} + .lp-hero-slider { position: relative; min-height: 370px; @@ -965,6 +1966,664 @@ body { line-height: 1.6; } +.op-graph-wrap { + position: relative; + margin-inline: auto; + width: 100%; + max-width: 520px; +} + +.op-graph { + position: relative; + min-height: 334px; +} + +.op-graph-canvas { + position: relative; + height: 264px; + border-radius: 22px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: + radial-gradient(120% 95% at 15% 10%, rgba(255, 255, 255, 0.08), transparent 62%), + linear-gradient(150deg, rgba(15, 23, 42, 0.24), rgba(15, 23, 42, 0.06)); + overflow: hidden; +} + +.op-graph-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.op-graph-line { + stroke: rgba(253, 98, 22, 0.88); + stroke-width: 0.42; + stroke-linecap: round; + stroke-linejoin: round; + fill: none; + stroke-dasharray: var(--op-line-len); + stroke-dashoffset: var(--op-line-len); + filter: drop-shadow(0 0 2px rgba(253, 98, 22, 0.42)); +} + +.op-graph-line-hidden { + opacity: 0; +} + +.op-graph-line-visible { + opacity: 1; + stroke-dashoffset: 0; + animation: none; +} + +.op-graph-line-drawing { + animation: opGraphLineDraw 1400ms ease forwards; +} + +.op-graph-node { + position: absolute; + transform: translate(-50%, -50%); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.01em; + transition: opacity 320ms ease, border-color 280ms ease, background 280ms ease, box-shadow 280ms ease; + animation: none; +} + +.op-graph-node-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 9px; + min-height: 22px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.01em; + border: 1px solid rgba(255, 255, 255, 0.22); + backdrop-filter: blur(8px); + transition: left 860ms ease, top 860ms ease, opacity 300ms ease, transform 300ms ease, border-color 280ms ease, background 280ms ease, box-shadow 280ms ease; +} + +.op-graph-node-profile { color: #fff; background: rgba(15, 23, 42, 0.6); } +.op-graph-node-opportunity { color: #fff; background: rgba(30, 41, 59, 0.5); } +.op-graph-node-signal { color: #fff; background: rgba(15, 23, 42, 0.48); } +.op-graph-node-status { color: #fff; background: rgba(51, 65, 85, 0.5); } + +.op-graph-node-on { + opacity: 1; + border-color: rgba(253, 98, 22, 0.66); + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.14), 0 0 12px rgba(253, 98, 22, 0.34); + animation: opGraphNodeIn 260ms ease; +} + +.op-graph-workspace { + position: absolute; + inset: 14% 8% 12%; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.34); + background: linear-gradient(150deg, rgba(15, 23, 42, 0.92), rgba(15, 23, 42, 0.74)); + padding: 12px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + opacity: 0; + transform: scale(0.9); + transition: opacity 700ms ease, transform 700ms ease; + z-index: 5; + overflow: hidden; +} + +.op-graph-workspace-tick { + grid-column: 1 / -1; + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid rgba(253, 98, 22, 0.62); + border-radius: 999px; + background: rgba(253, 98, 22, 0.18); + padding: 6px 10px; +} + +.op-graph-workspace-tick-icon { + display: inline-flex; + width: 18px; + height: 18px; + border-radius: 999px; + align-items: center; + justify-content: center; + background: rgba(253, 98, 22, 0.96); + color: #fff; + font-size: 12px; + font-weight: 900; + line-height: 1; +} + +.op-graph-workspace-tick-text { + color: rgba(255, 242, 230, 0.98); + font-size: 11px; + font-weight: 700; +} + +.op-graph-workspace-title { + margin: 0 0 1px 0; + color: rgba(253, 186, 116, 0.96); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + grid-column: 1 / -1; +} + +.op-graph-workspace-row { + display: inline-flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + color: rgba(248, 250, 252, 0.98); + padding: 8px 9px; + line-height: 1.2; + min-width: 0; + min-height: 52px; +} + +.op-graph-workspace-row strong { + color: rgba(248, 250, 252, 0.98); + font-size: 11px; + font-weight: 700; + display: block; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.op-graph-workspace-row small { + margin-top: 3px; + color: rgba(226, 232, 240, 0.9); + font-size: 10px; + display: block; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.op-graph-phase4 .op-graph-workspace { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.3), 0 0 22px rgba(253, 98, 22, 0.35); +} + +.op-graph-phase4 .op-graph-line, +.op-graph-phase4 .op-graph-node { + opacity: 0; + transition-duration: 420ms; +} + +.op-graph-copy { + margin-top: 12px; + text-align: center; +} + +.op-graph-copy-line { + margin: 0; + color: rgba(248, 250, 252, 0.94); + font-size: 15px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.op-graph-copy-sub { + margin: 4px 0 0; + color: rgba(226, 232, 240, 0.88); + font-size: 12px; + font-weight: 500; +} + +@keyframes opGraphNodeIn { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes opGraphLineDraw { + from { + stroke-dashoffset: var(--op-line-len); + } + to { + stroke-dashoffset: 0; + } +} + +.whySliderWrap { + position: relative; + margin-top: 24px; + display: grid; + gap: 18px; + padding-bottom: 26px; +} + +.whyHeroCard { + position: relative; + min-height: 260px; + border-radius: 24px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.24); + background: + radial-gradient(120% 80% at 8% 0%, rgba(255, 255, 255, 0.18), transparent 58%), + linear-gradient(145deg, rgba(15, 23, 42, 0.82), rgba(30, 41, 59, 0.82)); + box-shadow: 0 26px 52px -30px rgba(2, 6, 23, 0.9); +} + +.whyHeroCardPulse { + animation: whyCardBreath 7.2s ease-in-out infinite; +} + +.whyCardGlow { + position: absolute; + inset: 0; + background: + radial-gradient(220px 120px at 12% 4%, rgba(253, 98, 22, 0.36), transparent 66%), + linear-gradient(180deg, rgba(2, 6, 23, 0.1) 22%, rgba(2, 6, 23, 0.72) 92%); +} + +.whyCardContent { + position: relative; + z-index: 2; + padding: 26px 26px 28px; +} + +.whyContentSwap { + animation: whyContentIn 460ms ease; +} + +.whyHaloA, +.whyHaloB { + position: absolute; + border-radius: 999px; + filter: blur(30px); + pointer-events: none; +} + +.whyHaloA { + height: 160px; + width: 160px; + left: -30px; + top: -24px; + background: rgba(253, 98, 22, 0.26); + animation: whyHaloDriftA 8s ease-in-out infinite; +} + +.whyHaloB { + height: 140px; + width: 140px; + right: -20px; + bottom: -26px; + background: rgba(255, 255, 255, 0.15); + animation: whyHaloDriftB 9.5s ease-in-out infinite; +} + +.whyAutoTrack { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + width: 100%; + transform-origin: left; + background: linear-gradient(90deg, #fd6216, rgba(255, 188, 153, 0.9)); + animation: whyTrackFill 4.2s linear forwards; + z-index: 3; +} + +.whyMetaKicker { + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 210, 184, 0.9); + font-weight: 700; +} + +.whyOrbitalNav { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; +} + +.whyOrbitalBtn { + height: 38px; + width: 38px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.34); + background: rgba(15, 23, 42, 0.5); + display: inline-flex; + align-items: center; + justify-content: center; + color: #ff8f5a; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.whyOrbitalBtn:hover { + transform: translateY(-2px) scale(1.02); + border-color: rgba(253, 98, 22, 0.75); +} + +.whyOrbitalBtnActive { + background: rgba(253, 98, 22, 0.2); + border-color: rgba(253, 98, 22, 0.88); + box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.15); +} + +.whyIconShell { + display: inline-flex; + height: 42px; + width: 42px; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.34); + background: rgba(255, 255, 255, 0.16); + box-shadow: 0 14px 28px -18px rgba(2, 6, 23, 0.9); + color: #ff7f45; + font-weight: 800; +} + +.whyIconShell svg, +.whyOrbitalBtn svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.whyControls { + position: absolute; + right: 16px; + bottom: -10px; + display: flex; + gap: 12px; + z-index: 12; +} + +.whyControlBtn { + height: 40px; + width: 40px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.62); + background: rgba(15, 23, 42, 0.35); + color: #fff; + font-size: 13px; + transition: transform 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.whyControlBtn:hover { + transform: translateY(-1px); + border-color: rgba(253, 98, 22, 0.8); + background: rgba(253, 98, 22, 0.36); +} + +.hiwCodeCard { + margin-top: 26px; + border-radius: 24px; + border: 1px solid #dbe4f0; + background: #fff; + box-shadow: 0 26px 44px -34px rgba(2, 6, 23, 0.42); + overflow: hidden; + display: grid; + grid-template-columns: minmax(240px, 34%) 1fr; + animation: hiwCodeCardIn 420ms ease; +} + +.hiwCodeMedia { + position: relative; + min-height: 100%; + padding: 14px; + background: linear-gradient(180deg, #eef4fb, #f8fbff); + border-right: 1px solid #e1e9f3; +} + +.hiwCodeBigRect { + position: relative; + min-height: 220px; + border-radius: 20px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.46); + box-shadow: 0 24px 34px -24px rgba(2, 6, 23, 0.65); +} + +.hiwCodeBigRect::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.3)); + pointer-events: none; + z-index: 2; +} + +.hiwCodePhoto { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 1; +} + +.hiwCodeMediaCopy { + margin-top: 10px; + padding: 0 2px 2px; +} + +.hiwCodeBody { + padding: 18px 18px 16px 16px; + display: flex; + flex-direction: column; +} + +.hiwCodeKicker { + color: #c2410c; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + font-weight: 700; +} + +.hiwCodeTitle { + margin-top: 8px; + color: #0f172a; + font-size: 24px; + line-height: 1.15; + font-weight: 800; + max-width: 22ch; +} + +.hiwCodeDesc { + margin-top: 8px; + color: #475569; + font-size: 13px; + line-height: 1.5; + max-width: 52ch; +} + +.hiwCodeStepsHeading { + color: #1e293b; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; +} + +.hiwCodeSteps { + margin-top: 16px; + display: grid; + gap: 8px; +} + +.hiwCodeStep { + border-radius: 14px; + border: 1px solid #d7e1ed; + background: #f8fbff; + padding: 10px 12px; + display: grid; + grid-template-columns: 30px 1fr; + gap: 10px; + transition: border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease; +} + +.hiwCodeStep:hover { + border-color: rgba(253, 98, 22, 0.62); +} + +.hiwCodeStepActive { + border-color: #fd6216; + box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.12); + background: linear-gradient(145deg, #fff7f1 0%, #fff 100%); + transform: translateY(-1px); +} + +.hiwCodeStepNum { + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid rgba(253, 98, 22, 0.48); + background: #fff3eb; + color: #fd6216; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 800; +} + +.hiwCodeStepTitle { + color: #0f172a; + font-size: 14px; + line-height: 1.24; + font-weight: 700; + margin: 0; +} + +.hiwCodeStepDesc { + margin-top: 3px; + color: #475569; + font-size: 13px; + line-height: 1.38; +} + +.hiwCodeFooter { + margin-top: auto; + padding-top: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.hiwCodeArrows { + display: flex; + gap: 8px; +} + +.hiwCodeArrow { + width: 36px; + height: 36px; + border-radius: 999px; + border: 1px solid #d1dbe8; + background: #fff; + color: #1e293b; + font-size: 16px; + line-height: 1; + transition: border-color 180ms ease, transform 180ms ease, background-color 180ms ease; +} + +.hiwCodeArrow:hover { + transform: translateY(-1px); + border-color: rgba(253, 98, 22, 0.72); + background: #fff3eb; +} + +.hiwCodeDots { + display: flex; + gap: 6px; +} + +.hiwCodeDot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #cbd5e1; + transition: width 180ms ease, background-color 180ms ease; +} + +.hiwCodeDotActive { + width: 20px; + background: #fd6216; +} + +@keyframes whyCardBreath { + 0%, + 100% { + box-shadow: 0 26px 52px -30px rgba(2, 6, 23, 0.9); + } + 50% { + box-shadow: 0 28px 56px -28px rgba(253, 98, 22, 0.35); + } +} + +@keyframes whyContentIn { + from { + opacity: 0.45; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes whyTrackFill { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} + +@keyframes whyHaloDriftA { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(14px, 12px, 0); } +} + +@keyframes whyHaloDriftB { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(-12px, -10px, 0); } +} + +@keyframes hiwCodeCardIn { + from { + opacity: 0.45; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .lp-carousel-nav { margin-top: 10px; display: flex; @@ -1020,7 +2679,7 @@ body { transform: translateY(0px); } 50% { - transform: translateY(-7px); + transform: translateY(-11px); } } @@ -1030,7 +2689,7 @@ body { transform: translateY(0px); } 50% { - transform: translateY(-10px); + transform: translateY(-14px); } } @@ -1040,7 +2699,45 @@ body { transform: translateY(0px); } 50% { - transform: translateY(-12px); + transform: translateY(-16px); + } +} + +@keyframes lpIconLayerSway { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(0, -8px, 0); + } +} + +@keyframes lpMeshDrift { + 0% { + transform: translate3d(0, 0, 0) scale(1); + } + 100% { + transform: translate3d(0, 16px, 0) scale(1.03); + } +} + +@keyframes lpRibbonSweep { + 0% { + transform: translate3d(0, 0, 0); + } + 100% { + transform: translate3d(0, 18px, 0); + } +} + +@keyframes ctaPulse { + 0%, + 100% { + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.4) inset, 0 12px 24px -16px rgba(253, 98, 22, 0.9); + } + 50% { + box-shadow: 0 0 0 1px rgba(253, 98, 22, 0.52) inset, 0 16px 28px -16px rgba(253, 98, 22, 0.98); } } @@ -1049,7 +2746,1268 @@ body { grid-template-columns: 1fr; } - .lp-hero-slider { - min-height: 340px; + .lp-hero-graph { + max-width: 100%; + } + + .contact-mini-faq-grid { + grid-template-columns: 1fr; + } +} + +@media (min-width: 640px) { + .choose-path-panel .section-head h2 { + font-size: 36px; + } + + .faq-wrap h2 { + font-size: 30px; + } +} + +.lp-noise { + position: absolute; + inset: 0; + opacity: 0.08; + background-image: radial-gradient(rgba(255, 255, 255, 0.4) 0.6px, transparent 0.6px); + background-size: 4px 4px; +} + +.auth-page { + position: relative; + min-height: 100vh; + color: #fff; +} + +.auth-layout { + position: relative; + z-index: 1; + width: min(1260px, calc(100% - 32px)); + margin: 0 auto; + min-height: 100vh; + display: grid; + align-items: center; + grid-template-columns: 1.02fr 0.98fr; + gap: 24px; + padding: 24px 0; +} + +.auth-layout-single { + grid-template-columns: 1fr; + width: min(680px, calc(100% - 32px)); +} + +.auth-visual { + min-height: 620px; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.22); + background: linear-gradient(160deg, rgba(255, 255, 255, 0.17), rgba(255, 255, 255, 0.07)); + backdrop-filter: blur(12px); + box-shadow: 0 28px 60px -34px rgba(2, 6, 23, 0.88); + padding: 28px; + position: relative; + overflow: hidden; +} + +.auth-visual-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.auth-visual-overlay { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(16, 11, 47, 0.22), rgba(16, 11, 47, 0.66)); +} + +.auth-visual-content { + position: absolute; + left: 28px; + right: 28px; + bottom: 28px; + z-index: 2; +} + +.auth-form { + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.94); + color: #101228; + box-shadow: 0 28px 60px -34px rgba(2, 6, 23, 0.88); + backdrop-filter: blur(16px); + padding: 20px; +} + +.auth-form .title { + margin: 10px 0 0; + font-size: 36px; + font-weight: 800; + color: #101228; +} + +.auth-form .subtitle { + margin: 6px 0 0; + color: #535e7a; + font-size: 14px; +} + +.auth-form .label { + margin-bottom: 8px; + display: block; + color: #4b546f; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.auth-form .input, +.auth-form .select, +.auth-form .textarea { + height: 44px; + border-radius: 12px; + border: 1px solid #cfd4e3; + background: #fff; + padding: 0 14px; + font-size: 14px; + transition: border-color 180ms ease, box-shadow 180ms ease; +} + +.auth-form .textarea { + min-height: 110px; + padding-top: 10px; +} + +.auth-form .input:focus, +.auth-form .select:focus, +.auth-form .textarea:focus { + border-color: #fd6216; + box-shadow: 0 0 0 2px #ffd8c3; +} + +.auth-field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.auth-forgot-link { + font-size: 12px; + font-weight: 700; + color: #fd6216; + text-decoration: underline; +} + +.auth-password-wrap { + position: relative; +} + +.auth-password-wrap .input { + padding-right: 60px; +} + +.auth-toggle-visibility { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + border: 0; + background: transparent; + color: #5b6480; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; +} + +.auth-toggle-visibility:hover { + color: #1b2440; +} + +.auth-toggle-visibility svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.auth-captcha-row { + display: grid; + grid-template-columns: 34px 156px 1fr; + align-items: center; + gap: 8px; +} + +@media (min-width: 640px) { + .auth-captcha-row { + grid-template-columns: 34px 176px 1fr; + } +} + +.auth-captcha-refresh { + height: 34px; + width: 34px; + border-radius: 8px; + border: 1px solid #d6dbe8; + background: #fff; + color: #4f5975; + font-size: 16px; +} + +.auth-captcha-code { + height: 52px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #c8cedd; + border-radius: 12px; + padding: 0 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.14em; + font-size: 14px; + background: #fff; + color: #1e293b; +} + +.auth-submit-btn { + margin-top: 10px; + width: 100%; + height: 44px; + border: 0; + border-radius: 12px; + background: #fd6216; + color: #fff; + font-size: 14px; + font-weight: 700; +} + +.auth-submit-btn:disabled { + opacity: 0.72; +} + +.auth-footer-row { + margin-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.auth-footer-row .note { + margin: 0; + font-size: 12px; +} + +.otp-row { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + margin-top: 14px; + margin-bottom: 8px; +} + +.otp-input { + height: 52px; + border-radius: 12px; + border: 1px solid #cbd5e1; + text-align: center; + font-size: 22px; + font-weight: 700; + background: #fff; +} + +.back-top { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 80; + width: 46px; + height: 46px; + border-radius: 999px; + border: 1px solid rgba(253, 98, 22, 0.75); + background: #fd6216; + color: #fff; + font-size: 20px; + font-weight: 700; + box-shadow: 0 14px 28px -16px rgba(2, 6, 23, 0.88); +} + +@media (max-width: 1024px) { + .auth-layout { + grid-template-columns: 1fr; + width: min(760px, calc(100% - 32px)); + min-height: calc(100vh - 72px); + align-items: start; + padding-top: 16px; + padding-bottom: 24px; + } + + .auth-visual { + display: none; + } +} + +.about-page-root { + color: #f8fafc; +} + +.about-content { + position: relative; + z-index: 10; +} + +.about-content .container { + width: 100%; + max-width: 1240px; + padding-left: 16px; + padding-right: 16px; +} + +.about-with-rail { + padding-left: 0; +} + +.about-chapter-rail { + position: fixed; + left: 18px; + top: 50%; + transform: translateY(-50%); + z-index: 25; + display: none; + align-items: flex-start; + gap: 12px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(16, 11, 47, 0.7); + backdrop-filter: blur(12px); + border-radius: 14px; + padding: 8px 7px; + opacity: 0.8; +} + +.about-chapter-track { + position: relative; + width: 2px; + height: 120px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); +} + +.about-chapter-progress { + position: absolute; + left: 0; + top: 0; + width: 2px; + border-radius: 999px; + background: #fd6216; + box-shadow: 0 0 16px rgba(253, 98, 22, 0.66); + transition: height 260ms ease; +} + +.about-chapter-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 5px; +} + +.about-chapter-item a, +.about-chapter-item-active a { + font-size: 12px; + font-weight: 600; + line-height: 1.35; + text-decoration: none; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.82); + transition: color 160ms ease; +} + +.about-chapter-item a:hover { + color: #ffffff; +} + +.about-chapter-item-active a { + color: #fd6216; + text-shadow: 0 0 14px rgba(253, 98, 22, 0.55); +} + +.about-hero { + position: relative; + min-height: clamp(520px, 78vh, 760px); + display: flex; + align-items: center; + padding: 28px 0 8px; +} + +.about-hero::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(60% 50% at 82% 10%, rgba(253, 98, 22, 0.36), transparent 72%), + radial-gradient(40% 36% at 26% 60%, rgba(255, 255, 255, 0.07), transparent 72%); + pointer-events: none; +} + +.about-hero-inner { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr; + gap: 20px; + align-items: start; +} + +.about-kicker { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #ffd0b6; +} + +.about-kicker-orange { + color: #fd6216; +} + +.about-title { + margin: 12px 0 0; + font-size: clamp(38px, 7vw, 64px); + line-height: 1.08; + font-weight: 800; + color: #fff; +} + +.about-copy { + margin-top: 18px; + max-width: 740px; + font-size: 17px; + color: rgba(255, 255, 255, 0.82); +} + +.about-manifesto-card { + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: linear-gradient(145deg, rgba(16, 11, 47, 0.72), rgba(16, 11, 47, 0.5)); + backdrop-filter: blur(16px); + position: relative; + overflow: hidden; + padding: 24px; +} + +.about-manifesto-card h2 { + margin: 0; + font-size: 22px; + color: #fff; +} + +.about-manifesto-card ul { + margin: 14px 0 0; + padding-left: 18px; + color: rgba(255, 255, 255, 0.86); + font-size: 14px; + line-height: 1.7; +} + +.about-sheen-sweep { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(110deg, transparent 22%, rgba(255, 255, 255, 0.18) 46%, transparent 68%); + transform: translateX(-110%); + animation: aboutSheenSweep 9s ease-in-out infinite; +} + +.about-section-tight { + padding: 22px 0; +} + +.about-section-mid { + padding: 40px 0; +} + +.about-section-title, +.about-section-title-light { + margin: 8px 0 0; + font-size: clamp(30px, 5vw, 42px); + line-height: 1.1; + font-weight: 700; +} + +.about-section-title { + color: #fff; +} + +.about-section-title-light { + color: #100b2f; +} + +.about-chapter-label { + margin: 0; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.about-chapter-label-light { + color: #ffd0b6; +} + +.about-chapter-label-orange { + color: #fd6216; +} + +.about-chapter-title { + margin: 8px 0 0; + font-size: 30px; + line-height: 1.12; + font-weight: 700; +} + +.about-chapter-title-dark { + color: #ffffff; +} + +.about-chapter-title-light { + color: #100b2f; +} + +.about-chapter-problem-section .container { + position: relative; +} + +.about-chapter-problem-section { + min-height: auto; + display: flex; + align-items: flex-start; + padding-top: 0; + padding-bottom: 4px; +} + +.about-problem-stage { + position: relative; + min-height: clamp(420px, 56vh, 540px); + display: grid; + place-items: center; + text-align: center; + padding: 16px 16px 10px; + overflow: hidden; +} + +.about-problem-halo { + position: absolute; + left: 50%; + top: 46%; + width: min(640px, 82vw); + height: min(340px, 46vh); + transform: translate(-50%, -50%); + border-radius: 999px; + background: radial-gradient(circle, rgba(253, 98, 22, 0.52), rgba(253, 98, 22, 0.08) 58%, transparent 76%); + filter: blur(26px); + pointer-events: none; + transition: opacity 280ms ease; +} + +.about-problem-shape-a, +.about-problem-shape-b, +.about-problem-shape-c, +.about-problem-shape-d { + position: absolute; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(12px); + pointer-events: none; + transition: opacity 320ms ease, transform 360ms ease; +} + +.about-problem-shape-a { + left: 8%; + top: 24%; + width: 170px; + height: 64px; + animation: problemFloatOne 10s ease-in-out infinite; +} + +.about-problem-shape-b { + right: 10%; + top: 30%; + width: 190px; + height: 72px; + animation: problemFloatTwo 11s ease-in-out infinite; +} + +.about-problem-shape-c { + left: 15%; + bottom: 22%; + width: 150px; + height: 58px; + animation: problemFloatTwo 9s ease-in-out infinite; +} + +.about-problem-shape-d { + right: 14%; + bottom: 16%; + width: 184px; + height: 68px; + animation: problemFloatOne 12s ease-in-out infinite; +} + +.about-problem-headline { + position: relative; + z-index: 1; + max-width: 920px; + margin-top: 12px; + font-size: clamp(34px, 6.4vw, 72px); + line-height: 1.06; + font-weight: 800; + letter-spacing: -0.02em; + color: #fff; + transition: opacity 320ms ease, filter 320ms ease; +} + +.about-problem-body { + position: relative; + z-index: 1; + margin-top: 16px; + max-width: 780px; + font-size: clamp(16px, 2vw, 24px); + line-height: 1.6; + color: rgba(255, 255, 255, 0.86); + transition: opacity 320ms ease, filter 320ms ease; +} + +.about-glass-light { + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.24); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.93), rgba(255, 255, 255, 0.82)); + backdrop-filter: blur(16px); +} + +.about-glass-dark { + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: linear-gradient(145deg, rgba(16, 11, 47, 0.72), rgba(16, 11, 47, 0.5)); + backdrop-filter: blur(16px); +} + +.about-chapter-two-shell, +.about-trust-shell { + padding: 28px; +} + +.about-chapter-two-grid { + margin-top: 14px; + display: grid; + gap: 16px; + grid-template-columns: 1fr; +} + +.about-chapter-two-text h3 { + margin: 0; + color: #100b2f; + font-size: clamp(30px, 4vw, 44px); + line-height: 1.1; +} + +.about-chapter-two-heading { + margin: 0; + color: #100b2f; + font-size: clamp(30px, 5vw, 36px); + line-height: 1.12; + font-weight: 700; +} + +.about-chapter-two-text p { + margin-top: 16px; + color: #334155; + line-height: 1.8; +} + +.about-chapter-two-body { + margin-top: 20px; + color: #334155; + font-size: 16px; + line-height: 1.65; +} + +.about-trust-shell { + padding: 28px; +} + +.about-chapter-two-panel { + border-radius: 18px; + border: 1px solid rgba(16, 11, 47, 0.14); + background: linear-gradient(155deg, #ffffff, #f8fbff); + color: #100b2f; + min-height: 260px; + position: relative; + overflow: hidden; + transition: + opacity 700ms ease-out, + transform 700ms ease-out, + filter 700ms ease-out, + box-shadow 280ms ease-out; + transform-style: preserve-3d; +} + +.about-chapter-two-panel-glow { + position: absolute; + width: 180px; + height: 180px; + right: -40px; + top: -60px; + border-radius: 999px; + background: radial-gradient(circle, rgba(253, 98, 22, 0.24), transparent 72%); +} + +.about-chapter-two-reflection { + position: absolute; + inset: 0; + background: linear-gradient(110deg, transparent 25%, rgba(253, 98, 22, 0.15) 48%, transparent 72%); + transform: translateX(-110%); + animation: chapterTwoSweep 6s ease-out infinite; +} + +.about-chapter-two-panel-inner { + position: relative; + z-index: 1; + padding: 20px; +} + +.about-chapter-two-panel-label { + margin: 0; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.13em; + color: #fd6216; +} + +.about-chapter-two-panel-title { + margin: 12px 0 0; + font-size: 30px; + line-height: 1.2; + color: #100b2f; +} + +.about-chapter-two-divider { + margin-top: 16px; + display: block; + height: 1px; + background: linear-gradient(90deg, rgba(253, 98, 22, 0.55), transparent); +} + +.about-chapter-two-rows { + margin-top: 14px; + display: grid; + gap: 9px; +} + +.about-chapter-two-row { + display: flex; + align-items: center; + gap: 8px; + color: #334155; + font-size: 14px; + font-weight: 500; + transition: opacity 420ms ease-out, transform 420ms ease-out; +} + +.about-chapter-two-row-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #fd6216; + box-shadow: 0 0 0 5px rgba(253, 98, 22, 0.14); +} + +.about-chapter-two-closing { + margin-top: 16px; + color: #0f172a; + font-size: 14px; + font-weight: 600; +} + +.about-trust-sub { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.76); + font-size: 14px; +} + +.about-trust-sequence { + margin-top: 20px; +} + +.about-trust-sequence-list { + display: grid; + gap: 12px; +} + +.about-trust-sequence-row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.about-trust-sequence-card { + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.09); + padding: 14px; + backdrop-filter: blur(12px); + transition: transform 260ms ease, opacity 260ms ease, box-shadow 260ms ease; +} + +.about-trust-sequence-card:hover { + box-shadow: 0 18px 32px -24px rgba(253, 98, 22, 0.65); +} + +.about-trust-sequence-icon { + display: inline-flex; + height: 28px; + width: 28px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(253, 98, 22, 0.12); +} + +.about-trust-sequence-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #fd6216; +} + +.about-trust-num { + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #fd6216; +} + +.about-trust-sequence-card h3 { + margin: 6px 0 0; + font-size: 16px; + color: #fff; +} + +.about-trust-sequence-card p { + margin: 6px 0 0; + color: rgba(255, 255, 255, 0.78); + font-size: 14px; + line-height: 1.55; +} + +.about-principles-section { + min-height: 74vh; + padding-top: 14px; + padding-bottom: 0; +} + +.about-principle-narrative-section { + position: relative; + overflow: hidden; +} + +.about-principle-narrative-section::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 24px; + background: + radial-gradient(55% 46% at 76% 42%, rgba(253, 98, 22, 0.14), transparent 72%), + linear-gradient(180deg, rgba(16, 11, 47, 0.5), rgba(16, 11, 47, 0.16)); + pointer-events: none; +} + +.about-narrative-stage-root { + margin-top: 18px; + position: relative; + min-height: clamp(280px, 44vh, 420px); + display: flex; + align-items: center; + justify-content: center; +} + +.about-narrative-viewport { + position: relative; + width: min(100%, 860px); + min-height: 320px; + padding: 12px 0; +} + +.about-narrative-glow { + position: absolute; + left: 50%; + top: 50%; + width: min(720px, 84vw); + height: clamp(180px, 32vh, 300px); + transform: translate(-50%, -50%); + border-radius: 999px; + background: radial-gradient(circle, rgba(253, 98, 22, 0.66), rgba(253, 98, 22, 0.2) 50%, transparent 72%); + filter: blur(30px); + transition: opacity 260ms ease, transform 260ms ease; + pointer-events: none; +} + +.about-narrative-stack { + position: relative; + z-index: 1; + display: grid; + gap: 18px; +} + +.about-narrative-item-active, +.about-narrative-item-inactive { + transition: opacity 260ms ease, transform 260ms ease, text-shadow 260ms ease; +} + +.about-narrative-item-active { + opacity: 1; + transform: translate3d(0, 0, 0); + text-shadow: 0 0 24px rgba(253, 98, 22, 0.24); +} + +.about-narrative-item-inactive { + opacity: 0.35; + transform: translate3d(0, 8px, 0); +} + +.about-narrative-headline { + font-size: clamp(30px, 5vw, 66px); + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.02em; + color: #ffffff; + text-wrap: balance; +} + +.about-orange-word { + color: #fd6216; + text-shadow: 0 0 22px rgba(253, 98, 22, 0.45); +} + +.about-filter-underline, +.about-filter-underline-static, +.about-review-line, +.about-review-line-static { + margin-top: 10px; + width: min(360px, 56vw); + height: 2px; + border-radius: 999px; + transform-origin: left center; +} + +.about-filter-underline, +.about-filter-underline-static { + background: linear-gradient(90deg, rgba(253, 98, 22, 0.95), rgba(253, 98, 22, 0.26)); +} + +.about-filter-underline { + transform: scaleX(0); + transition: transform 260ms ease; +} + +.about-filter-underline-static { + transform: scaleX(1); +} + +.about-review-line-static { + transform: scaleX(1); +} + +.about-review-line, +.about-review-line-static { + margin-top: 14px; + width: min(460px, 68vw); + height: 1px; + background: linear-gradient(90deg, rgba(253, 98, 22, 0.8), rgba(255, 255, 255, 0.12)); +} + +.about-review-line { + transform: scaleX(0); + transition: transform 300ms ease; +} + +.about-narrative-hint { + margin: 0; + font-size: 16px; + line-height: 1.35; + letter-spacing: 0.12em; + color: rgba(255, 255, 255, 0.78); + text-transform: uppercase; +} + +.about-principles-subline { + margin-top: 12px; + font-size: 14px; + line-height: 1.4; + letter-spacing: 0.12em; + color: rgba(255, 255, 255, 0.78); +} + +@media (min-width: 640px) { + .about-chapter-title { + font-size: 36px; + } + + .about-chapter-two-shell { + padding: 40px; + } + + .about-principles-subline { + font-size: 16px; + } +} + +.about-timeline-section-tight .about-glass-light { + padding: 28px; +} + +.about-timeline-mask-init { + opacity: 0; + transform: translate3d(0, 12px, 0); + clip-path: inset(0 0 100% 0 round 20px); + transition: opacity 520ms ease, transform 520ms ease, clip-path 620ms ease; +} + +.about-timeline-mask-show { + opacity: 1; + transform: translate3d(0, 0, 0); + clip-path: inset(0 0 0 0 round 20px); +} + +.about-timeline-mask-show .about-timeline-spine-glow { + animation: timelineGlowDraw 760ms ease forwards; +} + +.about-timeline-wrap { + position: relative; + margin-top: 18px; + display: grid; + gap: 14px; + padding-left: 38px; +} + +.about-timeline-spine-glow { + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, rgba(253, 98, 22, 0.55), rgba(253, 98, 22, 0.12)); +} + +.about-timeline-milestone { + position: relative; + border-radius: 14px; + border: 1px solid rgba(16, 11, 47, 0.14); + background: rgba(255, 255, 255, 0.75); + padding: 14px; + opacity: 0.12; + transform: translate3d(0, 12px, 0) scale(0.985); + transition: border-color 220ms ease, box-shadow 220ms ease, transform 420ms ease, opacity 420ms ease; +} + +.about-timeline-milestone-visible { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); +} + +.about-timeline-milestone:hover { + transform: translateY(-2px); + border-color: rgba(253, 98, 22, 0.5); + box-shadow: 0 16px 24px -20px rgba(253, 98, 22, 0.65); +} + +.about-timeline-index { + position: absolute; + left: -33px; + top: 12px; + width: 24px; + height: 24px; + border-radius: 999px; + background: #fd6216; + color: #fff; + font-size: 11px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 6px rgba(253, 98, 22, 0.12); +} + +.about-timeline-milestone h3 { + margin: 0; + color: #100b2f; + font-size: 16px; +} + +.about-timeline-milestone p { + margin: 6px 0 0; + color: #475569; + font-size: 14px; +} + +.about-closing-card { + padding: 30px; + text-align: center; +} + +.about-closing-card h2 { + margin: 0; + color: #fff; + font-size: clamp(28px, 4vw, 42px); +} + +.about-closing-card .hero-actions { + margin-top: 18px; + justify-content: center; +} + +.about-reveal-init { + opacity: 0; + transform: translate3d(0, 14px, 0); + transition: opacity 420ms ease, transform 420ms ease; +} + +.about-reveal-show { + opacity: 1; + transform: translate3d(0, 0, 0); +} + +@keyframes aboutSheenSweep { + 0%, + 70% { + transform: translateX(-110%); + } + 100% { + transform: translateX(110%); + } +} + +@keyframes chapterTwoSweep { + 0% { + transform: translateX(-110%); + } + 100% { + transform: translateX(110%); + } +} + +@keyframes problemFloatOne { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(0, -10px, 0); } +} + +@keyframes problemFloatTwo { + 0%, 100% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(0, 8px, 0); } +} + +@media (min-width: 640px) { + .about-hero-inner { + grid-template-columns: 1.25fr 0.75fr; + gap: 24px; + } + + .about-trust-shell { + padding: 36px; + } + + .about-chapter-two-grid { + grid-template-columns: 1.1fr 0.9fr; + align-items: stretch; + } +} + +@media (min-width: 1280px) { + .about-chapter-rail { + display: flex; + } + + .about-with-rail .container { + padding-left: 128px; + padding-right: 28px; + } + + .about-chapter-two-grid { + grid-template-columns: 1.18fr 0.82fr; + gap: 28px; + align-items: center; + } + + .about-narrative-stage-root { + min-height: 74vh; + } +} + +@media (max-width: 1024px) { + .about-chapter-rail { + display: none; + } + + .about-problem-shape-a, + .about-problem-shape-b, + .about-problem-shape-c, + .about-problem-shape-d { + display: none; + } + + .about-principles-section { + min-height: auto; + padding-top: 6px; + padding-bottom: 0; + } + + .about-narrative-stage-root { + min-height: clamp(220px, 34vh, 320px); + } +} + +@media (max-width: 768px) { + .about-trust-sequence-card { + padding: 12px; + } + + .about-narrative-item-active, + .about-narrative-item-inactive { + opacity: 1; + transform: none; + text-shadow: none; + } + + .about-filter-underline, + .about-review-line { + transition-duration: 180ms; + } +} + +@keyframes timelineGlowDraw { + from { + transform: scaleY(0.2); + transform-origin: top; + opacity: 0.25; + } + to { + transform: scaleY(1); + transform-origin: top; + opacity: 1; } } diff --git a/src/components/OpportunityGraph.tsx b/src/components/OpportunityGraph.tsx new file mode 100644 index 0000000..9d2541b --- /dev/null +++ b/src/components/OpportunityGraph.tsx @@ -0,0 +1,204 @@ +import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'; + +const PHASES = [ + 'Opportunities start scattered', + 'Profiles and opportunities connect', + 'Relevant matches are prioritized', + 'Everything organizes into one system', + 'Nxtgauge: one clear opportunity workspace', +] as const; + +type NodeDef = { + id: string; + label: string; + kind: 'profile' | 'opportunity' | 'signal' | 'status'; + p2: [number, number]; + p4: [number, number]; +}; + +const NODES: NodeDef[] = [ + { id: 'developer', label: 'Developer', kind: 'profile', p2: [12, 18], p4: [18, 28] }, + { id: 'tutor', label: 'Tutor', kind: 'profile', p2: [30, 20], p4: [18, 40] }, + { id: 'photographer', label: 'Photographer', kind: 'profile', p2: [19, 44], p4: [18, 52] }, + { id: 'company', label: 'Company', kind: 'profile', p2: [24, 72], p4: [18, 64] }, + { id: 'verified', label: 'Verified', kind: 'status', p2: [44, 18], p4: [40, 28] }, + { id: 'skills', label: 'Skills', kind: 'signal', p2: [38, 62], p4: [40, 40] }, + { id: 'portfolio', label: 'Portfolio', kind: 'signal', p2: [58, 12], p4: [40, 52] }, + { id: 'match', label: 'Match', kind: 'signal', p2: [56, 70], p4: [56, 40] }, + { id: 'lead', label: 'Lead', kind: 'signal', p2: [72, 24], p4: [56, 52] }, + { id: 'job', label: 'Job', kind: 'opportunity', p2: [84, 30], p4: [72, 30] }, + { id: 'project', label: 'Project', kind: 'opportunity', p2: [78, 58], p4: [72, 44] }, + { id: 'opening', label: 'Opening', kind: 'opportunity', p2: [90, 74], p4: [72, 58] }, + { id: 'response', label: 'Response', kind: 'status', p2: [92, 48], p4: [88, 36] }, + { id: 'update', label: 'Update', kind: 'status', p2: [74, 10], p4: [88, 52] }, +]; + +const EDGES: Array<[string, string]> = [ + ['developer', 'skills'], + ['tutor', 'skills'], + ['photographer', 'skills'], + ['company', 'verified'], + ['developer', 'verified'], + ['portfolio', 'verified'], + ['verified', 'match'], + ['skills', 'match'], + ['lead', 'match'], + ['match', 'job'], + ['match', 'project'], + ['match', 'opening'], + ['portfolio', 'project'], + ['job', 'response'], + ['project', 'response'], + ['opening', 'response'], + ['response', 'update'], +]; + + + +const WORKSPACE_CARDS = [ + { label: 'Verified Profiles', value: 'Trust layer ready' }, + { label: 'Matched Opportunities', value: 'Priority queue' }, + { label: 'Responses', value: 'Tracked in one flow' }, + { label: 'Updates', value: 'Live status signals' }, +]; + +type Props = { + reduceMotion: boolean; +}; + +export default function OpportunityGraph(props: Props) { + const [timelineMs, setTimelineMs] = createSignal(0); + const [paused, setPaused] = createSignal(false); + const LOOP_MS = 42000; + const EDGE_REVEAL_STEP_MS = 1500; + + onMount(() => { + if (props.reduceMotion) { + setTimelineMs(LOOP_MS - 1000); + return; + } + const ticker = window.setInterval(() => { + if (paused()) return; + setTimelineMs((prev) => Math.min(prev + 100, LOOP_MS)); + }, 100); + onCleanup(() => window.clearInterval(ticker)); + }); + + const edgeStartMs = 16500; + const nodeEndMs = 14000; + const workspaceStartMs = 37200; + + const edgeRevealCount = createMemo(() => { + if (timelineMs() < edgeStartMs) return 0; + return Math.min(EDGES.length, Math.floor((timelineMs() - edgeStartMs) / EDGE_REVEAL_STEP_MS) + 1); + }); + const nodeRevealCount = createMemo(() => { + const nodeProgress = Math.max(0, Math.min(1, timelineMs() / nodeEndMs)); + return Math.floor(nodeProgress * NODES.length); + }); + const workspaceVisible = createMemo(() => timelineMs() >= workspaceStartMs); + + const nodePos = createMemo(() => { + const map = new Map(); + for (const node of NODES) map.set(node.id, node.p2); + return map; + }); + + const copy = createMemo(() => { + const t = timelineMs(); + if (t < 12000) return PHASES[0]; + if (t < 23500) return PHASES[1]; + if (t < 32500) return PHASES[2]; + if (t < workspaceStartMs) return PHASES[3]; + return PHASES[4]; + }); + + const lineCoords = (pair: [string, string]) => { + const [from, to] = pair; + const a = nodePos().get(from); + const b = nodePos().get(to); + if (!a || !b) return null; + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 0.001) return null; + const trim = 1.6; + const ux = dx / dist; + const uy = dy / dist; + return { + x1: a[0] + ux * trim, + y1: a[1] + uy * trim, + x2: b[0] - ux * trim, + y2: b[1] - uy * trim, + len: Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])), + }; + }; + + return ( +
setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocusIn={() => setPaused(true)} + onFocusOut={() => setPaused(false)} + aria-label="Nxtgauge opportunity graph preview" + role="region" + > +
+
+ + + {!workspaceVisible() && NODES.map((node, index) => { + if (index >= nodeRevealCount()) return null; + const xy = nodePos().get(node.id) || [0, 0]; + return ( + + {node.label} + + ); + })} + +
+
+ + Everything from Nxtgauge +
+

Opportunity Workspace

+ {WORKSPACE_CARDS.map((card) => ( + + {card.label} + {card.value} + + ))} +
+
+ +
+

{copy()}

+ {workspaceVisible() ?

From scattered signals to one clear decision system.

: null} +
+
+
+ ); +} diff --git a/src/components/PublicHeader.tsx b/src/components/PublicHeader.tsx new file mode 100644 index 0000000..841bf2a --- /dev/null +++ b/src/components/PublicHeader.tsx @@ -0,0 +1,87 @@ +import { A, useLocation } from '@solidjs/router'; +import { Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'; + +type PublicHeaderProps = { + loginHref?: string; + signupHref?: string; +}; + +const isRouteActive = (pathname: string, href: string) => { + if (href === '/') return pathname === '/'; + if (href === '/help-center') return pathname === '/help-center' || pathname.startsWith('/help-center/'); + return pathname === href; +}; + +export default function PublicHeader(props: PublicHeaderProps) { + const location = useLocation(); + const [scrolled, setScrolled] = createSignal(false); + const [mobileOpen, setMobileOpen] = createSignal(false); + + const loginHref = () => props.loginHref || '/auth/login'; + const signupHref = () => props.signupHref || '/auth/register'; + + onMount(() => { + const onScroll = () => setScrolled(window.scrollY > 10); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + onCleanup(() => window.removeEventListener('scroll', onScroll)); + }); + + createEffect(() => { + location.pathname; + setMobileOpen(false); + }); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/components/PublicLanding.tsx b/src/components/PublicLanding.tsx index f334aba..9073a5e 100644 --- a/src/components/PublicLanding.tsx +++ b/src/components/PublicLanding.tsx @@ -1,10 +1,14 @@ import { A } from '@solidjs/router'; -import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'; +import OpportunityGraph from '~/components/OpportunityGraph'; +import PublicHeader from '~/components/PublicHeader'; type PathCard = { title: string; description: string; button: string; + chip: string; + icon: 'briefcase' | 'user' | 'users' | 'code' | 'camera' | 'sparkles' | 'grad' | 'film' | 'pen' | 'share' | 'dumbbell' | 'utensils'; href: string; image: string; audience: 'customer' | 'professional' | 'company' | 'job_seeker'; @@ -18,39 +22,14 @@ type Flow = { steps: Array<{ title: string; description: string }>; }; -const heroSlides = [ - { - title: 'Customers', - bullets: ['Post requirements with clear intent', 'Receive verified responses after review'], - href: '/onboarding?schemaId=customer_onboarding_v1', - cta: 'Hire a Professional', - }, - { - title: 'Professionals', - bullets: ['Complete role onboarding and verification', 'Discover leads with focused matching'], - href: '/onboarding?schemaId=professional_onboarding_v1', - cta: 'Join as Professional', - }, - { - title: 'Companies', - bullets: ['Create approved job listings', 'Track applications in one workflow'], - href: '/onboarding?schemaId=company_onboarding_v1', - cta: 'Post a Job', - }, - { - title: 'Job Seekers', - bullets: ['Build profile and clear approvals', 'Apply and monitor opportunities'], - href: '/onboarding?schemaId=jobseeker_onboarding_v1', - cta: 'Apply for Jobs', - }, -] as const; - const pathCards: PathCard[] = [ { title: 'Post a Job', description: 'Create verified job openings and find the right talent faster.', button: 'Start as Company', - href: '/onboarding?schemaId=company_onboarding_v1', + chip: 'Company', + icon: 'briefcase', + href: '/auth/register?intent=company&redirect=/users/onboarding/company', audience: 'company', image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?q=80&w=800&auto=format&fit=crop', }, @@ -58,7 +37,9 @@ const pathCards: PathCard[] = [ title: 'Apply for Jobs', description: 'Build your profile and apply to approved opportunities quickly.', button: 'Start as Job Seeker', - href: '/onboarding?schemaId=jobseeker_onboarding_v1', + chip: 'Job Seeker', + icon: 'user', + href: '/auth/register?intent=job_seeker&redirect=/users/onboarding/job-seeker', audience: 'job_seeker', image: 'https://images.unsplash.com/photo-1586281380349-632531db7ed4?q=80&w=800&auto=format&fit=crop', }, @@ -66,7 +47,9 @@ const pathCards: PathCard[] = [ title: 'Hire a Professional', description: 'Post your requirement and discover trusted specialists.', button: 'Start as Customer', - href: '/onboarding?schemaId=customer_onboarding_v1', + chip: 'Customer', + icon: 'users', + href: '/auth/register?intent=customer&redirect=/users/onboarding/customer', audience: 'customer', image: 'https://images.unsplash.com/photo-1450101499163-c8848c66ca85?q=80&w=800&auto=format&fit=crop', }, @@ -74,7 +57,9 @@ const pathCards: PathCard[] = [ title: 'Join as Developer', description: 'Build products and grow with verified client demand.', button: 'Join Developer', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Developer', + chip: 'Professional', + icon: 'code', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=developer', audience: 'professional', image: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=800&auto=format&fit=crop', }, @@ -82,7 +67,9 @@ const pathCards: PathCard[] = [ title: 'Join as Photographer', description: 'Capture events and campaigns with trusted bookings.', button: 'Join Photographer', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Photographer', + chip: 'Professional', + icon: 'camera', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=photographer', audience: 'professional', image: 'https://images.unsplash.com/photo-1516035069371-29a1b244cc32?q=80&w=800&auto=format&fit=crop', }, @@ -90,7 +77,9 @@ const pathCards: PathCard[] = [ title: 'Join as Makeup Artist', description: 'Offer styling services with profile-based trust signals.', button: 'Join Makeup Artist', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Makeup%20Artist', + chip: 'Professional', + icon: 'sparkles', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=makeup_artist', audience: 'professional', image: 'https://images.unsplash.com/photo-1522335789203-aabd1fc54bc9?q=80&w=800&auto=format&fit=crop', }, @@ -98,7 +87,9 @@ const pathCards: PathCard[] = [ title: 'Join as Tutor', description: 'Teach learners and build your reputation with verified profiles.', button: 'Join Tutor', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Tutor', + chip: 'Professional', + icon: 'grad', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=tutor', audience: 'professional', image: 'https://images.unsplash.com/photo-1497633762265-9d179a990aa6?q=80&w=800&auto=format&fit=crop', }, @@ -106,7 +97,9 @@ const pathCards: PathCard[] = [ title: 'Join as Video Editor', description: 'Create compelling edits and work with quality clients.', button: 'Join Video Editor', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Video%20Editor', + chip: 'Professional', + icon: 'film', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=video_editor', audience: 'professional', image: 'https://images.unsplash.com/photo-1574717024653-61fd2cf4d44d?q=80&w=800&auto=format&fit=crop', }, @@ -114,7 +107,9 @@ const pathCards: PathCard[] = [ title: 'Join as Graphic Designer', description: 'Design brand-ready visuals and collaborate with growing businesses.', button: 'Join Graphic Designer', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Graphic%20Designer', + chip: 'Professional', + icon: 'pen', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=graphic_designer', audience: 'professional', image: 'https://images.unsplash.com/photo-1558655146-d09347e92766?q=80&w=800&auto=format&fit=crop', }, @@ -122,7 +117,9 @@ const pathCards: PathCard[] = [ title: 'Join as Social Media Manager', description: 'Plan campaigns and scale audience growth for clients.', button: 'Join Social Manager', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Social%20Media%20Manager', + chip: 'Professional', + icon: 'share', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=social_media_manager', audience: 'professional', image: 'https://images.unsplash.com/photo-1611162618071-b39a2ec055fb?q=80&w=800&auto=format&fit=crop', }, @@ -130,7 +127,9 @@ const pathCards: PathCard[] = [ title: 'Join as Fitness Trainer', description: 'Coach clients with structured plans and trusted profiles.', button: 'Join Trainer', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Fitness%20Trainer', + chip: 'Professional', + icon: 'dumbbell', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=fitness_trainer', audience: 'professional', image: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?q=80&w=800&auto=format&fit=crop', }, @@ -138,19 +137,21 @@ const pathCards: PathCard[] = [ title: 'Join as Catering Services', description: 'Showcase event-ready menus to customers and companies.', button: 'Join Catering', - href: '/onboarding?schemaId=professional_onboarding_v1&profession=Catering%20Services', + chip: 'Professional', + icon: 'utensils', + href: '/auth/register?intent=professional&redirect=/users/onboarding/professional&profession=catering_services', audience: 'professional', image: 'https://images.unsplash.com/photo-1555244162-803834f70033?q=80&w=800&auto=format&fit=crop', }, ]; const benefits = [ - { title: 'Verified profiles & businesses', body: 'Identity and profile checks reduce fake submissions and spam.' }, - { title: 'Approval-based quality (24-48 hours)', body: 'Profiles, requirements, and jobs are reviewed before visibility.' }, - { title: 'Smart matching using tags/skills', body: 'Tag-based relevance helps surface better opportunities faster.' }, - { title: 'Focused discovery with filters', body: 'Search and filter tools keep opportunity discovery focused.' }, - { title: 'Controlled contact visibility', body: 'Sensitive contact flow remains controlled by role and workflow.' }, - { title: 'Notifications & updates', body: 'Track approvals, responses, applications, and key updates.' }, + { title: 'Verified profiles & businesses', body: 'Identity and profile checks reduce fake submissions and spam.', icon: 'shield-check' }, + { title: 'Approval-based quality (24-48 hours)', body: 'Profiles, requirements, and jobs are reviewed before visibility.', icon: 'zap' }, + { title: 'Smart matching using tags/skills', body: 'Tag-based relevance helps surface better opportunities faster.', icon: 'hash' }, + { title: 'Focused discovery with filters', body: 'Search and filter tools keep opportunity discovery focused.', icon: 'search' }, + { title: 'Controlled contact visibility', body: 'Sensitive contact flow remains controlled by role and workflow.', icon: 'lock' }, + { title: 'Notifications & updates', body: 'Track approvals, responses, applications, and key updates.', icon: 'bell' }, ] as const; const flows: Flow[] = [ @@ -228,36 +229,159 @@ const faqs = [ ] as const; const chipNodes = [ - { label: '', left: '3%', top: '14%', cls: 'lp-chip-slow' }, - { label: 'Cam', left: '95%', top: '20%', cls: 'lp-chip-mid' }, - { label: 'Job', left: '5%', top: '78%', cls: 'lp-chip-fast' }, - { label: 'Pro', left: '92%', top: '74%', cls: 'lp-chip-slow' }, - { label: 'AI', left: '48%', top: '7%', cls: 'lp-chip-mid' }, + { kind: 'code', left: '2%', top: '14%', size: 44, cls: 'lp-chip-slow' }, + { kind: 'camera', left: '94%', top: '22%', size: 46, cls: 'lp-chip-mid' }, + { kind: 'briefcase', left: '3%', top: '76%', size: 46, cls: 'lp-chip-fast' }, + { kind: 'bell', left: '93%', top: '80%', size: 42, cls: 'lp-chip-slow' }, + { kind: 'sparkles', left: '50%', top: '6%', size: 40, cls: 'lp-chip-mid' }, ] as const; +function ChipIcon(props: { kind: (typeof chipNodes)[number]['kind'] }) { + const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const; + if (props.kind === 'camera') { + return ( + + ); + } + if (props.kind === 'briefcase') { + return ( + + ); + } + if (props.kind === 'bell') { + return ( + + ); + } + if (props.kind === 'sparkles') { + return ( + + ); + } + return ( + + ); +} + +function PathRoleIcon(props: { kind: PathCard['icon'] }) { + const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const; + if (props.kind === 'briefcase') return ; + if (props.kind === 'user') return ; + if (props.kind === 'users') return ; + if (props.kind === 'code') return ; + if (props.kind === 'camera') return ; + if (props.kind === 'sparkles') return ; + if (props.kind === 'grad') return ; + if (props.kind === 'film') return ; + if (props.kind === 'pen') return ; + if (props.kind === 'share') return ; + if (props.kind === 'dumbbell') return ; + return ; +} + +function CheckBadgeIcon() { + return ( + + ); +} + +function WhyBenefitIcon(props: { kind: (typeof benefits)[number]['icon'] }) { + const common = { fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' } as const; + if (props.kind === 'shield-check') { + return ( + + ); + } + if (props.kind === 'zap') { + return ( + + ); + } + if (props.kind === 'hash') { + return ( + + ); + } + if (props.kind === 'search') { + return ( + + ); + } + if (props.kind === 'lock') { + return ( + + ); + } + return ( + + ); +} + export default function PublicLanding() { - const [scrolled, setScrolled] = createSignal(false); - const [mobileOpen, setMobileOpen] = createSignal(false); - const [scrollY, setScrollY] = createSignal(0); const [reduceMotion, setReduceMotion] = createSignal(false); + const [scrollY, setScrollY] = createSignal(0); const [heroTilt, setHeroTilt] = createSignal({ x: 0, y: 0 }); - const [heroIdx, setHeroIdx] = createSignal(0); - const [filter, setFilter] = createSignal<'all' | 'customer' | 'professional' | 'company' | 'job_seeker'>('all'); const [pathPage, setPathPage] = createSignal(0); const [cardsPerPage, setCardsPerPage] = createSignal(3); + const [pathPaused, setPathPaused] = createSignal(false); + const [pathTouchStartX, setPathTouchStartX] = createSignal(null); const [benefitIdx, setBenefitIdx] = createSignal(0); const [flowIndex, setFlowIndex] = createSignal(0); const [flowStepIndex, setFlowStepIndex] = createSignal(0); const [openFaq, setOpenFaq] = createSignal(0); + const [showBackToTop, setShowBackToTop] = createSignal(false); onMount(() => { const media = window.matchMedia('(prefers-reduced-motion: reduce)'); const syncMotion = () => setReduceMotion(media.matches); syncMotion(); + let ticking = false; const onScroll = () => { - setScrolled(window.scrollY > 10); - setScrollY(window.scrollY || 0); + setShowBackToTop(window.scrollY > 500); + if (media.matches) return; + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + setScrollY(window.scrollY || 0); + ticking = false; + }); }; const syncCardsPerPage = () => { @@ -279,8 +403,6 @@ export default function PublicLanding() { window.addEventListener('resize', syncCardsPerPage); media.addEventListener('change', syncMotion); - const heroTimer = window.setInterval(() => setHeroIdx((x) => (x + 1) % heroSlides.length), 3500); - const pathTimer = window.setInterval(() => setPathPage((x) => x + 1), 4200); const benefitTimer = window.setInterval(() => setBenefitIdx((x) => (x + 1) % benefits.length), 4200); const flowTimer = window.setInterval(() => { const active = flows[flowIndex()]; @@ -296,21 +418,13 @@ export default function PublicLanding() { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', syncCardsPerPage); media.removeEventListener('change', syncMotion); - window.clearInterval(heroTimer); - window.clearInterval(pathTimer); window.clearInterval(benefitTimer); window.clearInterval(flowTimer); }); }); - const filteredPaths = createMemo(() => { - const value = filter(); - if (value === 'all') return pathCards; - return pathCards.filter((card) => card.audience === value); - }); - const pagedPaths = createMemo(() => { - const cards = filteredPaths(); + const cards = pathCards; const per = cardsPerPage(); const pages: PathCard[][] = []; for (let i = 0; i < cards.length; i += per) pages.push(cards.slice(i, i + per)); @@ -323,29 +437,29 @@ export default function PublicLanding() { return pathPage() % pages.length; }); - const pathCardsVisible = createMemo(() => { - const pages = pagedPaths(); - if (pages.length === 0) return []; - return pages[activePathPage()]; + createEffect(() => { + const pagesLen = pagedPaths().length; + if (reduceMotion() || pathPaused() || pagesLen <= 1) return; + const timer = window.setInterval(() => { + setPathPage((prev) => { + const next = prev + 1; + return next >= pagesLen ? 0 : next; + }); + }, 4200); + onCleanup(() => window.clearInterval(timer)); }); - const parallax = createMemo(() => ({ - mesh: Math.min(36, scrollY() * 0.1), - ribbon: Math.min(52, scrollY() * 0.18), - chips: Math.min(70, scrollY() * 0.23), - })); - return (
); diff --git a/src/entry-client.tsx b/src/entry-client.tsx index 7dac7a5..667cae5 100644 --- a/src/entry-client.tsx +++ b/src/entry-client.tsx @@ -1,4 +1,4 @@ // @refresh reload import { mount, StartClient } from '@solidjs/start/client'; -mount(() => , document.getElementById('app')!); +export default mount(() => , document.getElementById('app')!); diff --git a/src/lib/auth-intent.ts b/src/lib/auth-intent.ts new file mode 100644 index 0000000..837fe7d --- /dev/null +++ b/src/lib/auth-intent.ts @@ -0,0 +1,35 @@ +export type CanonicalIntent = 'customer' | 'professional' | 'company' | 'job_seeker' | null; + +const INTENT_KEY = 'nxtgauge_intent_v1'; + +export function normalizeIntent(value: string | null | undefined): CanonicalIntent { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (!normalized) return null; + if (normalized === 'customer') return 'customer'; + if (normalized === 'professional' || normalized === 'pro') return 'professional'; + if (normalized === 'company' || normalized === 'employer') return 'company'; + if (normalized === 'job_seeker' || normalized === 'job-seeker' || normalized === 'jobseeker') return 'job_seeker'; + return null; +} + +export function intentToOnboardingPath(intent: CanonicalIntent): string { + if (intent === 'company') return '/users/onboarding/company'; + if (intent === 'job_seeker') return '/users/onboarding/job-seeker'; + if (intent === 'professional') return '/users/onboarding/professional'; + return '/users/onboarding/customer'; +} + +export function saveCanonicalIntent(intent: CanonicalIntent): void { + if (typeof window === 'undefined') return; + if (!intent) { + window.localStorage.removeItem(INTENT_KEY); + return; + } + window.localStorage.setItem(INTENT_KEY, intent); +} + +export function readCanonicalIntent(): CanonicalIntent { + if (typeof window === 'undefined') return null; + return normalizeIntent(window.localStorage.getItem(INTENT_KEY)); +} diff --git a/src/lib/help-center.ts b/src/lib/help-center.ts new file mode 100644 index 0000000..88f5454 --- /dev/null +++ b/src/lib/help-center.ts @@ -0,0 +1,83 @@ +export type HelpArticle = { + id: string; + slug: string; + title: string; + summary: string; + categoryKey: string; + role: 'ALL' | 'company' | 'jobSeeker' | 'professional' | 'customer' | 'platform'; + tags: string[]; + updatedAt: string; + content: string; +}; + +export const HELP_ARTICLES: HelpArticle[] = [ + { + id: 'hc-1', + slug: 'how-verification-works', + title: 'How verification works', + summary: 'Understand document review steps, approval outcomes, and timeline.', + categoryKey: 'verification', + role: 'ALL', + tags: ['verification', 'documents', 'approval'], + updatedAt: '2026-03-17T00:00:00Z', + content: 'After signup, complete onboarding for one path and submit required documents. Admin review updates your status as pending, document required, approved, or rejected.', + }, + { + id: 'hc-2', + slug: 'customer-post-requirement', + title: 'How customers post requirements', + summary: 'Choose profession intent, add requirements, and track verified responses.', + categoryKey: 'requirements', + role: 'customer', + tags: ['customer', 'requirements'], + updatedAt: '2026-03-17T00:00:00Z', + content: 'Customer flow starts with selecting the professional category, then requirement details, budget, and timeline. After review, qualified professionals can respond.', + }, + { + id: 'hc-3', + slug: 'professional-onboarding-guide', + title: 'Professional onboarding guide', + summary: 'Choose your profession, upload portfolio, submit PDF ID documents, and wait for approval.', + categoryKey: 'onboarding', + role: 'professional', + tags: ['professional', 'onboarding', 'portfolio'], + updatedAt: '2026-03-17T00:00:00Z', + content: 'Each profession in Solid has its own onboarding and service configuration. Complete all steps and verification to unlock your full dashboard.', + }, +]; + +export function listHelpCenterArticles(input: { role?: string; categoryKey?: string; q?: string }) { + const role = String(input.role || 'ALL'); + const categoryKey = String(input.categoryKey || '').trim(); + const q = String(input.q || '').trim().toLowerCase(); + + return HELP_ARTICLES.filter((article) => { + const roleOk = role === 'ALL' || article.role === 'ALL' || article.role === role; + const categoryOk = !categoryKey || article.categoryKey === categoryKey; + const queryOk = !q || `${article.title} ${article.summary} ${article.tags.join(' ')}`.toLowerCase().includes(q); + return roleOk && categoryOk && queryOk; + }); +} + +export function listHelpCenterCategories() { + const keys = new Map(); + for (const article of HELP_ARTICLES) { + if (!keys.has(article.categoryKey)) { + const title = article.categoryKey + .split('-') + .map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1)) + .join(' '); + keys.set(article.categoryKey, title); + } + } + + return Array.from(keys.entries()).map(([key, title], idx) => ({ + id: `cat-${idx + 1}`, + key, + title, + })); +} + +export function getArticleBySlug(slug: string) { + return HELP_ARTICLES.find((article) => article.slug === slug) || null; +} diff --git a/src/lib/server/email-verification-store.ts b/src/lib/server/email-verification-store.ts new file mode 100644 index 0000000..e27e163 --- /dev/null +++ b/src/lib/server/email-verification-store.ts @@ -0,0 +1,76 @@ +import crypto from 'node:crypto'; + +type VerificationFlow = 'register' | 'login'; + +type VerificationRecord = { + codeHash: string; + expiresAt: number; + attempts: number; +}; + +const CHALLENGE_TTL_MS = 10 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const records = new Map(); + +function hashCode(code: string): string { + return crypto.createHash('sha256').update(code).digest('hex'); +} + +function keyFor(email: string, flow: VerificationFlow): string { + return `${flow}:${email.toLowerCase().trim()}`; +} + +function cleanupExpired(): void { + const now = Date.now(); + for (const [key, record] of records.entries()) { + if (record.expiresAt <= now) records.delete(key); + } +} + +export function createVerificationCode(input: { email: string; flow: VerificationFlow }) { + cleanupExpired(); + const email = input.email.toLowerCase().trim(); + const flow = input.flow; + const code = String(crypto.randomInt(100000, 1000000)); + const expiresAt = Date.now() + CHALLENGE_TTL_MS; + + records.set(keyFor(email, flow), { + codeHash: hashCode(code), + expiresAt, + attempts: 0, + }); + + return { + code, + expiresAt, + expiresInSeconds: Math.floor(CHALLENGE_TTL_MS / 1000), + }; +} + +export function consumeVerificationCode(input: { email: string; flow: VerificationFlow; code: string }): + | { ok: true } + | { ok: false; reason: 'NOT_FOUND' | 'EXPIRED' | 'INVALID_CODE' | 'TOO_MANY_ATTEMPTS' } { + cleanupExpired(); + const key = keyFor(input.email, input.flow); + const record = records.get(key); + if (!record) return { ok: false, reason: 'NOT_FOUND' }; + + if (record.expiresAt <= Date.now()) { + records.delete(key); + return { ok: false, reason: 'EXPIRED' }; + } + + if (record.attempts >= MAX_ATTEMPTS) { + records.delete(key); + return { ok: false, reason: 'TOO_MANY_ATTEMPTS' }; + } + + if (record.codeHash !== hashCode(input.code.trim())) { + record.attempts += 1; + records.set(key, record); + return { ok: false, reason: 'INVALID_CODE' }; + } + + records.delete(key); + return { ok: true }; +} diff --git a/src/lib/server/gateway.ts b/src/lib/server/gateway.ts new file mode 100644 index 0000000..de6af14 --- /dev/null +++ b/src/lib/server/gateway.ts @@ -0,0 +1,31 @@ +const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); + +export function gatewayUrl(path: string) { + const normalized = path.startsWith('/') ? path : `/${path}`; + return `${gatewayBase}${normalized}`; +} + +export function readAccessTokenFromRequest(request: Request): string | null { + const cookie = request.headers.get('cookie') || ''; + if (!cookie) return null; + + const parts = cookie.split(';').map((part) => part.trim()); + const pair = parts.find((part) => part.startsWith('nxtgauge_access_token=')); + if (!pair) return null; + + const token = pair.split('=').slice(1).join('=').trim(); + if (!token) return null; + + try { + return decodeURIComponent(token); + } catch { + return token; + } +} + +export function withAuthHeaders(request: Request, extra?: Record) { + const token = readAccessTokenFromRequest(request); + const headers: Record = { ...(extra || {}) }; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} diff --git a/src/lib/server/smtp.ts b/src/lib/server/smtp.ts new file mode 100644 index 0000000..e8840c1 --- /dev/null +++ b/src/lib/server/smtp.ts @@ -0,0 +1,258 @@ +import net from 'node:net'; +import tls from 'node:tls'; +import { Buffer } from 'node:buffer'; + +type SocketLike = net.Socket | tls.TLSSocket; + +const DEFAULT_TIMEOUT_MS = 10000; + +function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback; + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function parsePort(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +function buildMessage(input: { from: string; to: string; subject: string; text: string }): string { + const headers = [ + `From: ${input.from}`, + `To: ${input.to}`, + `Subject: ${input.subject}`, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=UTF-8', + ]; + + return `${headers.join('\r\n')}\r\n\r\n${input.text}\r\n`; +} + +class SmtpConnection { + private socket: SocketLike; + private buffer = ''; + + constructor(socket: SocketLike) { + this.socket = socket; + this.socket.setEncoding('utf8'); + this.socket.on('data', (chunk: string) => { + this.buffer += chunk; + }); + } + + private waitForResponse(expectedCodes: number[]): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('SMTP response timeout')); + }, DEFAULT_TIMEOUT_MS); + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const checkBuffer = () => { + const lines = this.buffer.split(/\r?\n/).filter(Boolean); + if (lines.length === 0) return; + const lastLine = lines[lines.length - 1]; + const match = lastLine.match(/^(\d{3})([\s-])/); + if (!match) return; + if (match[2] === '-') return; + + this.buffer = ''; + cleanup(); + const code = Number(match[1]); + if (!expectedCodes.includes(code)) { + reject(new Error(`SMTP error ${code}: ${lastLine}`)); + return; + } + resolve(lastLine); + }; + + const interval = setInterval(checkBuffer, 20); + + const cleanup = () => { + clearTimeout(timeout); + clearInterval(interval); + this.socket.off('error', onError); + }; + + this.socket.on('error', onError); + checkBuffer(); + }); + } + + async expect(expectedCodes: number[]) { + await this.waitForResponse(expectedCodes); + } + + async command(commandText: string, expectedCodes: number[]) { + this.socket.write(`${commandText}\r\n`); + await this.waitForResponse(expectedCodes); + } + + async writeRaw(data: string, expectedCodes: number[]) { + this.socket.write(data); + await this.waitForResponse(expectedCodes); + } + + end() { + this.socket.end(); + } +} + +function connectPlain(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }); + socket.setTimeout(DEFAULT_TIMEOUT_MS); + + const onError = (error: Error) => { + socket.off('timeout', onTimeout); + socket.destroy(); + reject(error); + }; + + const onTimeout = () => { + socket.off('error', onError); + socket.destroy(); + reject(new Error('SMTP connection timeout')); + }; + + socket.once('error', onError); + socket.once('timeout', onTimeout); + socket.once('connect', () => { + socket.off('error', onError); + socket.off('timeout', onTimeout); + socket.setTimeout(0); + resolve(socket); + }); + }); +} + +function connectSecure(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = tls.connect({ host, port, servername: host }); + socket.setTimeout(DEFAULT_TIMEOUT_MS); + + const onError = (error: Error) => { + socket.off('timeout', onTimeout); + socket.destroy(); + reject(error); + }; + + const onTimeout = () => { + socket.off('error', onError); + socket.destroy(); + reject(new Error('SMTP connection timeout')); + }; + + socket.once('error', onError); + socket.once('timeout', onTimeout); + socket.once('secureConnect', () => { + socket.off('error', onError); + socket.off('timeout', onTimeout); + socket.setTimeout(0); + resolve(socket); + }); + }); +} + +function upgradeToStartTls(socket: net.Socket, host: string): Promise { + return new Promise((resolve, reject) => { + const secureSocket = tls.connect({ socket, servername: host }); + secureSocket.setTimeout(DEFAULT_TIMEOUT_MS); + + const onError = (error: Error) => { + secureSocket.off('timeout', onTimeout); + secureSocket.destroy(); + reject(error); + }; + + const onTimeout = () => { + secureSocket.off('error', onError); + secureSocket.destroy(); + reject(new Error('SMTP STARTTLS timeout')); + }; + + secureSocket.once('error', onError); + secureSocket.once('timeout', onTimeout); + secureSocket.once('secureConnect', () => { + secureSocket.off('error', onError); + secureSocket.off('timeout', onTimeout); + secureSocket.setTimeout(0); + resolve(secureSocket); + }); + }); +} + +export async function sendVerificationEmail(input: { + to: string; + code: string; + expiresInMinutes: number; +}) { + const host = process.env.MAIL_SMTP_HOST || '127.0.0.1'; + const port = parsePort(process.env.MAIL_SMTP_PORT, 587); + const secure = parseBooleanEnv(process.env.MAIL_SMTP_SECURE, false); + const useStartTls = parseBooleanEnv(process.env.MAIL_SMTP_STARTTLS, !secure); + const user = process.env.MAIL_SMTP_USER || process.env.MAIL_FROM; + const pass = process.env.MAIL_SMTP_PASS; + const from = process.env.MAIL_FROM; + + if (!from) throw new Error('MAIL_FROM is required'); + if (!user || !pass) throw new Error('MAIL_SMTP_USER and MAIL_SMTP_PASS are required'); + + const subject = 'Your Nxtgauge Verification Code'; + const text = [ + 'Use this code to complete your verification:', + '', + input.code, + '', + `This code expires in ${input.expiresInMinutes} minutes.`, + 'If you did not request this code, please ignore this email.', + ].join('\n'); + + const message = buildMessage({ from, to: input.to, subject, text }); + + let socket: SocketLike | null = null; + let client: SmtpConnection | null = null; + + try { + if (secure) { + socket = await connectSecure(host, port); + client = new SmtpConnection(socket); + } else { + const plainSocket = await connectPlain(host, port); + socket = plainSocket; + client = new SmtpConnection(plainSocket); + } + + await client.expect([220]); + await client.command('EHLO nxtgauge.com', [250]); + + if (!secure && useStartTls && socket instanceof net.Socket && !(socket instanceof tls.TLSSocket)) { + await client.command('STARTTLS', [220]); + const tlsSocket = await upgradeToStartTls(socket, host); + socket = tlsSocket; + client = new SmtpConnection(tlsSocket); + await client.command('EHLO nxtgauge.com', [250]); + } + + await client.command('AUTH LOGIN', [334]); + await client.command(Buffer.from(user).toString('base64'), [334]); + await client.command(Buffer.from(pass).toString('base64'), [235]); + await client.command(`MAIL FROM:<${from}>`, [250]); + await client.command(`RCPT TO:<${input.to}>`, [250, 251]); + await client.command('DATA', [354]); + await client.writeRaw(`${message}\r\n.\r\n`, [250]); + await client.command('QUIT', [221]); + } finally { + if (client) client.end(); + else if (socket) socket.end(); + } +} diff --git a/src/routes/about.tsx b/src/routes/about.tsx index 955e05a..bfda4a8 100644 --- a/src/routes/about.tsx +++ b/src/routes/about.tsx @@ -1,34 +1,541 @@ import { A } from '@solidjs/router'; +import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import PublicHeader from '~/components/PublicHeader'; + +const chapters = [ + { id: 'chapter-problem', title: '01 The problem' }, + { id: 'chapter-built', title: '02 What we built' }, + { id: 'chapter-trust', title: '03 How trust works' }, + { id: 'chapter-principles', title: '04 Principles' }, + { id: 'chapter-timeline', title: '05 Timeline' }, +] as const; + +const trustSequence = [ + { num: '01', title: 'Submission', body: 'A user submits profile, job, or requirement details with required context.' }, + { num: '02', title: 'Human Review', body: 'A reviewer checks quality, relevance, and verification requirements.' }, + { num: '03', title: 'Approval', body: 'Validated submissions are approved and moved to publish-ready state.' }, + { num: '04', title: 'Visible to Marketplace', body: 'Approved entities become discoverable and can receive responses.' }, +] as const; + +const milestones = [ + { title: 'Research', body: 'Mapped trust breakdowns in hiring and service discovery journeys.' }, + { title: 'MVP', body: 'Built a marketplace core with structured onboarding and review signals.' }, + { title: 'Private pilot', body: 'Tested operational workflows with role-specific submissions.' }, + { title: 'Launch', body: 'Released trust-layered marketplace experience to wider users.' }, + { title: 'Next: expanding categories', body: 'Adding more categories while keeping quality controls strong.' }, +] as const; +const chapterFourNarrative = [ + 'We didn’t build another marketplace.', + 'We built a filter.', + 'A review layer.', + 'Clarity replaces noise.', +] as const; +const chapterTwoRows = ['Profile status', 'Requirement status', 'Activity transparency'] as const; export default function AboutPage() { - return ( -
-
-

Brand Story

-

Trust-first hiring and services.

-

- Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence. -

- -
+ const [showBackToTop, setShowBackToTop] = createSignal(false); + const [reduceMotion, setReduceMotion] = createSignal(false); + const [activeChapter, setActiveChapter] = createSignal(0); + const [scrollY, setScrollY] = createSignal(0); -
-
-

Our Manifesto

-
    -
  • Verify what matters
  • -
  • Reduce spam and guesswork
  • -
  • Match people faster
  • + const [heroVisible, setHeroVisible] = createSignal(false); + const [problemVisible, setProblemVisible] = createSignal(false); + const [builtVisible, setBuiltVisible] = createSignal(false); + const [trustVisible, setTrustVisible] = createSignal(false); + const [principlesVisible, setPrinciplesVisible] = createSignal(false); + const [timelineVisible, setTimelineVisible] = createSignal(false); + const [closingVisible, setClosingVisible] = createSignal(false); + + const [problemProgress, setProblemProgress] = createSignal(0); + const [trustProgress, setTrustProgress] = createSignal(0); + const [principleProgress, setPrincipleProgress] = createSignal(0); + const [builtTilt, setBuiltTilt] = createSignal({ x: 0, y: 0 }); + + let heroRef: HTMLElement | undefined; + let chapterProblemRef: HTMLElement | undefined; + let chapterBuiltRef: HTMLElement | undefined; + let chapterTrustRef: HTMLElement | undefined; + let chapterPrinciplesRef: HTMLElement | undefined; + let chapterTimelineRef: HTMLElement | undefined; + let closingRef: HTMLElement | undefined; + + onMount(() => { + const media = window.matchMedia('(prefers-reduced-motion: reduce)'); + const syncMotion = () => setReduceMotion(media.matches); + syncMotion(); + + const chapterRefs = () => [chapterProblemRef, chapterBuiltRef, chapterTrustRef, chapterPrinciplesRef, chapterTimelineRef]; + + const revealMap = new Map void>(); + if (heroRef) revealMap.set(heroRef, setHeroVisible); + if (chapterProblemRef) revealMap.set(chapterProblemRef, setProblemVisible); + if (chapterBuiltRef) revealMap.set(chapterBuiltRef, setBuiltVisible); + if (chapterTrustRef) revealMap.set(chapterTrustRef, setTrustVisible); + if (chapterPrinciplesRef) revealMap.set(chapterPrinciplesRef, setPrinciplesVisible); + if (chapterTimelineRef) revealMap.set(chapterTimelineRef, setTimelineVisible); + if (closingRef) revealMap.set(closingRef, setClosingVisible); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const setter = revealMap.get(entry.target as HTMLElement); + if (setter) setter(true); + }); + }, + { threshold: 0.12 } + ); + revealMap.forEach((_, el) => observer.observe(el)); + + const onScroll = () => { + setShowBackToTop(window.scrollY > 500); + setScrollY(window.scrollY || 0); + + const middle = window.innerHeight * 0.42; + let nextActive = 0; + chapterRefs().forEach((section, index) => { + if (!section) return; + const rect = section.getBoundingClientRect(); + if (rect.top <= middle) nextActive = index; + }); + setActiveChapter(nextActive); + + if (chapterProblemRef && !reduceMotion()) { + const rect = chapterProblemRef.getBoundingClientRect(); + const viewport = window.innerHeight; + const start = viewport * 0.9; + const end = viewport * 0.18; + const range = rect.height + (start - end); + const raw = (start - rect.top) / range; + setProblemProgress(Math.max(0, Math.min(1, raw))); + } else if (reduceMotion()) { + setProblemProgress(1); + } + + if (chapterTrustRef && !reduceMotion()) { + const rect = chapterTrustRef.getBoundingClientRect(); + const viewport = window.innerHeight; + const start = viewport * 0.75; + const end = viewport * 0.18; + const range = rect.height + (start - end); + const raw = (start - rect.top) / range; + setTrustProgress(Math.max(0, Math.min(1, raw))); + } else if (reduceMotion()) { + setTrustProgress(1); + } + + if (chapterPrinciplesRef && !reduceMotion()) { + const rect = chapterPrinciplesRef.getBoundingClientRect(); + const viewport = window.innerHeight; + const sectionTopAbs = window.scrollY + rect.top; + const start = sectionTopAbs - viewport * 0.9; + const end = sectionTopAbs + rect.height - viewport * 0.52; + const range = Math.max(1, end - start); + const raw = (window.scrollY - start) / range; + setPrincipleProgress(Math.max(0, Math.min(1, raw))); + } else if (reduceMotion()) { + setPrincipleProgress(1); + } + }; + + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + media.addEventListener('change', syncMotion); + onCleanup(() => { + window.removeEventListener('scroll', onScroll); + media.removeEventListener('change', syncMotion); + observer.disconnect(); + }); + }); + + const progress = createMemo(() => { + if (chapters.length <= 1) return 0; + return (activeChapter() / (chapters.length - 1)) * 100; + }); + + const progressBetween = (value: number, start: number, end: number) => { + const span = Math.max(0.001, end - start); + return Math.max(0, Math.min(1, (value - start) / span)); + }; + + const effectiveProblemProgress = createMemo(() => (reduceMotion() ? 1 : problemProgress())); + const effectiveTrustProgress = createMemo(() => (reduceMotion() ? 1 : trustProgress())); + const effectivePrincipleProgress = createMemo(() => (reduceMotion() ? 1 : principleProgress())); + + const chapterOneHeadlineIn = createMemo(() => progressBetween(effectiveProblemProgress(), 0.12, 0.42)); + const chapterOneBodyIn = createMemo(() => progressBetween(effectiveProblemProgress(), 0.24, 0.56)); + const chapterOneShapeFade = createMemo(() => (reduceMotion() ? 0 : 0.08 * (1 - effectiveProblemProgress()))); + + const principleStage = createMemo(() => { + const p = effectivePrincipleProgress(); + if (p < 0.25) return 0; + if (p < 0.5) return 1; + if (p < 0.75) return 2; + return 3; + }); + const stateTwoUnderline = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.26, 0.46)); + const stateThreeLine = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.52, 0.74)); + + return ( +
    +
-
-

How trust works

-

Submission → Human review → Approval → Marketplace visibility.

-
-
+ + +
+
+
+
+

Brand Story

+

Trust-first hiring and services.

+

+ Nxtgauge is built to reduce noise, improve quality, and help people connect with confidence. +

+ +
+ + +
+
+
+ +
+
+
+ + + + + +

Chapter 01

+

The Problem

+

+ The hardest part isn’t finding options. +

+

+ It’s knowing which one deserves your time. +
+ Scrolling. Comparing. Second-guessing. Starting over. +
+ The real cost isn’t money. +
+ It’s momentum. +

+
+
+
+ +
+
+
+

Chapter 02

+

What We Built

+
+
+

We wanted to reduce hesitation.

+

+ Not by adding more choices. +
+ But by designing fewer, stronger ones. +
+ Nxtgauge is built around one idea: +
+ Confidence should come before commitment. +

+
+ +
+
+
+
+ +
+
+
+

Chapter 03

+

The Trust Layer

+

Most reviews complete within 24-48 hours.

+
+
+ + {(step, idx) => ( +
= (idx() / trustSequence.length) * 0.85 ? 1 : 0.3, + transform: effectiveTrustProgress() >= (idx() / trustSequence.length) * 0.85 ? 'translate3d(0,0,0)' : 'translate3d(0,10px,0)', + }} + > +
+ + + +
+

{step.num}

+

{step.title}

+

{step.body}

+
+
+
+ )} +
+
+
+
+
+
+ +
+
+
+

Chapter 04

+

Principles

+
+ +

We didn’t build another marketplace.

+
+

We built a filter.

+ +
+
+

A review layer.

+

Profiles. Jobs. Requirements.

+ +
+

Clarity replaces noise.

+
+ } + > +
+ +
+ + {(line, idx) => ( +
+

+ {idx() === 1 ? ( + <> + We built a filter. + + ) : ( + line + )} +

+ + + + + <> +

Profiles. Jobs. Requirements.

+ + +
+
+ )} +
+
+
+ +
+ + +
+ +
+
+
+

Chapter 05

+

Timeline

+
+ + + {(milestone, index) => ( +
+ {index() + 1} +

{milestone.title}

+

{milestone.body}

+
+ )} +
+
+
+
+
+ +
+
+
+

Have a question or want to partner?

+ +
+
+
+ + + + +
); } diff --git a/src/routes/api/runtime/auth-visuals.ts b/src/routes/api/runtime/auth-visuals.ts new file mode 100644 index 0000000..9a546fd --- /dev/null +++ b/src/routes/api/runtime/auth-visuals.ts @@ -0,0 +1,38 @@ +const FALLBACK_CONFIG = { + login: { + default: { + src: '/images/auth-company-1.jpg', + tag: 'Public Workspace', + title: 'Welcome Back To Nxtgauge', + subtitle: 'Sign in to manage requests, applications, and services with a unified, verified account.', + }, + }, + register: { + default: { + src: '/images/auth-company-1.jpg', + tag: 'Get Started', + title: 'Create Your Nxtgauge Account', + subtitle: 'Join verified opportunities for customers, professionals, companies, and job seekers.', + }, + }, +}; + +function getVisual(page: 'login' | 'register', intent: string) { + const visuals = FALLBACK_CONFIG[page]; + return visuals.default; +} + +export async function GET({ request }: { request: Request }) { + const url = new URL(request.url); + const pageParam = String(url.searchParams.get('page') || 'login').toLowerCase(); + const page = pageParam === 'register' ? 'register' : 'login'; + const intent = String(url.searchParams.get('intent') || 'default').toLowerCase(); + + return new Response( + JSON.stringify({ success: true, visual: getVisual(page, intent) }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); +} diff --git a/src/routes/api/runtime/onboarding/complete.ts b/src/routes/api/runtime/onboarding/complete.ts new file mode 100644 index 0000000..362fc08 --- /dev/null +++ b/src/routes/api/runtime/onboarding/complete.ts @@ -0,0 +1,44 @@ +import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway'; + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const roleKey = String(body?.roleKey || '').trim(); + const requiresApproval = body?.requiresApproval !== false; + + const upstream = await fetch(gatewayUrl('/me/onboarding-state/complete'), { + method: 'POST', + headers: withAuthHeaders(request, { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-portal-target': 'public', + }), + body: JSON.stringify({ + ...(roleKey ? { roleKey } : {}), + requiresApproval, + }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Failed to complete onboarding', + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true, data: payload }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/runtime/onboarding/progress.ts b/src/routes/api/runtime/onboarding/progress.ts new file mode 100644 index 0000000..602d79b --- /dev/null +++ b/src/routes/api/runtime/onboarding/progress.ts @@ -0,0 +1,48 @@ +import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway'; + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const roleKey = String(body?.roleKey || '').trim(); + const currentStep = Number(body?.currentStep || 0); + const totalSteps = Number(body?.totalSteps || 0); + const dataJson = body?.dataJson; + + const upstream = await fetch(gatewayUrl('/me/onboarding-state/progress'), { + method: 'POST', + headers: withAuthHeaders(request, { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-portal-target': 'public', + }), + body: JSON.stringify({ + ...(roleKey ? { roleKey } : {}), + currentStep: Number.isFinite(currentStep) ? currentStep : 0, + totalSteps: Number.isFinite(totalSteps) ? totalSteps : 0, + ...(dataJson ? { dataJson } : {}), + }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Failed to update onboarding progress', + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true, data: payload }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/runtime/onboarding/schema.ts b/src/routes/api/runtime/onboarding/schema.ts new file mode 100644 index 0000000..d5d5202 --- /dev/null +++ b/src/routes/api/runtime/onboarding/schema.ts @@ -0,0 +1,41 @@ +import { gatewayUrl } from '~/lib/server/gateway'; + +export async function GET({ request }: { request: Request }) { + try { + const url = new URL(request.url); + const schemaId = String(url.searchParams.get('schemaId') || '').trim(); + if (!schemaId) { + return new Response(JSON.stringify({ success: false, error: 'schemaId is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const upstream = await fetch(gatewayUrl(`/external/onboarding-schemas/${encodeURIComponent(schemaId)}`), { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Failed to load onboarding schema', + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true, data: payload }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/runtime/onboarding/state.ts b/src/routes/api/runtime/onboarding/state.ts new file mode 100644 index 0000000..b7fa0ad --- /dev/null +++ b/src/routes/api/runtime/onboarding/state.ts @@ -0,0 +1,39 @@ +import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway'; + +export async function GET({ request }: { request: Request }) { + try { + const url = new URL(request.url); + const roleKey = String(url.searchParams.get('roleKey') || '').trim(); + + const upstreamUrl = roleKey + ? `${gatewayUrl('/me/onboarding-state')}?${new URLSearchParams({ roleKey }).toString()}` + : gatewayUrl('/me/onboarding-state'); + + const upstream = await fetch(upstreamUrl, { + method: 'GET', + headers: withAuthHeaders(request, { Accept: 'application/json', 'x-portal-target': 'public' }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Failed to load onboarding state', + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true, data: payload }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/runtime/profile-status.ts b/src/routes/api/runtime/profile-status.ts new file mode 100644 index 0000000..f6d2710 --- /dev/null +++ b/src/routes/api/runtime/profile-status.ts @@ -0,0 +1,32 @@ +import { gatewayUrl, withAuthHeaders } from '~/lib/server/gateway'; + +export async function GET({ request }: { request: Request }) { + try { + const upstream = await fetch(gatewayUrl('/me/profile-status'), { + method: 'GET', + headers: withAuthHeaders(request, { Accept: 'application/json', 'x-portal-target': 'public' }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Failed to load profile status', + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true, data: payload }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/auth/forgot-password.ts b/src/routes/api/users/auth/forgot-password.ts new file mode 100644 index 0000000..ccfa8d5 --- /dev/null +++ b/src/routes/api/users/auth/forgot-password.ts @@ -0,0 +1,39 @@ +const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const { email } = body as { email?: string }; + if (!email) { + return new Response(JSON.stringify({ success: false, error: 'Email is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const upstream = await fetch(`${gatewayBase}/users/auth/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response(JSON.stringify({ success: false, error: payload?.error || payload?.message || 'Failed to send reset link' }), { + status: upstream.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true, message: payload?.message || 'Reset instructions sent.' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Failed to send reset link' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/auth/login.ts b/src/routes/api/users/auth/login.ts new file mode 100644 index 0000000..ae2f41e --- /dev/null +++ b/src/routes/api/users/auth/login.ts @@ -0,0 +1,56 @@ +const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const { email, password } = body as { email?: string; password?: string }; + + const upstream = await fetch(`${gatewayBase}/users/auth/external/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-portal-target': 'public', + }, + body: JSON.stringify({ email, password }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ + success: false, + error: payload?.message || payload?.error || 'Invalid credentials', + error_code: payload?.error_code, + }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + data: { + token: payload.accessToken || payload.access_token, + refreshToken: payload.refreshToken || payload.refresh_token, + ...(payload.user || {}), + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': [ + `nxtgauge_access_token=${encodeURIComponent(String(payload.accessToken || payload.access_token || ''))}; Path=/; HttpOnly; SameSite=Lax`, + `nxtgauge_refresh_token=${encodeURIComponent(String(payload.refreshToken || payload.refresh_token || ''))}; Path=/; HttpOnly; SameSite=Lax`, + ].join(', '), + }, + }, + ); + } catch { + return new Response(JSON.stringify({ success: false, error: 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/auth/register.ts b/src/routes/api/users/auth/register.ts new file mode 100644 index 0000000..8def506 --- /dev/null +++ b/src/routes/api/users/auth/register.ts @@ -0,0 +1,55 @@ +const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const { name, email, password, userType } = body as { + name?: string; + email?: string; + password?: string; + userType?: number; + }; + + if (!name || !email || !password) { + return new Response(JSON.stringify({ success: false, error: 'Name, email and password are required.' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const upstream = await fetch(`${gatewayBase}/users/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-portal-target': 'public', + }, + body: JSON.stringify({ name, email, password, ...(typeof userType === 'number' ? { userType } : {}) }), + cache: 'no-store', + }); + + const data = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response( + JSON.stringify({ success: false, error: data?.message || data?.error || 'Registration failed' }), + { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + data: { + userId: data?.id, + email: data?.email || email, + message: 'Registration successful', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/auth/reset-password.ts b/src/routes/api/users/auth/reset-password.ts new file mode 100644 index 0000000..e9f596b --- /dev/null +++ b/src/routes/api/users/auth/reset-password.ts @@ -0,0 +1,44 @@ +const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); + +export async function POST({ request }: { request: Request }) { + try { + const body = await request.json().catch(() => ({})); + const { email, token, newPassword } = body as { + email?: string; + token?: string; + newPassword?: string; + }; + + if (!email || !token || !newPassword) { + return new Response(JSON.stringify({ success: false, error: 'Email, token, and new password are required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const upstream = await fetch(`${gatewayBase}/users/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, token, new_password: newPassword }), + cache: 'no-store', + }); + + const payload = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return new Response(JSON.stringify({ success: false, error: payload?.error || payload?.message || 'Failed to reset password' }), { + status: upstream.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true, message: payload?.message || 'Password reset successfully' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Failed to reset password' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/auth/verification/request-code.ts b/src/routes/api/users/auth/verification/request-code.ts new file mode 100644 index 0000000..9c35cdd --- /dev/null +++ b/src/routes/api/users/auth/verification/request-code.ts @@ -0,0 +1,103 @@ +import { createVerificationCode } from '~/lib/server/email-verification-store'; +import { sendVerificationEmail } from '~/lib/server/smtp'; + +type VerificationFlow = 'register' | 'login'; + +function parseFlow(value: unknown): VerificationFlow { + return value === 'login' ? 'login' : 'register'; +} + +function maskEmail(email: string): string { + const [localPart, domain] = email.split('@'); + if (!localPart || !domain) return 'your email'; + if (localPart.length <= 2) return `${localPart[0] || '*'}*@${domain}`; + return `${localPart.slice(0, 2)}***@${domain}`; +} + +export async function POST({ request }: { request: Request }) { + try { + const rawBody = await request.text().catch(() => ''); + const url = new URL(request.url); + const query = url.searchParams; + + let body: any = {}; + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + const params = new URLSearchParams(rawBody); + body = { + email: params.get('email') || '', + flow: params.get('flow') || '', + }; + } + } + + const bodyData = body?.data && typeof body.data === 'object' ? body.data : {}; + const email = String(body?.email || bodyData?.email || query.get('email') || '').trim().toLowerCase(); + const flow = parseFlow(body?.flow || bodyData?.flow || query.get('flow')); + + if (!email) { + return new Response(JSON.stringify({ success: false, error: 'Email is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challenge = createVerificationCode({ email, flow }); + const exposeDebugCode = process.env.NODE_ENV !== 'production'; + const isDev = process.env.NODE_ENV !== 'production'; + const strictDelivery = process.env.MAIL_REQUIRE_SUCCESS === 'true'; + const mailTimeoutMs = 12000; + + try { + await Promise.race([ + sendVerificationEmail({ + to: email, + code: challenge.code, + expiresInMinutes: Math.max(Math.floor(challenge.expiresInSeconds / 60), 1), + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('SMTP delivery timeout')), mailTimeoutMs), + ), + ]); + } catch (mailError: any) { + if (!isDev || strictDelivery) { + throw mailError; + } + + return new Response( + JSON.stringify({ + success: true, + data: { + maskedEmail: maskEmail(email), + expiresInSeconds: challenge.expiresInSeconds, + debugCode: challenge.code, + emailDelivery: 'failed', + }, + message: `Verification code generated for ${maskEmail(email)} (email delivery failed in local dev).`, + warning: mailError?.message || 'Email delivery failed', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response( + JSON.stringify({ + success: true, + data: { + maskedEmail: maskEmail(email), + expiresInSeconds: challenge.expiresInSeconds, + ...(exposeDebugCode ? { debugCode: challenge.code } : {}), + }, + message: `Verification code sent to ${maskEmail(email)}`, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } catch (error: any) { + return new Response( + JSON.stringify({ success: false, error: error?.message || 'Failed to send verification code' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } +} diff --git a/src/routes/api/users/auth/verify.ts b/src/routes/api/users/auth/verify.ts new file mode 100644 index 0000000..df1a522 --- /dev/null +++ b/src/routes/api/users/auth/verify.ts @@ -0,0 +1,82 @@ +import { consumeVerificationCode } from '~/lib/server/email-verification-store'; + +const shouldSkipVerification = () => process.env.SKIP_EMAIL_VERIFICATION === 'true'; + +export async function POST({ request }: { request: Request }) { + try { + const rawBody = await request.text().catch(() => ''); + const url = new URL(request.url); + const query = url.searchParams; + + let body: any = {}; + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch { + const params = new URLSearchParams(rawBody); + body = { + email: params.get('email') || '', + code: params.get('code') || params.get('verificationCode') || params.get('verification_code') || '', + flow: params.get('flow') || '', + }; + } + } + + const bodyData = body?.data && typeof body.data === 'object' ? body.data : {}; + const email = String(body?.email || bodyData?.email || query.get('email') || '').trim().toLowerCase(); + const code = String( + body?.code + || body?.verificationCode + || body?.verification_code + || bodyData?.code + || bodyData?.verificationCode + || bodyData?.verification_code + || query.get('code') + || query.get('verificationCode') + || query.get('verification_code') + || '', + ).trim(); + + const flowInput = body?.flow || bodyData?.flow || query.get('flow'); + const flow = flowInput === 'login' ? 'login' : 'register'; + + if (!email || !code) { + return new Response(JSON.stringify({ success: false, message: 'Email and code are required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (shouldSkipVerification()) { + return new Response(JSON.stringify({ success: true, message: 'Email verification skipped (development mode).' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const result = consumeVerificationCode({ email, flow, code }); + if (!result.ok) { + const message = + result.reason === 'INVALID_CODE' + ? 'Invalid verification code' + : result.reason === 'TOO_MANY_ATTEMPTS' + ? 'Too many attempts. Please request a new code.' + : 'Verification code expired. Please request a new code.'; + + return new Response(JSON.stringify({ success: false, message }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ success: true, message: 'Email verified successfully' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: any) { + return new Response(JSON.stringify({ success: false, message: error?.message || 'Invalid code or email' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/auth/forgot-password/index.tsx b/src/routes/auth/forgot-password/index.tsx new file mode 100644 index 0000000..553b5f3 --- /dev/null +++ b/src/routes/auth/forgot-password/index.tsx @@ -0,0 +1,176 @@ +import { A, useSearchParams } from '@solidjs/router'; +import { createMemo, createSignal } from 'solid-js'; + +function getPasswordChecks(password: string, confirmPassword: string) { + return { + minLength: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + special: /[^A-Za-z0-9]/.test(password), + match: confirmPassword.length > 0 && password === confirmPassword, + }; +} + +export default function ForgotPasswordPage() { + const [search] = useSearchParams(); + const [mode, setMode] = createSignal<'request' | 'reset'>(search.token ? 'reset' : 'request'); + const [email, setEmail] = createSignal(search.email || ''); + const [token, setToken] = createSignal(search.token || ''); + const [newPassword, setNewPassword] = createSignal(''); + const [confirmPassword, setConfirmPassword] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + const [success, setSuccess] = createSignal(''); + + const checks = createMemo(() => getPasswordChecks(newPassword(), confirmPassword())); + const passwordStrong = createMemo(() => { + const c = checks(); + return c.minLength && c.uppercase && c.lowercase && c.number && c.special; + }); + + const requestReset = async () => { + setError(''); + setSuccess(''); + + if (!email().trim()) { + setError('Email is required.'); + return; + } + + setLoading(true); + try { + const response = await fetch('/api/users/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email().trim().toLowerCase() }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + setError(payload?.error || 'Failed to send reset email.'); + return; + } + + setSuccess(payload?.message || 'Reset instructions sent. Check your email.'); + setMode('reset'); + } catch { + setError('Failed to send reset email.'); + } finally { + setLoading(false); + } + }; + + const resetPassword = async () => { + setError(''); + setSuccess(''); + + if (!email().trim() || !token().trim() || !newPassword() || !confirmPassword()) { + setError('Email, token, and password fields are required.'); + return; + } + + if (!passwordStrong()) { + setError('Password must include uppercase, lowercase, number, special character, and be at least 8 characters.'); + return; + } + + if (!checks().match) { + setError('Passwords do not match.'); + return; + } + + setLoading(true); + try { + const response = await fetch('/api/users/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: email().trim().toLowerCase(), + token: token().trim(), + newPassword: newPassword(), + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + setError(payload?.error || 'Failed to reset password.'); + return; + } + + setSuccess(payload?.message || 'Password reset successfully. You can now sign in.'); + setNewPassword(''); + setConfirmPassword(''); + } catch { + setError('Failed to reset password.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ ); +} diff --git a/src/routes/auth/login-verification/index.tsx b/src/routes/auth/login-verification/index.tsx new file mode 100644 index 0000000..c93118e --- /dev/null +++ b/src/routes/auth/login-verification/index.tsx @@ -0,0 +1,5 @@ +import VerificationPage from '~/routes/auth/verification/index'; + +export default function LoginVerificationPage() { + return ; +} diff --git a/src/routes/auth/login/index.tsx b/src/routes/auth/login/index.tsx index 1833173..a43fd56 100644 --- a/src/routes/auth/login/index.tsx +++ b/src/routes/auth/login/index.tsx @@ -1,16 +1,233 @@ -import { A } from '@solidjs/router'; +import { A, useNavigate, useSearchParams } from '@solidjs/router'; +import { createMemo, createSignal, onMount } from 'solid-js'; +import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent'; +import PublicHeader from '~/components/PublicHeader'; + +function makeCaptcha() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +function isValidEmail(value: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +} + +function PasswordVisibilityIcon(props: { visible: boolean }) { + if (props.visible) { + return ( + + ); + } + return ( + + ); +} export default function LoginPage() { + const navigate = useNavigate(); + const [search] = useSearchParams(); + const intent = normalizeIntent(search.intent || search.intentRole); + const redirect = search.redirect; + const safeRedirect = redirect && redirect.startsWith('/') ? redirect : null; + const postLoginTarget = safeRedirect || intentToOnboardingPath(intent || readCanonicalIntent()); + + const [email, setEmail] = createSignal(''); + const [password, setPassword] = createSignal(''); + const [captcha, setCaptcha] = createSignal(makeCaptcha()); + const [captchaInput, setCaptchaInput] = createSignal(''); + const [error, setError] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [showPassword, setShowPassword] = createSignal(false); + const [visual, setVisual] = createSignal({ + src: '/images/auth-company-1.jpg', + tag: 'Public Workspace', + title: 'Welcome Back To Nxtgauge', + subtitle: 'Sign in to manage requests, applications, and services with a unified, verified account.', + }); + + const emailValid = createMemo(() => isValidEmail(email())); + + const signUpHref = createMemo(() => { + const next = new URLSearchParams(); + if (intent) next.set('intent', intent); + next.set('redirect', postLoginTarget); + return `/auth/register?${next.toString()}`; + }); + + onMount(() => { + const query = new URLSearchParams({ + page: 'login', + intent: intent || 'default', + }); + fetch(`/api/runtime/auth-visuals?${query.toString()}`) + .then((res) => res.json()) + .then((payload) => { + const next = payload?.visual; + if (!next?.src) return; + setVisual({ + src: String(next.src), + tag: String(next.tag || 'Public Workspace'), + title: String(next.title || 'Welcome Back To Nxtgauge'), + subtitle: String(next.subtitle || 'Sign in to manage requests, applications, and services with a unified, verified account.'), + }); + }) + .catch(() => {}); + }); + + const handleLogin = async () => { + setError(''); + + if (!email().trim() || !password().trim() || !captchaInput().trim()) { + setError('Please fill all fields.'); + return; + } + + if (!emailValid()) { + setError('Please enter a valid email address.'); + return; + } + + if (captchaInput().trim().toUpperCase() !== captcha()) { + setError('Captcha does not match.'); + setCaptcha(makeCaptcha()); + setCaptchaInput(''); + return; + } + + setLoading(true); + + try { + saveCanonicalIntent(intent); + + const loginResponse = await fetch('/api/users/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email: email().trim().toLowerCase(), password: password() }), + }); + + const payload = await loginResponse.json().catch(() => ({})); + if (!loginResponse.ok || !payload?.success) { + setError(payload?.error || 'Invalid email or password.'); + setLoading(false); + return; + } + + const verificationResponse = await fetch('/api/users/auth/verification/request-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email().trim().toLowerCase(), flow: 'login' }), + }); + const verificationPayload = await verificationResponse.json().catch(() => ({})); + if (!verificationResponse.ok || !verificationPayload?.success) { + setError(verificationPayload?.error || verificationPayload?.message || 'Failed to send verification code.'); + setLoading(false); + return; + } + + const next = new URLSearchParams({ + email: email().trim().toLowerCase(), + flow: 'login', + redirect: postLoginTarget, + }); + if (intent) next.set('intent', intent); + + navigate(`/auth/verification?${next.toString()}`); + } catch { + setError('Login failed. Please try again.'); + } finally { + setLoading(false); + } + }; + return ( -
-
-

Login

-

Auth parity page scaffolded. Final auth integration to be connected in next migration stage.

- -
+
+
); } diff --git a/src/routes/auth/register/index.tsx b/src/routes/auth/register/index.tsx index 0d3d203..7a117c8 100644 --- a/src/routes/auth/register/index.tsx +++ b/src/routes/auth/register/index.tsx @@ -1,16 +1,324 @@ -import { A } from '@solidjs/router'; +import { A, useNavigate, useSearchParams } from '@solidjs/router'; +import { createMemo, createSignal, onMount } from 'solid-js'; +import { intentToOnboardingPath, normalizeIntent, saveCanonicalIntent } from '~/lib/auth-intent'; +import PublicHeader from '~/components/PublicHeader'; + +const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1'; +const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1'; + +const makeCaptcha = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +}; + +function isValidEmail(value: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +} + +function normalizeProfessionalRole(value: string | null): string | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (!normalized) return null; + return normalized.replace(/[\s-]+/g, '_').replace(/[^a-z_]/g, ''); +} + +function getRegistrationExtras(intent: string | null) { + if (intent === 'company') return { userType: 1 }; + return { userType: 3 }; +} + +function PasswordVisibilityIcon(props: { visible: boolean }) { + if (props.visible) { + return ( + + ); + } + return ( + + ); +} export default function RegisterPage() { + const navigate = useNavigate(); + const [search] = useSearchParams(); + + const intentParam = normalizeIntent(search.intent || search.intentRole); + const redirectParam = search.redirect; + const safeRedirect = redirectParam && redirectParam.startsWith('/') ? redirectParam : null; + const professionalRole = normalizeProfessionalRole(search.profession || search.role || null); + const resolvedIntent = intentParam || 'customer'; + + const onboardingTarget = createMemo(() => { + const base = intentToOnboardingPath(resolvedIntent); + if (resolvedIntent !== 'professional' || !professionalRole) return base; + return `${base}?profession=${encodeURIComponent(professionalRole)}`; + }); + + const resolvedRedirect = createMemo(() => { + if (!safeRedirect) return onboardingTarget(); + if ( + resolvedIntent === 'professional' && + professionalRole && + safeRedirect.startsWith('/users/onboarding/professional') && + !safeRedirect.includes('profession=') + ) { + return `${safeRedirect}?profession=${encodeURIComponent(professionalRole)}`; + } + return safeRedirect; + }); + + const [firstName, setFirstName] = createSignal(''); + const [lastName, setLastName] = createSignal(''); + const [email, setEmail] = createSignal(''); + const [password, setPassword] = createSignal(''); + const [confirmPassword, setConfirmPassword] = createSignal(''); + const [captcha, setCaptcha] = createSignal(''); + const [captchaInput, setCaptchaInput] = createSignal(''); + const [error, setError] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [showPassword, setShowPassword] = createSignal(false); + const [showConfirmPassword, setShowConfirmPassword] = createSignal(false); + const [visual, setVisual] = createSignal({ + src: '/images/auth-company-1.jpg', + tag: 'Get Started', + title: 'Create Your Nxtgauge Account', + subtitle: 'Join verified opportunities for customers, professionals, companies, and job seekers.', + }); + + onMount(() => { + if (!captcha()) setCaptcha(makeCaptcha()); + const query = new URLSearchParams({ + page: 'register', + intent: resolvedIntent || 'default', + }); + fetch(`/api/runtime/auth-visuals?${query.toString()}`) + .then((res) => res.json()) + .then((payload) => { + const next = payload?.visual; + if (!next?.src) return; + setVisual({ + src: String(next.src), + tag: String(next.tag || 'Get Started'), + title: String(next.title || 'Create Your Nxtgauge Account'), + subtitle: String(next.subtitle || 'Join verified opportunities for customers, professionals, companies, and job seekers.'), + }); + }) + .catch(() => {}); + }); + + const checks = createMemo(() => ({ + minLength: password().length >= 8, + uppercase: /[A-Z]/.test(password()), + lowercase: /[a-z]/.test(password()), + number: /[0-9]/.test(password()), + special: /[^A-Za-z0-9]/.test(password()), + match: confirmPassword().length > 0 && password() === confirmPassword(), + })); + + const emailValid = createMemo(() => isValidEmail(email())); + const passwordStrong = createMemo(() => { + const c = checks(); + return c.minLength && c.uppercase && c.lowercase && c.number && c.special; + }); + + const canSubmit = createMemo(() => { + return ( + firstName().trim().length > 0 && + lastName().trim().length > 0 && + emailValid() && + passwordStrong() && + checks().match && + captchaInput().trim().toUpperCase() === captcha() + ); + }); + + const loginHref = createMemo(() => + `/auth/login?intent=${encodeURIComponent(resolvedIntent || 'customer')}&redirect=${encodeURIComponent(resolvedRedirect())}`, + ); + + const refreshCaptcha = () => { + setCaptcha(makeCaptcha()); + setCaptchaInput(''); + }; + + const handleRegister = async () => { + setError(''); + + if (!canSubmit()) { + setError('Please complete all fields correctly.'); + return; + } + + if (captchaInput().trim().toUpperCase() !== captcha()) { + setError('Captcha does not match.'); + refreshCaptcha(); + return; + } + + setLoading(true); + + try { + const normalizedEmail = email().trim().toLowerCase(); + saveCanonicalIntent(resolvedIntent); + + const verificationResponse = await fetch('/api/users/auth/verification/request-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: normalizedEmail, flow: 'register' }), + }); + + const verificationPayload = await verificationResponse.json().catch(() => ({})); + if (!verificationResponse.ok || !verificationPayload?.success) { + setError(verificationPayload?.error || verificationPayload?.message || 'Failed to send verification code.'); + setLoading(false); + return; + } + + const debugCode = String(verificationPayload?.data?.debugCode || '').trim(); + if (debugCode) { + window.localStorage.setItem( + DEV_VERIFICATION_CODE_KEY, + JSON.stringify({ email: normalizedEmail, flow: 'register', code: debugCode, createdAt: Date.now() }), + ); + } + + const fullName = `${firstName().trim()} ${lastName().trim()}`.trim(); + window.localStorage.setItem( + PENDING_REGISTER_KEY, + JSON.stringify({ + name: fullName, + email: normalizedEmail, + password: password(), + userType: getRegistrationExtras(resolvedIntent).userType, + intent: resolvedIntent, + redirect: resolvedRedirect(), + }), + ); + + const next = new URLSearchParams({ + email: normalizedEmail, + flow: 'register', + intent: resolvedIntent || 'customer', + redirect: resolvedRedirect(), + }); + + navigate(`/auth/verification?${next.toString()}`); + return; + } catch { + setError('Registration failed. Please try again.'); + } finally { + setLoading(false); + } + }; + return ( -
-
-

Register

-

Auth parity page scaffolded. Final OTP/signup integration to be connected in next migration stage.

- -
+
+
); } diff --git a/src/routes/auth/verification/index.tsx b/src/routes/auth/verification/index.tsx new file mode 100644 index 0000000..81490fa --- /dev/null +++ b/src/routes/auth/verification/index.tsx @@ -0,0 +1,278 @@ +import { A, useNavigate, useSearchParams } from '@solidjs/router'; +import { createMemo, createSignal, For, onMount } from 'solid-js'; +import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent'; + +const OTP_LENGTH = 6; +const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1'; +const DEV_VERIFICATION_CODE_KEY = 'nxtgauge_dev_verification_code_v1'; + +type PendingRegisterPayload = { + name: string; + email: string; + password: string; + userType: number; + intent?: string; + redirect?: string; +}; + +function readPendingRegisterPayload(): PendingRegisterPayload | null { + if (typeof window === 'undefined') return null; + const raw = window.localStorage.getItem(PENDING_REGISTER_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as PendingRegisterPayload; + } catch { + return null; + } +} + +function readDevVerificationCode(email: string, flow: string): string { + if (typeof window === 'undefined') return ''; + const raw = window.localStorage.getItem(DEV_VERIFICATION_CODE_KEY); + if (!raw) return ''; + + try { + const parsed = JSON.parse(raw) as { email?: string; flow?: string; code?: string; createdAt?: number }; + const code = String(parsed?.code || '').trim(); + const savedEmail = String(parsed?.email || '').trim().toLowerCase(); + const savedFlow = String(parsed?.flow || '').trim(); + const createdAt = Number(parsed?.createdAt || 0); + const isFresh = Number.isFinite(createdAt) && createdAt > 0 && Date.now() - createdAt <= 15 * 60 * 1000; + if (!code || savedEmail !== email.toLowerCase() || savedFlow !== flow || !isFresh) return ''; + return code; + } catch { + return ''; + } +} + +export default function VerificationPage() { + const navigate = useNavigate(); + const [search] = useSearchParams(); + + const email = () => String(search.email || ''); + const flow = () => String(search.flow || 'register'); + const intent = () => normalizeIntent(search.intent || search.intentRole); + const redirect = () => { + const v = search.redirect; + return v && v.startsWith('/') ? v : null; + }; + + const resolvedIntent = createMemo(() => intent() || readCanonicalIntent()); + const registerTarget = createMemo(() => intentToOnboardingPath(resolvedIntent())); + + const [otp, setOtp] = createSignal(Array.from({ length: OTP_LENGTH }, () => '')); + const [timer, setTimer] = createSignal(30); + const [error, setError] = createSignal(''); + const [info, setInfo] = createSignal(''); + const [loading, setLoading] = createSignal(false); + const [isResending, setIsResending] = createSignal(false); + + let intervalId: ReturnType | undefined; + const otpRefs: Array = []; + + onMount(() => { + const debugCode = readDevVerificationCode(email(), flow()); + if (debugCode) setInfo(`Local dev OTP: ${debugCode}`); + + intervalId = setInterval(() => { + setTimer((v) => (v > 0 ? v - 1 : 0)); + }, 1000); + }); + + const continueAfterVerification = async () => { + saveCanonicalIntent(resolvedIntent()); + + if (flow() === 'register') { + const pending = readPendingRegisterPayload(); + if (!pending?.email || !pending?.password || !pending?.name) { + setError('Registration session expired. Please register again.'); + setLoading(false); + return; + } + + if (email() && pending.email.toLowerCase() !== email().toLowerCase()) { + setError('Verification email does not match registration details. Please register again.'); + setLoading(false); + return; + } + + try { + const registerResponse = await fetch('/api/users/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: pending.name, + email: pending.email, + password: pending.password, + ...(typeof pending.userType === 'number' ? { userType: pending.userType } : {}), + }), + }); + + const registerPayload = await registerResponse.json().catch(() => ({})); + if (!registerResponse.ok || !registerPayload?.success) { + setError(String(registerPayload?.error || registerPayload?.message || 'Registration failed.')); + setLoading(false); + return; + } + } catch { + setError('Registration failed after verification. Please try again.'); + setLoading(false); + return; + } + + try { + const loginResponse = await fetch('/api/users/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email: pending.email, password: pending.password }), + }); + const loginPayload = await loginResponse.json().catch(() => ({})); + if (!loginResponse.ok || !loginPayload?.success) { + setError('Email verified and account created. Please sign in to continue.'); + setLoading(false); + return; + } + } catch { + setError('Email verified and account created. Please sign in to continue.'); + setLoading(false); + return; + } + + window.localStorage.removeItem(PENDING_REGISTER_KEY); + window.localStorage.removeItem(DEV_VERIFICATION_CODE_KEY); + navigate(pending.redirect || redirect() || registerTarget(), { replace: true }); + return; + } + + navigate(redirect() || registerTarget(), { replace: true }); + }; + + const handleChange = (index: number, value: string) => { + const digit = value.replace(/\D/g, '').slice(0, 1); + const next = [...otp()]; + next[index] = digit; + setOtp(next); + if (digit && index < OTP_LENGTH - 1) otpRefs[index + 1]?.focus(); + }; + + const handleVerify = async () => { + const code = otp().join(''); + if (code.length !== OTP_LENGTH) { + setError(`Please enter ${OTP_LENGTH}-digit code.`); + return; + } + + setLoading(true); + setError(''); + setInfo(''); + + try { + const response = await fetch('/api/users/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email(), code, flow: flow() }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + setError(payload?.message || payload?.error || 'Invalid verification code.'); + setLoading(false); + return; + } + + await continueAfterVerification(); + } catch { + setError('Unable to verify code. Please try again.'); + setLoading(false); + } + }; + + const resend = async () => { + if (!email() || timer() > 0 || isResending()) return; + setIsResending(true); + setError(''); + setInfo(''); + + try { + const response = await fetch('/api/users/auth/verification/request-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email(), flow: flow() }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + setError(payload?.message || payload?.error || 'Failed to resend verification code.'); + setIsResending(false); + return; + } + + const debugCode = String(payload?.data?.debugCode || '').trim(); + if (debugCode) { + window.localStorage.setItem( + DEV_VERIFICATION_CODE_KEY, + JSON.stringify({ email: email(), flow: flow(), code: debugCode, createdAt: Date.now() }), + ); + } + + setOtp(Array.from({ length: OTP_LENGTH }, () => '')); + setTimer(30); + setInfo(debugCode ? `A new code was generated. Local dev OTP: ${debugCode}` : 'A new verification code was sent.'); + otpRefs[0]?.focus(); + } catch { + setError('Failed to resend verification code.'); + } finally { + setIsResending(false); + } + }; + + return ( +
+
+ ); +} diff --git a/src/routes/companies/index.tsx b/src/routes/companies/index.tsx index bdcdeb5..b24fb5a 100644 --- a/src/routes/companies/index.tsx +++ b/src/routes/companies/index.tsx @@ -1,14 +1,5 @@ -import { A } from '@solidjs/router'; +import PublicLanding from '~/components/PublicLanding'; -export default function CompaniesLandingPage() { - return ( -
-
-

For Companies

-

Post jobs with verified company identity

-

Complete company onboarding and manage recruitment workflows with trust signals.

- Start Company Onboarding -
-
- ); +export default function CompaniesPage() { + return ; } diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index f770f10..23680e0 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -1,69 +1,259 @@ -import { createMemo, createSignal } from 'solid-js'; +import { A } from '@solidjs/router'; +import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import PublicHeader from '~/components/PublicHeader'; + +type FormValues = { + fullName: string; + email: string; + phone: string; + userType: string; + topic: string; + message: string; + attachment: File | null; +}; + +type FormErrors = Partial>; + +const initialValues: FormValues = { + fullName: '', + email: '', + phone: '', + userType: '', + topic: '', + message: '', + attachment: null, +}; + +const userTypes = [ + 'Customer (Hire professional)', + 'Company (Post job)', + 'Professional (Provide services)', + 'Job Seeker (Apply jobs)', +] as const; + +const topics = [ + 'Account & Login', + 'Verification', + 'Posting a Job', + 'Posting a Requirement', + 'Leads / Matching', + 'Payments / Credits', + 'Bug Report', + 'Other', +] as const; + +function IconMail() { + return ( + + ); +} + +function IconClock() { + return ( + + ); +} + +function IconPin() { + return ( + + ); +} export default function ContactPage() { - const [name, setName] = createSignal(''); - const [email, setEmail] = createSignal(''); - const [topic, setTopic] = createSignal(''); - const [message, setMessage] = createSignal(''); - const [sent, setSent] = createSignal(false); + const [values, setValues] = createSignal(initialValues); + const [errors, setErrors] = createSignal({}); + const [submitted, setSubmitted] = createSignal(false); + const [showBackToTop, setShowBackToTop] = createSignal(false); - const canSubmit = createMemo(() => name().trim() && email().trim() && topic().trim() && message().trim().length >= 20); + const validate = (v: FormValues): FormErrors => { + const next: FormErrors = {}; + if (!v.fullName.trim()) next.fullName = 'Full name is required.'; + if (!v.email.trim()) next.email = 'Email is required.'; + if (v.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) next.email = 'Enter a valid email.'; + if (!v.userType.trim()) next.userType = 'Please select user type.'; + if (!v.topic.trim()) next.topic = 'Please select a topic.'; + if (!v.message.trim()) next.message = 'Message is required.'; + if (v.message.trim() && v.message.trim().length < 20) next.message = 'Message must be at least 20 characters.'; + if (v.attachment) { + if (v.attachment.size > 10 * 1024 * 1024) next.attachment = 'Attachment must be 10MB or smaller.'; + const allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg']; + if (!allowed.includes(v.attachment.type)) next.attachment = 'Allowed formats: PDF, PNG, JPG.'; + } + return next; + }; + + const canSubmit = createMemo(() => Object.keys(validate(values())).length === 0); + + const update = (key: K, next: FormValues[K]) => { + setValues((prev) => ({ ...prev, [key]: next })); + }; + + onMount(() => { + const onScroll = () => setShowBackToTop(window.scrollY > 500); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + onCleanup(() => window.removeEventListener('scroll', onScroll)); + }); return ( -
-
-

Reach Out

-

Contact us

-

Tell us what you need — we’ll get back to you.

-
+
+