Finalize onboarding + role flow updates and public UI polish
This commit is contained in:
parent
9c10ef3bc6
commit
bd709b0120
25 changed files with 1058 additions and 256 deletions
328
src/app.css
328
src/app.css
|
|
@ -315,6 +315,12 @@ body {
|
|||
box-shadow: 0 18px 34px -24px rgba(2, 6, 23, 0.44);
|
||||
}
|
||||
|
||||
.contact-layout-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
grid-template-columns: 1.45fr 1fr;
|
||||
}
|
||||
|
||||
.contact-side-card {
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
|
|
@ -420,6 +426,12 @@ body {
|
|||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.contact-quick-clarity {
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 10px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.help-hero-panel {
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 24px 50px -36px rgba(2, 6, 23, 0.9);
|
||||
|
|
@ -447,14 +459,34 @@ body {
|
|||
}
|
||||
|
||||
.help-solid-section {
|
||||
background: #fff;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-article-list {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-article-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-article-headline h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.help-article-headline span {
|
||||
font-size: 13px;
|
||||
color: rgba(226, 232, 240, 0.82);
|
||||
}
|
||||
|
||||
.help-empty-card {
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(16, 11, 47, 0.2);
|
||||
|
|
@ -589,6 +621,10 @@ body {
|
|||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.contact-layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.help-search-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -1530,56 +1566,79 @@ body {
|
|||
}
|
||||
|
||||
.public-footer {
|
||||
position: relative;
|
||||
z-index: 12;
|
||||
border-top: 1px solid rgba(16, 11, 47, 0.1);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(24px);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.public-footer .footer-row {
|
||||
min-height: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
.public-footer-row {
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 18px;
|
||||
padding: 12px 0;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.public-footer .footer-row p {
|
||||
.public-footer-row p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.public-footer .footer-links {
|
||||
.public-footer-logo {
|
||||
height: 44px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.public-footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.public-footer .footer-links a {
|
||||
.public-footer-links a {
|
||||
color: #100b2f;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.public-footer .footer-links a:hover {
|
||||
.public-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 {
|
||||
.public-footer-links {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.public-footer-row {
|
||||
min-height: auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.public-footer-row p {
|
||||
flex: initial;
|
||||
}
|
||||
|
||||
.public-footer-logo {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ghost-dark {
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
color: #fff;
|
||||
|
|
@ -1738,13 +1797,14 @@ body {
|
|||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.lp-bg {
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.lp-dark-base {
|
||||
|
|
@ -2777,6 +2837,12 @@ body {
|
|||
position: relative;
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.auth-page > *:not(.lp-bg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.auth-layout {
|
||||
|
|
@ -3145,7 +3211,7 @@ body {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
.about-content .container {
|
||||
.about-content > section .container {
|
||||
width: 100%;
|
||||
max-width: 1240px;
|
||||
padding-left: 16px;
|
||||
|
|
@ -3223,10 +3289,10 @@ body {
|
|||
|
||||
.about-hero {
|
||||
position: relative;
|
||||
min-height: clamp(520px, 78vh, 760px);
|
||||
min-height: clamp(430px, 64vh, 620px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 0 8px;
|
||||
padding: 18px 0 0;
|
||||
}
|
||||
|
||||
.about-hero::before {
|
||||
|
|
@ -3244,7 +3310,7 @@ body {
|
|||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
|
|
@ -3262,7 +3328,7 @@ body {
|
|||
}
|
||||
|
||||
.about-title {
|
||||
margin: 12px 0 0;
|
||||
margin: 8px 0 0;
|
||||
font-size: clamp(38px, 7vw, 64px);
|
||||
line-height: 1.08;
|
||||
font-weight: 800;
|
||||
|
|
@ -3270,12 +3336,16 @@ body {
|
|||
}
|
||||
|
||||
.about-copy {
|
||||
margin-top: 18px;
|
||||
margin-top: 12px;
|
||||
max-width: 740px;
|
||||
font-size: 17px;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.about-hero .hero-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.about-manifesto-card {
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
|
|
@ -3283,7 +3353,7 @@ body {
|
|||
backdrop-filter: blur(16px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.about-manifesto-card h2 {
|
||||
|
|
@ -3293,11 +3363,11 @@ body {
|
|||
}
|
||||
|
||||
.about-manifesto-card ul {
|
||||
margin: 14px 0 0;
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.about-sheen-sweep {
|
||||
|
|
@ -3310,11 +3380,11 @@ body {
|
|||
}
|
||||
|
||||
.about-section-tight {
|
||||
padding: 22px 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.about-section-mid {
|
||||
padding: 40px 0;
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.about-section-title,
|
||||
|
|
@ -3373,16 +3443,16 @@ body {
|
|||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.about-problem-stage {
|
||||
position: relative;
|
||||
min-height: clamp(420px, 56vh, 540px);
|
||||
min-height: clamp(360px, 50vh, 480px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
padding: 16px 16px 10px;
|
||||
padding: 10px 14px 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
@ -3449,7 +3519,7 @@ body {
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 920px;
|
||||
margin-top: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: clamp(34px, 6.4vw, 72px);
|
||||
line-height: 1.06;
|
||||
font-weight: 800;
|
||||
|
|
@ -3461,10 +3531,10 @@ body {
|
|||
.about-problem-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 16px;
|
||||
margin-top: 10px;
|
||||
max-width: 780px;
|
||||
font-size: clamp(16px, 2vw, 24px);
|
||||
line-height: 1.6;
|
||||
line-height: 1.42;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
transition: opacity 320ms ease, filter 320ms ease;
|
||||
}
|
||||
|
|
@ -3485,13 +3555,13 @@ body {
|
|||
|
||||
.about-chapter-two-shell,
|
||||
.about-trust-shell {
|
||||
padding: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.about-chapter-two-grid {
|
||||
margin-top: 14px;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -3511,20 +3581,20 @@ body {
|
|||
}
|
||||
|
||||
.about-chapter-two-text p {
|
||||
margin-top: 16px;
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.about-chapter-two-body {
|
||||
margin-top: 20px;
|
||||
margin-top: 14px;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.about-trust-shell {
|
||||
padding: 28px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.about-chapter-two-panel {
|
||||
|
|
@ -3628,12 +3698,12 @@ body {
|
|||
}
|
||||
|
||||
.about-trust-sequence {
|
||||
margin-top: 20px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.about-trust-sequence-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.about-trust-sequence-row {
|
||||
|
|
@ -3696,13 +3766,23 @@ body {
|
|||
|
||||
.about-principles-section {
|
||||
min-height: 74vh;
|
||||
padding-top: 14px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 0;
|
||||
scroll-margin-top: 240px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.about-principles-section:target {
|
||||
scroll-margin-top: 240px;
|
||||
}
|
||||
|
||||
.about-principle-narrative-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: linear-gradient(145deg, rgba(16, 11, 47, 0.66), rgba(16, 11, 47, 0.46));
|
||||
box-shadow: 0 16px 28px -22px rgba(2, 6, 23, 0.58);
|
||||
}
|
||||
|
||||
.about-principle-narrative-section::before {
|
||||
|
|
@ -3711,15 +3791,15 @@ body {
|
|||
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));
|
||||
radial-gradient(55% 46% at 76% 42%, rgba(253, 98, 22, 0.2), transparent 72%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(16, 11, 47, 0.02));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.about-narrative-stage-root {
|
||||
margin-top: 18px;
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
min-height: clamp(280px, 44vh, 420px);
|
||||
min-height: clamp(220px, 36vh, 320px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -3728,8 +3808,8 @@ body {
|
|||
.about-narrative-viewport {
|
||||
position: relative;
|
||||
width: min(100%, 860px);
|
||||
min-height: 320px;
|
||||
padding: 12px 0;
|
||||
min-height: 250px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.about-narrative-glow {
|
||||
|
|
@ -3750,7 +3830,7 @@ body {
|
|||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.about-narrative-item-active,
|
||||
|
|
@ -3770,6 +3850,7 @@ body {
|
|||
}
|
||||
|
||||
.about-narrative-headline {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 66px);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
|
|
@ -3814,7 +3895,7 @@ body {
|
|||
|
||||
.about-review-line,
|
||||
.about-review-line-static {
|
||||
margin-top: 14px;
|
||||
margin-top: 8px;
|
||||
width: min(460px, 68vw);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(253, 98, 22, 0.8), rgba(255, 255, 255, 0.12));
|
||||
|
|
@ -3835,13 +3916,22 @@ body {
|
|||
}
|
||||
|
||||
.about-principles-subline {
|
||||
margin-top: 4px;
|
||||
margin: 2px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.about-principles-subline-inline {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.about-chapter-title {
|
||||
font-size: 36px;
|
||||
|
|
@ -3854,6 +3944,10 @@ body {
|
|||
.about-principles-subline {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.about-principles-subline-inline {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.about-timeline-section-tight .about-glass-light {
|
||||
|
|
@ -3879,9 +3973,9 @@ body {
|
|||
|
||||
.about-timeline-wrap {
|
||||
position: relative;
|
||||
margin-top: 18px;
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
|
|
@ -3946,7 +4040,7 @@ body {
|
|||
}
|
||||
|
||||
.about-closing-card {
|
||||
padding: 30px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -3957,7 +4051,7 @@ body {
|
|||
}
|
||||
|
||||
.about-closing-card .hero-actions {
|
||||
margin-top: 18px;
|
||||
margin-top: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
|
@ -4008,7 +4102,7 @@ body {
|
|||
}
|
||||
|
||||
.about-trust-shell {
|
||||
padding: 36px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.about-chapter-two-grid {
|
||||
|
|
@ -4022,7 +4116,7 @@ body {
|
|||
display: flex;
|
||||
}
|
||||
|
||||
.about-with-rail .container {
|
||||
.about-with-rail > section .container {
|
||||
padding-left: 128px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
|
@ -4034,7 +4128,7 @@ body {
|
|||
}
|
||||
|
||||
.about-narrative-stage-root {
|
||||
min-height: 74vh;
|
||||
min-height: 62vh;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4052,8 +4146,13 @@ body {
|
|||
|
||||
.about-principles-section {
|
||||
min-height: auto;
|
||||
padding-top: 6px;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 0;
|
||||
scroll-margin-top: 180px;
|
||||
}
|
||||
|
||||
.about-principles-section:target {
|
||||
scroll-margin-top: 180px;
|
||||
}
|
||||
|
||||
.about-narrative-stage-root {
|
||||
|
|
@ -4380,6 +4479,93 @@ body {
|
|||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* ── Guided Tour ── */
|
||||
.tour-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 0, 38, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.tour-modal {
|
||||
width: min(560px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 24px 80px -30px rgba(2, 6, 23, 0.5);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.tour-eyebrow {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #fd6216;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tour-modal h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #100b2f;
|
||||
}
|
||||
|
||||
.tour-modal p {
|
||||
margin: 10px 0 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tour-progress {
|
||||
margin-top: 16px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tour-progress span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #fd6216, #ff8a4d);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
.tour-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tour-modal {
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.tour-modal h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.tour-actions {
|
||||
justify-content: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tour-actions .btn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Choose Role Page ── */
|
||||
.choose-role-page {
|
||||
position: relative;
|
||||
|
|
@ -4390,6 +4576,12 @@ body {
|
|||
justify-content: flex-start;
|
||||
padding: 24px 16px 60px;
|
||||
color: #fff;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.choose-role-page > *:not(.lp-bg) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.choose-role-container {
|
||||
|
|
@ -4924,5 +5116,3 @@ body {
|
|||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
92
src/components/PublicBackground.tsx
Normal file
92
src/components/PublicBackground.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { For } from 'solid-js';
|
||||
|
||||
const chipNodes = [
|
||||
{ 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 (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
||||
<path d="M14 4a2 2 0 0 1 1.76 1.05l.49.9A2 2 0 0 0 18 7h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h2a2 2 0 0 0 1.76-1.05l.49-.9A2 2 0 0 1 10 4z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (props.kind === 'briefcase') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
||||
<path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
|
||||
<path d="M22 13a18 18 0 0 1-20 0" />
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (props.kind === 'bell') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
||||
<path d="M10.3 21a2 2 0 0 0 3.4 0" />
|
||||
<path d="M3.3 15.3A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.4 14 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.4 6-2.7 7.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (props.kind === 'sparkles') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
||||
<path d="M11 2.8a1 1 0 0 1 2 0l1 5.6a2 2 0 0 0 1.6 1.6l5.6 1a1 1 0 0 1 0 2l-5.6 1a2 2 0 0 0-1.6 1.6l-1 5.6a1 1 0 0 1-2 0l-1-5.6A2 2 0 0 0 8.4 14l-5.6-1a1 1 0 0 1 0-2l5.6-1A2 2 0 0 0 10 8.4z" />
|
||||
<path d="M20 2v4M22 4h-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
||||
<path d="m18 16 4-4-4-4" />
|
||||
<path d="m6 8-4 4 4 4" />
|
||||
<path d="m14.5 4-5 16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type PublicBackgroundProps = {
|
||||
scrollY?: number;
|
||||
reduceMotion?: boolean;
|
||||
meshFactor?: number;
|
||||
ribbonFactor?: number;
|
||||
chipsFactor?: number;
|
||||
meshCap?: number;
|
||||
ribbonCap?: number;
|
||||
chipsCap?: number;
|
||||
};
|
||||
|
||||
export default function PublicBackground(props: PublicBackgroundProps) {
|
||||
const y = () => (props.reduceMotion ? 0 : props.scrollY || 0);
|
||||
const meshFactor = () => props.meshFactor ?? 0.1;
|
||||
const ribbonFactor = () => props.ribbonFactor ?? 0.18;
|
||||
const chipsFactor = () => props.chipsFactor ?? 0.24;
|
||||
const meshCap = () => props.meshCap ?? 36;
|
||||
const ribbonCap = () => props.ribbonCap ?? 58;
|
||||
const chipsCap = () => props.chipsCap ?? 80;
|
||||
|
||||
return (
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" style={{ transform: `translate3d(0, ${Math.min(meshCap(), y() * meshFactor())}px, 0)` }} />
|
||||
<div class="lp-ribbon" style={{ transform: `translate3d(0, ${Math.min(ribbonCap(), y() * ribbonFactor())}px, 0)` }} />
|
||||
<div class="lp-noise" />
|
||||
<div class="lp-chips" style={{ transform: `translate3d(0, ${Math.min(chipsCap(), y() * chipsFactor())}px, 0)` }}>
|
||||
<For each={chipNodes}>
|
||||
{(chip) => (
|
||||
<span class={`lp-chip ${chip.cls}`} style={{ left: chip.left, top: chip.top, width: `${chip.size}px`, height: `${chip.size}px` }}>
|
||||
<ChipIcon kind={chip.kind} />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/components/PublicFooter.tsx
Normal file
17
src/components/PublicFooter.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { A } from '@solidjs/router';
|
||||
|
||||
export default function PublicFooter() {
|
||||
return (
|
||||
<footer class="public-footer">
|
||||
<div class="container public-footer-row">
|
||||
<img class="public-footer-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
|
||||
<p>© {new Date().getFullYear()} Nxtgauge. All rights reserved.</p>
|
||||
<div class="public-footer-links">
|
||||
<A href="/terms">Terms</A>
|
||||
<A href="/privacy">Privacy</A>
|
||||
<A href="/help-center">Help Center</A>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { A } from '@solidjs/router';
|
|||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
||||
import OpportunityGraph from '~/components/OpportunityGraph';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
type PathCard = {
|
||||
title: string;
|
||||
|
|
@ -723,17 +724,7 @@ export default function PublicLanding() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="public-footer">
|
||||
<div class="container footer-row">
|
||||
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
|
||||
<p>© {new Date().getFullYear()} Nxtgauge. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<A href="/terms">Terms</A>
|
||||
<A href="/privacy">Privacy</A>
|
||||
<A href="/help-center">Help Center</A>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<PublicFooter />
|
||||
<Show when={showBackToTop()}>
|
||||
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' })}>
|
||||
↑
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@ import { Component, Show, createEffect, For, createSignal } from 'solid-js';
|
|||
import { useNavigate, A } from '@solidjs/router';
|
||||
import { authState, logout, switchRole } from '~/lib/auth';
|
||||
import { shouldShowRoleSwitcher } from '~/lib/auth-flow';
|
||||
import {
|
||||
getRoleTourStorageKey,
|
||||
getWelcomeTourStorageKey,
|
||||
pickGuidedTour,
|
||||
readSeenRoleTours,
|
||||
WELCOME_TOUR_VALUE,
|
||||
writeSeenRoleTours,
|
||||
} from '~/lib/guided-tour';
|
||||
import type { GuidedTourKind } from '~/lib/guided-tour';
|
||||
import {
|
||||
type GuidedTourStep,
|
||||
resolveRoleApprovedTourSteps,
|
||||
resolveWelcomeTourSteps,
|
||||
} from '~/lib/guided-tour-content';
|
||||
|
||||
// ── Icons (inline SVGs for zero deps) ─────────────────────────────────────────
|
||||
|
||||
|
|
@ -88,6 +102,8 @@ const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Compon
|
|||
export default function DashboardLayout(props: { children: any }) {
|
||||
const navigate = useNavigate();
|
||||
const [switchingRole, setSwitchingRole] = createSignal(false);
|
||||
const [tourKind, setTourKind] = createSignal<GuidedTourKind | null>(null);
|
||||
const [tourStepIndex, setTourStepIndex] = createSignal(0);
|
||||
|
||||
createEffect(() => {
|
||||
const s = authState();
|
||||
|
|
@ -104,6 +120,13 @@ export default function DashboardLayout(props: { children: any }) {
|
|||
const rc = () => authState().runtime_config;
|
||||
const roleOptions = () => rc()?.user?.roles ?? [];
|
||||
const activeRole = () => rc()?.user?.active_role ?? rc()?.role ?? '';
|
||||
const activeTourSteps = (): GuidedTourStep[] => {
|
||||
const kind = tourKind();
|
||||
if (!kind) return [];
|
||||
if (kind === 'welcome') return resolveWelcomeTourSteps(rc()?.guided_tours ?? null);
|
||||
return resolveRoleApprovedTourSteps(activeRole(), rc()?.guided_tours ?? null);
|
||||
};
|
||||
const tourStep = () => activeTourSteps()[tourStepIndex()];
|
||||
|
||||
const navItems = () => {
|
||||
if (rc()?.role === 'USER') {
|
||||
|
|
@ -142,6 +165,58 @@ export default function DashboardLayout(props: { children: any }) {
|
|||
}
|
||||
}
|
||||
|
||||
function finishTour() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const userId = rc()?.user?.id;
|
||||
if (!userId || !tourKind()) {
|
||||
setTourKind(null);
|
||||
setTourStepIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tourKind() === 'welcome') {
|
||||
window.localStorage.setItem(getWelcomeTourStorageKey(userId), WELCOME_TOUR_VALUE);
|
||||
} else {
|
||||
const key = getRoleTourStorageKey(userId);
|
||||
const seen = readSeenRoleTours(window.localStorage.getItem(key));
|
||||
seen.add(activeRole());
|
||||
window.localStorage.setItem(key, writeSeenRoleTours(seen));
|
||||
}
|
||||
|
||||
setTourKind(null);
|
||||
setTourStepIndex(0);
|
||||
}
|
||||
|
||||
function nextTourStep() {
|
||||
const total = activeTourSteps().length;
|
||||
if (tourStepIndex() >= total - 1) {
|
||||
finishTour();
|
||||
return;
|
||||
}
|
||||
setTourStepIndex((current) => current + 1);
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const runtime = rc();
|
||||
const userId = runtime?.user?.id;
|
||||
if (!runtime || !userId) return;
|
||||
|
||||
const welcomeTourSeen = window.localStorage.getItem(getWelcomeTourStorageKey(userId)) === WELCOME_TOUR_VALUE;
|
||||
const seenRoleTours = readSeenRoleTours(window.localStorage.getItem(getRoleTourStorageKey(userId)));
|
||||
const nextTour = pickGuidedTour({
|
||||
userId,
|
||||
activeRole: runtime?.user?.active_role ?? runtime?.role,
|
||||
welcomeTourSeen,
|
||||
seenRoleTours,
|
||||
});
|
||||
|
||||
if (nextTour && nextTour !== tourKind()) {
|
||||
setTourKind(nextTour);
|
||||
setTourStepIndex(0);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="dashboard-shell">
|
||||
{/* ── Sidebar ── */}
|
||||
|
|
@ -214,6 +289,28 @@ export default function DashboardLayout(props: { children: any }) {
|
|||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Show when={tourKind() && tourStep()}>
|
||||
<div class="tour-overlay">
|
||||
<section class="tour-modal" role="dialog" aria-modal="true" aria-label="Guided tour">
|
||||
<p class="tour-eyebrow">Guided Tour</p>
|
||||
<h3>{tourStep()?.title}</h3>
|
||||
<p>{tourStep()?.body}</p>
|
||||
<div class="tour-progress">
|
||||
<span style={{ width: `${((tourStepIndex() + 1) / (activeTourSteps().length || 1)) * 100}%` }} />
|
||||
</div>
|
||||
<div class="tour-actions">
|
||||
<button class="btn" onClick={finishTour}>Skip for now</button>
|
||||
<Show when={tourStepIndex() > 0}>
|
||||
<button class="btn" onClick={() => setTourStepIndex((current) => current - 1)}>Back</button>
|
||||
</Show>
|
||||
<button class="btn primary" onClick={nextTourStep}>
|
||||
{tourStepIndex() >= activeTourSteps().length - 1 ? 'Finish' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
31
src/lib/auth-intent.test.ts
Normal file
31
src/lib/auth-intent.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { intentToOnboardingPath, normalizeIntent } from './auth-intent';
|
||||
|
||||
describe('normalizeIntent', () => {
|
||||
it('normalizes all supported intent aliases', () => {
|
||||
expect(normalizeIntent('customer')).toBe('customer');
|
||||
expect(normalizeIntent('professional')).toBe('professional');
|
||||
expect(normalizeIntent('pro')).toBe('professional');
|
||||
expect(normalizeIntent('company')).toBe('company');
|
||||
expect(normalizeIntent('employer')).toBe('company');
|
||||
expect(normalizeIntent('job_seeker')).toBe('job_seeker');
|
||||
expect(normalizeIntent('job-seeker')).toBe('job_seeker');
|
||||
expect(normalizeIntent('jobseeker')).toBe('job_seeker');
|
||||
});
|
||||
|
||||
it('returns null for unknown values', () => {
|
||||
expect(normalizeIntent('unknown')).toBeNull();
|
||||
expect(normalizeIntent('')).toBeNull();
|
||||
expect(normalizeIntent(null)).toBeNull();
|
||||
expect(normalizeIntent(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('intentToOnboardingPath', () => {
|
||||
it('maps each intent to expected onboarding path', () => {
|
||||
expect(intentToOnboardingPath('company')).toBe('/users/onboarding/company');
|
||||
expect(intentToOnboardingPath('job_seeker')).toBe('/users/onboarding/job-seeker');
|
||||
expect(intentToOnboardingPath('professional')).toBe('/users/onboarding/professional');
|
||||
expect(intentToOnboardingPath('customer')).toBe('/users/onboarding/customer');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,11 @@ export interface RuntimeConfig {
|
|||
role: string;
|
||||
onboarding_required: boolean;
|
||||
onboarding_status?: string;
|
||||
guided_tours?: {
|
||||
welcome?: Array<{ title: string; body: string }>;
|
||||
role_approved_default?: Array<{ title: string; body: string }>;
|
||||
roles?: Record<string, Array<{ title: string; body: string }>>;
|
||||
};
|
||||
enabled_modules: string[];
|
||||
feature_flags: Record<string, boolean>;
|
||||
permissions: Record<string, boolean>;
|
||||
|
|
|
|||
50
src/lib/guided-tour-content.test.ts
Normal file
50
src/lib/guided-tour-content.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveRoleApprovedTourSteps, resolveWelcomeTourSteps } from './guided-tour-content';
|
||||
|
||||
describe('resolveWelcomeTourSteps', () => {
|
||||
it('uses runtime-config welcome steps when provided', () => {
|
||||
const steps = resolveWelcomeTourSteps({
|
||||
welcome: [
|
||||
{ title: 'A', body: 'B' },
|
||||
{ title: 'C', body: 'D' },
|
||||
],
|
||||
});
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].title).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to default steps when runtime data is missing', () => {
|
||||
const steps = resolveWelcomeTourSteps();
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRoleApprovedTourSteps', () => {
|
||||
it('returns role-specific defaults for primary roles', () => {
|
||||
expect(resolveRoleApprovedTourSteps('COMPANY').length).toBeGreaterThan(0);
|
||||
expect(resolveRoleApprovedTourSteps('CUSTOMER').length).toBeGreaterThan(0);
|
||||
expect(resolveRoleApprovedTourSteps('JOB_SEEKER').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns professional defaults for non-primary roles', () => {
|
||||
const steps = resolveRoleApprovedTourSteps('PHOTOGRAPHER');
|
||||
expect(steps.length).toBeGreaterThan(0);
|
||||
expect(steps[0].title.toLowerCase()).toContain('photographer');
|
||||
});
|
||||
|
||||
it('uses runtime role override when present', () => {
|
||||
const steps = resolveRoleApprovedTourSteps('TUTOR', {
|
||||
roles: {
|
||||
TUTOR: [{ title: 'Tutor Custom', body: 'Custom flow' }],
|
||||
},
|
||||
});
|
||||
expect(steps).toEqual([{ title: 'Tutor Custom', body: 'Custom flow' }]);
|
||||
});
|
||||
|
||||
it('uses runtime role approved default when specific role override is absent', () => {
|
||||
const steps = resolveRoleApprovedTourSteps('MAKEUP_ARTIST', {
|
||||
role_approved_default: [{ title: 'Default Custom', body: 'Default flow' }],
|
||||
});
|
||||
expect(steps).toEqual([{ title: 'Default Custom', body: 'Default flow' }]);
|
||||
});
|
||||
});
|
||||
132
src/lib/guided-tour-content.ts
Normal file
132
src/lib/guided-tour-content.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { normalizeRoleForTour } from './guided-tour';
|
||||
|
||||
export interface GuidedTourStep {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface RuntimeGuidedTours {
|
||||
welcome?: GuidedTourStep[];
|
||||
role_approved_default?: GuidedTourStep[];
|
||||
roles?: Record<string, GuidedTourStep[]>;
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME_STEPS: GuidedTourStep[] = [
|
||||
{
|
||||
title: 'Welcome to your Nxtgauge dashboard',
|
||||
body: 'This is your home base. You can explore options and start your first onboarding flow from here.',
|
||||
},
|
||||
{
|
||||
title: 'Choose what you want to do',
|
||||
body: 'Use "Choose What You Need" or "Explore Nxtgauge" to select a path that matches your goal.',
|
||||
},
|
||||
{
|
||||
title: 'Track your progress',
|
||||
body: 'You can always return to check status updates, notifications, and your profile in one place.',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_COMPANY_APPROVED_STEPS: GuidedTourStep[] = [
|
||||
{
|
||||
title: 'Your company profile is approved',
|
||||
body: 'You can now create job posts and manage hiring activity from your company dashboard.',
|
||||
},
|
||||
{
|
||||
title: 'Start with job postings',
|
||||
body: 'Go to Jobs to create or update postings and send them for admin approval when required.',
|
||||
},
|
||||
{
|
||||
title: 'Review incoming applications',
|
||||
body: 'Use Applications to shortlist, reject, and progress candidates through your hiring flow.',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CUSTOMER_APPROVED_STEPS: GuidedTourStep[] = [
|
||||
{
|
||||
title: 'Your customer profile is approved',
|
||||
body: 'You now have access to requirement workflows and can connect with verified professionals.',
|
||||
},
|
||||
{
|
||||
title: 'Share your requirements',
|
||||
body: 'Create requirements with your details, timeline, and budget to receive relevant responses.',
|
||||
},
|
||||
{
|
||||
title: 'Track and manage responses',
|
||||
body: 'Use marketplace and request views to compare professionals and move forward confidently.',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_JOB_SEEKER_APPROVED_STEPS: GuidedTourStep[] = [
|
||||
{
|
||||
title: 'Your job seeker profile is approved',
|
||||
body: 'You can now browse jobs and apply directly from your dashboard.',
|
||||
},
|
||||
{
|
||||
title: 'Find opportunities faster',
|
||||
body: 'Use job filters and details pages to focus on roles that match your goals.',
|
||||
},
|
||||
{
|
||||
title: 'Track every application',
|
||||
body: 'Check your applications panel for shortlist, interview, and offer updates.',
|
||||
},
|
||||
];
|
||||
|
||||
function isValidStepArray(value: unknown): value is GuidedTourStep[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(step) =>
|
||||
step &&
|
||||
typeof step === 'object' &&
|
||||
typeof (step as GuidedTourStep).title === 'string' &&
|
||||
typeof (step as GuidedTourStep).body === 'string',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function prettyRoleName(role: string): string {
|
||||
const normalized = normalizeRoleForTour(role);
|
||||
if (!normalized) return 'professional';
|
||||
return normalized.toLowerCase().replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function defaultProfessionalApprovedSteps(role: string): GuidedTourStep[] {
|
||||
const roleName = prettyRoleName(role);
|
||||
return [
|
||||
{
|
||||
title: `Your ${roleName} profile is approved`,
|
||||
body: 'Your role-specific dashboard is now unlocked with modules tailored to your work.',
|
||||
},
|
||||
{
|
||||
title: 'Complete your public profile',
|
||||
body: 'Update portfolio, services, and profile sections so customers and companies can trust your expertise.',
|
||||
},
|
||||
{
|
||||
title: 'Respond and grow',
|
||||
body: 'Track incoming leads or opportunities, and use your dashboard tools to convert them into outcomes.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveWelcomeTourSteps(runtimeTours?: RuntimeGuidedTours | null): GuidedTourStep[] {
|
||||
if (isValidStepArray(runtimeTours?.welcome)) return runtimeTours!.welcome;
|
||||
return DEFAULT_WELCOME_STEPS;
|
||||
}
|
||||
|
||||
export function resolveRoleApprovedTourSteps(
|
||||
role: string | null | undefined,
|
||||
runtimeTours?: RuntimeGuidedTours | null,
|
||||
): GuidedTourStep[] {
|
||||
const normalizedRole = normalizeRoleForTour(role);
|
||||
if (!normalizedRole || normalizedRole === 'USER') return [];
|
||||
|
||||
const roleOverride = runtimeTours?.roles?.[normalizedRole];
|
||||
if (isValidStepArray(roleOverride)) return roleOverride;
|
||||
if (isValidStepArray(runtimeTours?.role_approved_default)) return runtimeTours!.role_approved_default;
|
||||
|
||||
if (normalizedRole === 'COMPANY') return DEFAULT_COMPANY_APPROVED_STEPS;
|
||||
if (normalizedRole === 'CUSTOMER') return DEFAULT_CUSTOMER_APPROVED_STEPS;
|
||||
if (normalizedRole === 'JOB_SEEKER') return DEFAULT_JOB_SEEKER_APPROVED_STEPS;
|
||||
return defaultProfessionalApprovedSteps(normalizedRole);
|
||||
}
|
||||
67
src/lib/guided-tour.test.ts
Normal file
67
src/lib/guided-tour.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRoleTourStorageKey,
|
||||
getWelcomeTourStorageKey,
|
||||
normalizeRoleForTour,
|
||||
pickGuidedTour,
|
||||
readSeenRoleTours,
|
||||
writeSeenRoleTours,
|
||||
} from './guided-tour';
|
||||
|
||||
describe('guided-tour storage keys', () => {
|
||||
it('creates stable keys by user id', () => {
|
||||
expect(getWelcomeTourStorageKey('u1')).toBe('nxtgauge_tour_welcome_seen_u1');
|
||||
expect(getRoleTourStorageKey('u1')).toBe('nxtgauge_tour_roles_seen_u1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRoleForTour', () => {
|
||||
it('normalizes spacing and case', () => {
|
||||
expect(normalizeRoleForTour(' job seeker ')).toBe('JOB_SEEKER');
|
||||
expect(normalizeRoleForTour('make-up artist')).toBe('MAKE_UP_ARTIST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('role tour serialization', () => {
|
||||
it('reads and writes role sets without duplicates', () => {
|
||||
const serialized = writeSeenRoleTours(['customer', 'CUSTOMER', 'job-seeker']);
|
||||
expect(serialized).toBe('CUSTOMER,JOB_SEEKER');
|
||||
|
||||
const set = readSeenRoleTours(serialized);
|
||||
expect(set.has('CUSTOMER')).toBe(true);
|
||||
expect(set.has('JOB_SEEKER')).toBe(true);
|
||||
expect(set.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickGuidedTour', () => {
|
||||
it('shows welcome tour first for known user', () => {
|
||||
const next = pickGuidedTour({
|
||||
userId: 'u1',
|
||||
activeRole: 'USER',
|
||||
welcomeTourSeen: false,
|
||||
seenRoleTours: new Set(),
|
||||
});
|
||||
expect(next).toBe('welcome');
|
||||
});
|
||||
|
||||
it('shows role-approved tour once when active role is non-user', () => {
|
||||
const next = pickGuidedTour({
|
||||
userId: 'u1',
|
||||
activeRole: 'photographer',
|
||||
welcomeTourSeen: true,
|
||||
seenRoleTours: new Set(['CUSTOMER']),
|
||||
});
|
||||
expect(next).toBe('role-approved');
|
||||
});
|
||||
|
||||
it('does not show role-approved tour for already seen roles', () => {
|
||||
const next = pickGuidedTour({
|
||||
userId: 'u1',
|
||||
activeRole: 'TUTOR',
|
||||
welcomeTourSeen: true,
|
||||
seenRoleTours: new Set(['TUTOR']),
|
||||
});
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
});
|
||||
50
src/lib/guided-tour.ts
Normal file
50
src/lib/guided-tour.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export type GuidedTourKind = 'welcome' | 'role-approved';
|
||||
|
||||
export const WELCOME_TOUR_VALUE = '1';
|
||||
|
||||
export function normalizeRoleForTour(role: string | null | undefined): string {
|
||||
return String(role ?? '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]+/g, '_');
|
||||
}
|
||||
|
||||
export function getWelcomeTourStorageKey(userId: string): string {
|
||||
return `nxtgauge_tour_welcome_seen_${userId}`;
|
||||
}
|
||||
|
||||
export function getRoleTourStorageKey(userId: string): string {
|
||||
return `nxtgauge_tour_roles_seen_${userId}`;
|
||||
}
|
||||
|
||||
export function readSeenRoleTours(raw: string | null | undefined): Set<string> {
|
||||
if (!raw) return new Set();
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((item) => normalizeRoleForTour(item))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSeenRoleTours(roles: Iterable<string>): string {
|
||||
const normalized = Array.from(roles)
|
||||
.map((role) => normalizeRoleForTour(role))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized)).join(',');
|
||||
}
|
||||
|
||||
export function pickGuidedTour(params: {
|
||||
userId: string | null | undefined;
|
||||
activeRole: string | null | undefined;
|
||||
welcomeTourSeen: boolean;
|
||||
seenRoleTours: Set<string>;
|
||||
}): GuidedTourKind | null {
|
||||
if (!params.userId) return null;
|
||||
if (!params.welcomeTourSeen) return 'welcome';
|
||||
|
||||
const activeRole = normalizeRoleForTour(params.activeRole);
|
||||
if (!activeRole || activeRole === 'USER') return null;
|
||||
if (params.seenRoleTours.has(activeRole)) return null;
|
||||
return 'role-approved';
|
||||
}
|
||||
55
src/lib/onboarding-flow.test.ts
Normal file
55
src/lib/onboarding-flow.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from './onboarding-flow';
|
||||
|
||||
describe('normalizeRoleKey', () => {
|
||||
it('normalizes case and separators', () => {
|
||||
expect(normalizeRoleKey('job seeker')).toBe('JOB_SEEKER');
|
||||
expect(normalizeRoleKey('job-seeker')).toBe('JOB_SEEKER');
|
||||
expect(normalizeRoleKey(' photographer ')).toBe('PHOTOGRAPHER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemaIdFromInput', () => {
|
||||
it('maps major role schemas', () => {
|
||||
expect(schemaIdFromInput('CUSTOMER', '')).toBe('customer_onboarding_v1');
|
||||
expect(schemaIdFromInput('COMPANY', '')).toBe('company_onboarding_v1');
|
||||
expect(schemaIdFromInput('JOB_SEEKER', '')).toBe('jobseeker_onboarding_v1');
|
||||
expect(schemaIdFromInput('jobseeker', '')).toBe('jobseeker_onboarding_v1');
|
||||
});
|
||||
|
||||
it('maps professional schema with profession key', () => {
|
||||
expect(schemaIdFromInput('PROFESSIONAL', 'Photographer')).toBe('photographer_onboarding_v1');
|
||||
expect(schemaIdFromInput('professional', 'Social Media Manager')).toBe('social_media_manager_onboarding_v1');
|
||||
expect(schemaIdFromInput('PROFESSIONAL', 'Fitness-Trainer')).toBe('fitness_trainer_onboarding_v1');
|
||||
});
|
||||
|
||||
it('falls back for generic professional when no profession', () => {
|
||||
expect(schemaIdFromInput('PROFESSIONAL', '')).toBe('professional_onboarding_v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateVisibility', () => {
|
||||
it('supports equals conditions', () => {
|
||||
const visible = evaluateVisibility(
|
||||
[{ field: 'profession', equals: 'photographer' }],
|
||||
{ profession: 'photographer' },
|
||||
);
|
||||
expect(visible).toBe(true);
|
||||
});
|
||||
|
||||
it('supports in conditions', () => {
|
||||
const visible = evaluateVisibility(
|
||||
[{ field: 'service_mode', in: ['remote', 'hybrid'] }],
|
||||
{ service_mode: 'hybrid' },
|
||||
);
|
||||
expect(visible).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when condition mismatches', () => {
|
||||
const visible = evaluateVisibility(
|
||||
[{ field: 'profession', equals: 'developer' }],
|
||||
{ profession: 'tutor' },
|
||||
);
|
||||
expect(visible).toBe(false);
|
||||
});
|
||||
});
|
||||
37
src/lib/onboarding-flow.ts
Normal file
37
src/lib/onboarding-flow.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { RuntimeVisibilityCondition } from '~/lib/runtime/types';
|
||||
|
||||
export function evaluateVisibility(
|
||||
conditions: RuntimeVisibilityCondition[] | undefined,
|
||||
values: Record<string, unknown>,
|
||||
) {
|
||||
if (!conditions || conditions.length === 0) return true;
|
||||
return conditions.every((condition) => {
|
||||
const value = values[condition.field];
|
||||
if (typeof condition.equals === 'string') return String(value || '') === condition.equals;
|
||||
if (Array.isArray(condition.in)) return condition.in.includes(String(value || ''));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRoleKey(value: string | null | undefined) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[-\s]+/g, '_');
|
||||
}
|
||||
|
||||
export function schemaIdFromInput(roleKey: string, profession: string) {
|
||||
const normalizedRole = normalizeRoleKey(roleKey);
|
||||
const normalizedProfession = String(profession || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[-\s]+/g, '_')
|
||||
.replace(/[^a-z_]/g, '');
|
||||
|
||||
if (normalizedRole === 'CUSTOMER') return 'customer_onboarding_v1';
|
||||
if (normalizedRole === 'COMPANY') return 'company_onboarding_v1';
|
||||
if (normalizedRole === 'JOB_SEEKER' || normalizedRole === 'JOBSEEKER') return 'jobseeker_onboarding_v1';
|
||||
if (normalizedRole === 'PROFESSIONAL' && normalizedProfession) return `${normalizedProfession}_onboarding_v1`;
|
||||
if (normalizedRole === 'PROFESSIONAL') return 'professional_onboarding_v1';
|
||||
return '';
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicFooter from '~/components/PublicFooter';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
|
||||
const chapters = [
|
||||
|
|
@ -176,44 +178,46 @@ export default function AboutPage() {
|
|||
});
|
||||
const stateTwoUnderline = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.26, 0.46));
|
||||
const stateThreeLine = createMemo(() => progressBetween(effectivePrincipleProgress(), 0.52, 0.74));
|
||||
|
||||
// Calculate brightness for each narrative item as it passes through center
|
||||
const getNarrativeItemOpacity = (stageIdx: number) => {
|
||||
const chapterFourMotion = (idx: number) => {
|
||||
const p = effectivePrincipleProgress();
|
||||
const stageStart = stageIdx * 0.25;
|
||||
const stageEnd = (stageIdx + 1) * 0.25;
|
||||
const center = (idx + 0.5) / chapterFourNarrative.length;
|
||||
const preWindow = 0.14;
|
||||
|
||||
// Fade in/out boundaries (slight buffer before/after stage)
|
||||
const fadeInStart = Math.max(0, stageStart - 0.05);
|
||||
const fadeOutEnd = Math.min(1, stageEnd + 0.05);
|
||||
|
||||
// Outside the visible range = dim
|
||||
if (p < fadeInStart || p > fadeOutEnd) {
|
||||
return 0.35;
|
||||
// Once a line has crossed center, keep it bright and glowing.
|
||||
if (p >= center) {
|
||||
return {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
glow: 52,
|
||||
};
|
||||
}
|
||||
|
||||
// Fade in from dimmed to bright
|
||||
if (p < stageStart) {
|
||||
return 0.35 + (1 - 0.35) * ((p - fadeInStart) / (stageStart - fadeInStart));
|
||||
}
|
||||
// Before center: approach from dim to bright, but no glow yet.
|
||||
const distanceToCenter = Math.max(0, center - p);
|
||||
const t = Math.max(0, Math.min(1, 1 - distanceToCenter / preWindow));
|
||||
|
||||
// In the center stage = fully bright
|
||||
if (p <= stageEnd) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fade out from bright to dimmed
|
||||
return 1 - ((p - stageEnd) / (fadeOutEnd - stageEnd)) * (1 - 0.35);
|
||||
return {
|
||||
opacity: 0.46 + t * 0.54,
|
||||
y: 0,
|
||||
glow: 0,
|
||||
};
|
||||
};
|
||||
const chapterFourBackdropGlow = createMemo(() => {
|
||||
// Keep chapter backdrop glow once the first narrative line reaches center.
|
||||
const firstCenter = 0.5 / chapterFourNarrative.length;
|
||||
return effectivePrincipleProgress() >= firstCenter ? 1 : 0;
|
||||
});
|
||||
const scrollToChapter = (chapterId: string) => {
|
||||
const target = document.getElementById(chapterId);
|
||||
if (!target) return;
|
||||
const offset = window.innerWidth >= 1280 ? 180 : 150;
|
||||
const top = target.getBoundingClientRect().top + window.scrollY - offset;
|
||||
window.scrollTo({ top: Math.max(0, top), behavior: reduceMotion() ? 'auto' : 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<main class="lp-main about-page-root">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(40, scrollY() * 0.12)}px, 0)` }} />
|
||||
<div class="lp-ribbon" style={{ transform: `translate3d(0, ${reduceMotion() ? 0 : Math.min(55, scrollY() * 0.22)}px, 0)` }} />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground scrollY={scrollY()} reduceMotion={reduceMotion()} meshFactor={0.12} ribbonFactor={0.22} meshCap={40} ribbonCap={55} />
|
||||
|
||||
<div class="lp-content about-content about-with-rail">
|
||||
<PublicHeader />
|
||||
|
|
@ -226,7 +230,15 @@ export default function AboutPage() {
|
|||
<For each={chapters}>
|
||||
{(chapter, idx) => (
|
||||
<li class={idx() === activeChapter() ? 'about-chapter-item-active' : 'about-chapter-item'}>
|
||||
<a href={`#${chapter.id}`}>{chapter.title}</a>
|
||||
<a
|
||||
href={`#${chapter.id}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
scrollToChapter(chapter.id);
|
||||
}}
|
||||
>
|
||||
{chapter.title}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -458,7 +470,7 @@ export default function AboutPage() {
|
|||
class="about-section-tight about-principles-section"
|
||||
>
|
||||
<div class="container">
|
||||
<article class={`about-principle-narrative-section about-reveal-init ${principlesVisible() ? 'about-reveal-show' : ''}`}>
|
||||
<article class={`about-glass-dark about-trust-shell about-principle-narrative-section about-reveal-init ${principlesVisible() ? 'about-reveal-show' : ''}`}>
|
||||
<p class="about-chapter-label about-chapter-label-light">Chapter 04</p>
|
||||
<h2 class="about-chapter-title about-chapter-title-dark">Principles</h2>
|
||||
<div class="about-narrative-stage-root">
|
||||
|
|
@ -472,8 +484,10 @@ export default function AboutPage() {
|
|||
<span class="about-filter-underline-static" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="about-narrative-headline about-narrative-item-active">A review layer.</p>
|
||||
<p class="about-principles-subline">Profiles. Jobs. Requirements.</p>
|
||||
<p class="about-narrative-headline about-narrative-item-active">
|
||||
A review layer.
|
||||
<span class="about-principles-subline-inline">Profiles. Jobs. Requirements.</span>
|
||||
</p>
|
||||
<span class="about-review-line-static" />
|
||||
</div>
|
||||
<p class="about-narrative-headline about-narrative-item-active">Clarity replaces noise.</p>
|
||||
|
|
@ -484,7 +498,7 @@ export default function AboutPage() {
|
|||
<span
|
||||
class="about-narrative-glow"
|
||||
style={{
|
||||
opacity: 0.12 + effectivePrincipleProgress() * 0.54,
|
||||
opacity: chapterFourBackdropGlow() * 0.78,
|
||||
}}
|
||||
/>
|
||||
<div class="about-narrative-stack">
|
||||
|
|
@ -493,8 +507,11 @@ export default function AboutPage() {
|
|||
<div
|
||||
class={principleStage() === idx() ? 'about-narrative-item-active' : 'about-narrative-item-inactive'}
|
||||
style={{
|
||||
opacity: getNarrativeItemOpacity(idx()),
|
||||
'text-shadow': `0 0 ${Math.max(0, (getNarrativeItemOpacity(idx()) - 0.35) * 8)}px rgba(253, 98, 22, 0.3)`,
|
||||
opacity: chapterFourMotion(idx()).opacity,
|
||||
transform: `translate3d(0, ${chapterFourMotion(idx()).y}px, 0)`,
|
||||
'text-shadow': chapterFourMotion(idx()).glow > 0
|
||||
? `0 0 ${chapterFourMotion(idx()).glow}px rgba(253, 98, 22, 0.34)`
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<p class="about-narrative-headline">
|
||||
|
|
@ -511,7 +528,7 @@ export default function AboutPage() {
|
|||
</Show>
|
||||
<Show when={idx() === 2}>
|
||||
<>
|
||||
<p class="about-principles-subline">Profiles. Jobs. Requirements.</p>
|
||||
<span class="about-principles-subline-inline">Profiles. Jobs. Requirements.</span>
|
||||
<span class="about-review-line" style={{ transform: `scaleX(${stateThreeLine()})` }} />
|
||||
</>
|
||||
</Show>
|
||||
|
|
@ -571,6 +588,8 @@ export default function AboutPage() {
|
|||
↑
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { A, useSearchParams } from '@solidjs/router';
|
||||
import { createMemo, createSignal } from 'solid-js';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
|
||||
function getPasswordChecks(password: string, confirmPassword: string) {
|
||||
return {
|
||||
|
|
@ -110,12 +111,7 @@ export default function ForgotPasswordPage() {
|
|||
|
||||
return (
|
||||
<main class="auth-page">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground />
|
||||
|
||||
<div class="auth-layout auth-layout-single">
|
||||
<section class="auth-form card glass-light">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
|||
import { createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent';
|
||||
import { isValidEmail, isValidCaptcha } from '~/lib/form-validation';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import CaptchaCanvas from '~/components/CaptchaCanvas';
|
||||
import { resolvePostLoginTarget } from '~/lib/auth-flow';
|
||||
|
|
@ -162,12 +163,7 @@ export default function LoginPage() {
|
|||
|
||||
return (
|
||||
<main class="auth-page">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground />
|
||||
|
||||
<PublicHeader signupHref={signUpHref()} />
|
||||
|
||||
|
|
@ -183,9 +179,7 @@ export default function LoginPage() {
|
|||
</section>
|
||||
|
||||
<section class="auth-form card glass-light">
|
||||
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
|
||||
<h2 class="title">Sign In</h2>
|
||||
<p class="subtitle">Use your external account credentials to continue.</p>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">EMAIL</label>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
||||
import { createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { intentToOnboardingPath, normalizeIntent, saveCanonicalIntent } from '~/lib/auth-intent';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import CaptchaCanvas from '~/components/CaptchaCanvas';
|
||||
import { isValidEmail, isValidName, checkPasswordStrength, isPasswordStrong, isValidCaptcha } from '~/lib/form-validation';
|
||||
|
|
@ -302,12 +303,7 @@ export default function RegisterPage() {
|
|||
|
||||
return (
|
||||
<main class="auth-page">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground />
|
||||
|
||||
<PublicHeader loginHref={loginHref()} />
|
||||
|
||||
|
|
@ -323,7 +319,6 @@ export default function RegisterPage() {
|
|||
</section>
|
||||
|
||||
<section class="auth-form card glass-light">
|
||||
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
|
||||
<h2 class="title">Create Your Account</h2>
|
||||
|
||||
<div class="grid" style={{ 'grid-template-columns': '1fr 1fr', margin: 0 }}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { A, useNavigate, useSearchParams } from '@solidjs/router';
|
|||
import { createMemo, createSignal, For, onMount } from 'solid-js';
|
||||
import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent';
|
||||
import { setTokens } from '~/lib/http';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1';
|
||||
|
|
@ -231,12 +232,7 @@ export default function VerificationPage() {
|
|||
|
||||
return (
|
||||
<main class="auth-page">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground />
|
||||
|
||||
<div class="auth-layout auth-layout-single">
|
||||
<section class="auth-form card glass-light">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { A } from '@solidjs/router';
|
||||
import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
type FormValues = {
|
||||
fullName: string;
|
||||
|
|
@ -74,6 +76,7 @@ export default function ContactPage() {
|
|||
const [errors, setErrors] = createSignal<FormErrors>({});
|
||||
const [submitted, setSubmitted] = createSignal(false);
|
||||
const [showBackToTop, setShowBackToTop] = createSignal(false);
|
||||
const [scrollY, setScrollY] = createSignal(0);
|
||||
|
||||
const validate = (v: FormValues): FormErrors => {
|
||||
const next: FormErrors = {};
|
||||
|
|
@ -99,7 +102,10 @@ export default function ContactPage() {
|
|||
};
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => setShowBackToTop(window.scrollY > 500);
|
||||
const onScroll = () => {
|
||||
setShowBackToTop(window.scrollY > 500);
|
||||
setScrollY(window.scrollY || 0);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onCleanup(() => window.removeEventListener('scroll', onScroll));
|
||||
|
|
@ -107,17 +113,12 @@ export default function ContactPage() {
|
|||
|
||||
return (
|
||||
<main class="lp-main">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground scrollY={scrollY()} />
|
||||
|
||||
<div class="lp-content">
|
||||
<PublicHeader />
|
||||
|
||||
<section class="public-section scene-dark">
|
||||
<section class="public-section help-section-lg">
|
||||
<div class="container panel panel-dark contact-hero-panel">
|
||||
<p class="eyebrow">Reach out</p>
|
||||
<h1 class="lp-hero-title">Contact us</h1>
|
||||
|
|
@ -131,9 +132,9 @@ export default function ContactPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="public-section scene-light">
|
||||
<section class="public-section help-section-mid">
|
||||
<div class="container">
|
||||
<div class="flow-card" style={{ 'grid-template-columns': '1.45fr 1fr' }}>
|
||||
<div class="contact-layout-grid">
|
||||
<form
|
||||
class="card glass-light contact-form-card"
|
||||
onSubmit={(event) => {
|
||||
|
|
@ -211,8 +212,8 @@ export default function ContactPage() {
|
|||
</label>
|
||||
|
||||
<div class="hero-actions">
|
||||
<button class="btn primary" type="submit" disabled={!canSubmit()}>Send message</button>
|
||||
<button class="btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button>
|
||||
<button class="lp-primary-btn" type="submit" disabled={!canSubmit()}>Send message</button>
|
||||
<button class="lp-ghost-btn" type="button" onClick={() => { setValues(initialValues); setErrors({}); }}>Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -222,18 +223,18 @@ export default function ContactPage() {
|
|||
<p class="sub contact-detail"><span class="contact-icon"><IconClock /></span>Typically within 24–48 hours</p>
|
||||
<p class="sub contact-detail"><span class="contact-icon"><IconPin /></span>Remote-first, India</p>
|
||||
<div class="hero-actions">
|
||||
<A class="btn" href="/about">About Us</A>
|
||||
<A class="btn" href="/#faqs">FAQs</A>
|
||||
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/about">About Us</A>
|
||||
<A class="lp-ghost-btn lp-ghost-btn-dark" href="/#faqs">FAQs</A>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="public-section scene-light">
|
||||
<section class="public-section help-section-lg">
|
||||
<div class="container">
|
||||
<h2 class="center">Common Questions</h2>
|
||||
<p class="center sub">Quick clarity before you raise a ticket.</p>
|
||||
<p class="center sub contact-quick-clarity">Quick clarity before you raise a query.</p>
|
||||
<div class="contact-mini-faq-grid">
|
||||
<article class="contact-mini-faq-card"><h3>Approval time</h3><p>Most profile and listing approvals are completed in 24–48 hours.</p></article>
|
||||
<article class="contact-mini-faq-card"><h3>Verification</h3><p>Verification is required to reduce spam and improve trust.</p></article>
|
||||
|
|
@ -248,6 +249,8 @@ export default function ContactPage() {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<PublicFooter />
|
||||
|
||||
<Show when={showBackToTop()}>
|
||||
<button class="back-top" onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}>
|
||||
↑
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { A, useParams } from '@solidjs/router';
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { getArticleBySlug } from '~/lib/help-center';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
function categoryTitle(input: string) {
|
||||
return input
|
||||
|
|
@ -13,19 +16,22 @@ function categoryTitle(input: string) {
|
|||
export default function HelpCenterArticlePage() {
|
||||
const params = useParams();
|
||||
const article = getArticleBySlug(params.slug || '');
|
||||
const [scrollY, setScrollY] = createSignal(0);
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => setScrollY(window.scrollY || 0);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onCleanup(() => window.removeEventListener('scroll', onScroll));
|
||||
});
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<main class="lp-main">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground scrollY={scrollY()} />
|
||||
<div class="lp-content">
|
||||
<PublicHeader />
|
||||
<section class="public-section scene-light">
|
||||
<section class="public-section scene-dark">
|
||||
<div class="container panel panel-light">
|
||||
<h1 class="title">Article not found</h1>
|
||||
<p class="subtitle">The requested Help Center article is unavailable.</p>
|
||||
|
|
@ -34,6 +40,7 @@ export default function HelpCenterArticlePage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
@ -41,15 +48,10 @@ export default function HelpCenterArticlePage() {
|
|||
|
||||
return (
|
||||
<main class="lp-main">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground scrollY={scrollY()} />
|
||||
<div class="lp-content">
|
||||
<PublicHeader />
|
||||
<section class="public-section scene-light">
|
||||
<section class="public-section scene-dark">
|
||||
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
|
||||
<p class="eyebrow">{categoryTitle(article.categoryKey)}</p>
|
||||
<h1 class="title">{article.title}</h1>
|
||||
|
|
@ -71,7 +73,7 @@ export default function HelpCenterArticlePage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="public-section scene-light">
|
||||
<section class="public-section scene-dark">
|
||||
<div class="container panel panel-light" style={{ 'max-width': '960px' }}>
|
||||
<h2>Need more help?</h2>
|
||||
<p class="sub">If this article does not solve your issue, send your question with context to support.</p>
|
||||
|
|
@ -81,6 +83,8 @@ export default function HelpCenterArticlePage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PublicFooter />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import { authState } from '~/lib/auth';
|
||||
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, RuntimeVisibilityCondition, UploadedFileMeta } from '~/lib/runtime/types';
|
||||
|
||||
function evaluateVisibility(conditions: RuntimeVisibilityCondition[] | undefined, values: Record<string, unknown>) {
|
||||
if (!conditions || conditions.length === 0) return true;
|
||||
return conditions.every((condition) => {
|
||||
const value = values[condition.field];
|
||||
if (typeof condition.equals === 'string') return String(value || '') === condition.equals;
|
||||
if (Array.isArray(condition.in)) return condition.in.includes(String(value || ''));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
import type { RuntimeOnboardingConfig, RuntimeOnboardingField, UploadedFileMeta } from '~/lib/runtime/types';
|
||||
import { evaluateVisibility, normalizeRoleKey, schemaIdFromInput } from '~/lib/onboarding-flow';
|
||||
|
||||
function isEmptyValue(value: unknown) {
|
||||
if (value == null) return true;
|
||||
|
|
@ -53,28 +44,6 @@ function validateField(field: RuntimeOnboardingField, value: unknown): string |
|
|||
return null;
|
||||
}
|
||||
|
||||
function normalizeRoleKey(value: string | null | undefined) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[-\s]+/g, '_');
|
||||
}
|
||||
|
||||
function schemaIdFromInput(roleKey: string, profession: string) {
|
||||
const normalizedRole = normalizeRoleKey(roleKey);
|
||||
const normalizedProfession = String(profession || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[-\s]+/g, '_')
|
||||
.replace(/[^a-z_]/g, '');
|
||||
|
||||
if (normalizedRole === 'CUSTOMER') return 'customer_onboarding_v1';
|
||||
if (normalizedRole === 'COMPANY') return 'company_onboarding_v1';
|
||||
if (normalizedRole === 'JOB_SEEKER' || normalizedRole === 'JOBSEEKER') return 'jobseeker_onboarding_v1';
|
||||
if (normalizedRole === 'PROFESSIONAL' && normalizedProfession) return `${normalizedProfession}_onboarding_v1`;
|
||||
if (normalizedRole === 'PROFESSIONAL') return 'professional_onboarding_v1';
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeSchemaPayload(payload: any, schemaId: string, roleKey: string): RuntimeOnboardingConfig | null {
|
||||
const root = payload?.data || payload || {};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main class="page marketing-page">
|
||||
<section class="card glass-light">
|
||||
<h1 class="title">Privacy Policy</h1>
|
||||
<p class="subtitle">We collect only necessary account, onboarding, and verification data for platform operations and trust.</p>
|
||||
<ul>
|
||||
<li>Data used for role onboarding and verification.</li>
|
||||
<li>Access controlled by role and workflow.</li>
|
||||
<li>Support: support@nxtgauge.com.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<>
|
||||
<main class="page marketing-page">
|
||||
<section class="card glass-light">
|
||||
<h1 class="title">Privacy Policy</h1>
|
||||
<p class="subtitle">We collect only necessary account, onboarding, and verification data for platform operations and trust.</p>
|
||||
<ul>
|
||||
<li>Data used for role onboarding and verification.</li>
|
||||
<li>Access controlled by role and workflow.</li>
|
||||
<li>Support: support@nxtgauge.com.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<PublicFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { A, useSearchParams } from '@solidjs/router';
|
||||
import { For, createMemo } from 'solid-js';
|
||||
import { For, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { listHelpCenterArticles, listHelpCenterCategories } from '~/lib/help-center';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
ALL: 'All roles',
|
||||
|
|
@ -22,6 +24,7 @@ function categoryTitle(input: string) {
|
|||
|
||||
export default function SupportPage() {
|
||||
const [search] = useSearchParams();
|
||||
const [scrollY, setScrollY] = createSignal(0);
|
||||
|
||||
const role = createMemo(() => String(search.role || 'ALL'));
|
||||
const category = createMemo(() => String(search.category || ''));
|
||||
|
|
@ -42,15 +45,18 @@ export default function SupportPage() {
|
|||
})
|
||||
.map((item, idx) => ({ id: `derived-${idx + 1}`, key: item.categoryKey, title: categoryTitle(item.categoryKey) }));
|
||||
});
|
||||
const categoryName = createMemo(() => visibleCategories().find((cat) => cat.key === category())?.title || categoryTitle(category()));
|
||||
|
||||
onMount(() => {
|
||||
const onScroll = () => setScrollY(window.scrollY || 0);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onCleanup(() => window.removeEventListener('scroll', onScroll));
|
||||
});
|
||||
|
||||
return (
|
||||
<main class="lp-main">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground scrollY={scrollY()} />
|
||||
|
||||
<div class="lp-content">
|
||||
<PublicHeader />
|
||||
|
|
@ -103,7 +109,18 @@ export default function SupportPage() {
|
|||
|
||||
<section class="public-section help-solid-section help-section-mid">
|
||||
<div class="container">
|
||||
<div class="split help-article-list">
|
||||
<div class="help-article-headline">
|
||||
<h2>
|
||||
{category()
|
||||
? `${categoryName()} articles`
|
||||
: role() === 'ALL'
|
||||
? 'Latest articles'
|
||||
: `${ROLE_LABELS[role()] || 'Role'} articles`}
|
||||
</h2>
|
||||
<span>{articles().length} articles</span>
|
||||
</div>
|
||||
|
||||
<div class="help-article-list">
|
||||
<For each={articles()}>
|
||||
{(article) => (
|
||||
<article class="help-article-card">
|
||||
|
|
@ -124,7 +141,6 @@ export default function SupportPage() {
|
|||
</article>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{articles().length === 0 && <article class="help-empty-card">No Help Center articles matched your filters.</article>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,17 +160,7 @@ export default function SupportPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="public-footer">
|
||||
<div class="container footer-row">
|
||||
<img class="brand-logo" src="/nxtgauge-logo.png" alt="NXTGAUGE" />
|
||||
<p>© {new Date().getFullYear()} Nxtgauge. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<A href="/terms">Terms</A>
|
||||
<A href="/privacy">Privacy</A>
|
||||
<A href="/help-center">Help Center</A>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import PublicFooter from '~/components/PublicFooter';
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<main class="page marketing-page">
|
||||
<section class="card glass-light">
|
||||
<h1 class="title">Terms and Conditions</h1>
|
||||
<p class="subtitle">Using NXTGAUGE means accepting role-based onboarding, verification checks, and platform moderation policies.</p>
|
||||
<ul>
|
||||
<li>Accounts must provide accurate information.</li>
|
||||
<li>Verification documents are mandatory for approval.</li>
|
||||
<li>Spam or fraudulent activity may lead to suspension.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<>
|
||||
<main class="page marketing-page">
|
||||
<section class="card glass-light">
|
||||
<h1 class="title">Terms and Conditions</h1>
|
||||
<p class="subtitle">Using NXTGAUGE means accepting role-based onboarding, verification checks, and platform moderation policies.</p>
|
||||
<ul>
|
||||
<li>Accounts must provide accurate information.</li>
|
||||
<li>Verification documents are mandatory for approval.</li>
|
||||
<li>Spam or fraudulent activity may lead to suspension.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<PublicFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import PublicBackground from '~/components/PublicBackground';
|
||||
import PublicHeader from '~/components/PublicHeader';
|
||||
|
||||
type RoleOption = {
|
||||
|
|
@ -66,12 +67,7 @@ export default function ChooseRolePage() {
|
|||
|
||||
return (
|
||||
<main class="choose-role-page">
|
||||
<div class="lp-bg" aria-hidden="true">
|
||||
<div class="lp-dark-base" />
|
||||
<div class="lp-mesh" />
|
||||
<div class="lp-ribbon" />
|
||||
<div class="lp-noise" />
|
||||
</div>
|
||||
<PublicBackground />
|
||||
|
||||
<PublicHeader />
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue