28 Commits

Author SHA1 Message Date
tanweer919 2fa7ffc327 Merge pull request 'fix(legal): spacing on opt-in/terms' (#20) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 09:25:40 +00:00
tanweer919 5f67c8fa85 fix(legal): correct spacing around company name on opt-in and terms pages 2026-07-13 14:55:33 +05:30
tanweer919 4a53cb6353 Merge pull request 'feat(legal): SMS opt-in + Privacy + Terms for Twilio A2P 10DLC' (#19) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 09:21:07 +00:00
tanweer919 5511ef4110 feat(legal): SMS opt-in form + Privacy Policy + Terms for Twilio A2P 10DLC
Public, unauthenticated pages required for A2P 10DLC campaign verification:
- /sms-opt-in: web opt-in form (phone field, un-pre-checked consent checkbox,
  message description, frequency, msg&data-rates + HELP/STOP disclaimers, links
  to Terms & Privacy, 'Yes, sign me up!' submit).
- /privacy: Privacy Policy with the mandatory SMS clauses (no third-party/affiliate
  sharing of mobile numbers or SMS consent, message frequency, rates disclosure).
- /terms: Terms of Service with an SMS program-terms section.
Business details centralized in legal-config.ts (replace SMS_SENDER with the
live Twilio number once approved).
2026-07-13 14:50:49 +05:30
tanweer919 870525d6c7 Merge pull request 'fix(login): back nav, single OTP channel, register back buttons' (#18) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 09:02:35 +00:00
tanweer919 127e0f8912 fix(login): back-button nav, single OTP channel, register back buttons
- Login: identify → password/otp now push() history (was replace()), so Back
  returns to the email screen instead of leaving to /register.
- Login: in Shell mode the OTP screen no longer shows an email/SMS toggle —
  the channel is fixed by how you signed in, so email sign-in never shows a
  stray SMS option.
- Login: hide phone (SMS) sign-in while OTP is mocked — a faked login code
  can't mint a session (AuthGate would bounce), so it can't work without SMS.
  Email OTP + password + Google stay real and working.
- Register: add Back buttons (email screen → sign in; profile screen → email).
- Share MOCK_OTP/DEMO_OTP via @/lib/otp.
2026-07-13 14:32:18 +05:30
tanweer919 925170296e Merge pull request 'feat(otp): mock phone OTP fallback while Twilio is down' (#17) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 08:31:29 +00:00
tanweer919 27a5aa939e feat(otp): mock phone OTP fallback while Twilio SMS is down
The Twilio sender isn't delivering, so gate the Verify step behind
NEXT_PUBLIC_MOCK_OTP (default true): 'Send code' skips Supabase/Twilio and a
fixed demo code (123456) verifies, with clear demo-mode copy. The real
addPhone/verifyPhone path is untouched — flip the flag to "false" to restore
live SMS verification.
2026-07-13 13:59:04 +05:30
tanweer919 0a4468cc54 Merge pull request 'feat: real phone OTP verification (Twilio) in register + onboarding' (#16) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:59:22 +00:00
tanweer919 da7f7a7891 feat(register/onboard): real phone OTP verification via Twilio (SDK 0.2.6)
Replaces the mock verify step (which accepted any 6 digits) with a real SMS
OTP: addPhone() → Supabase/Twilio texts a code → verifyPhone() confirms it.

- Registration creates the auth account when leaving the Account step, so the
  phone can be attached + verified against a live Supabase session; finish()
  no longer double-registers.
- Onboarding gains a real phone-verify step (Profile → Verify → Address).
- Email is trusted without an OTP in both flows (Supabase auto-confirms on
  signup; Google verifies for onboarding), matching project config.
- Bump @abe-kap/appshell-sdk to ^0.2.6.
2026-07-13 04:29:02 +05:30
tanweer919 3c6190d16a Merge pull request 'fix(onboarding): reliably prefill verified email (SDK 0.2.5)' (#15) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:36:29 +00:00
tanweer919 6ba00bfbf9 fix(onboarding): reliably prefill the verified email (SDK 0.2.5)
getUserEmail() relied on getSession(), which returns null here because the
Supabase session is intentionally never persisted to storage — so the
onboarding email field stayed empty and Continue was blocked. SDK 0.2.5
captures the email in memory from the token-exchange response instead.
Bump @abe-kap/appshell-sdk to ^0.2.5.
2026-07-13 04:06:13 +05:30
tanweer919 5e2fa574bd Merge pull request 'fix(onboarding): gate direct access behind OAuth handoff' (#14) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:32:35 +00:00
tanweer919 d79da8cd6a fix(onboarding): gate direct access behind the OAuth handoff
/portal/onboarding is only a step in the Google sign-in flow. Redirect to
login unless the login page's onboard_email handoff hint is present (and the
session is authenticated), instead of showing an empty form on direct/typed
URL access. Hold rendering until the check passes to avoid a form flash, and
clear the hint once onboarding completes so it can't be reused.
2026-07-13 04:01:01 +05:30
tanweer919 10141806dc Merge pull request 'fix(onboarding): prefill verified email; SDK 0.2.4' (#13) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:26:05 +00:00
tanweer919 08ef85869f fix(onboarding): prefill verified email in a disabled field; bump SDK 0.2.4
- Onboarding reads the OAuth email (session-storage hint + live Supabase
  session via getUserEmail) so Continue is no longer disabled.
- Replace the empty 'Signed in as' banner with a disabled, prefilled
  email field (email is fixed — Google-verified).
- Stash the verified email at OAuth callback time for onboarding prefill.
- Bump @abe-kap/appshell-sdk to ^0.2.4 (getUserEmail via getSession).
2026-07-13 03:55:05 +05:30
tanweer919 619ec4c9b1 Merge pull request 'feat(auth): Google onboarding flow (option 3)' (#12) from feat/login-methods into goutamnextflow 2026-07-12 22:06:03 +00:00
tanweer919 66cd8953ba Merge pull request 'feat(auth): gate Google sign-in on CRM profile' (#11) from feat/login-methods into goutamnextflow 2026-07-12 21:52:11 +00:00
tanweer919 11931cbf6f Merge pull request 'polish: real role in user chip' (#10) from feat/login-methods into goutamnextflow 2026-07-12 21:33:24 +00:00
tanweer919 43f9a3eb83 Merge pull request 'feat(dashboard): real user + sign-out; remove demo text' (#9) from feat/login-methods into goutamnextflow 2026-07-12 21:27:58 +00:00
tanweer919 f5ff7bf6ea Merge pull request 'fix(auth): session persists on reload (SDK 0.2.2)' (#8) from feat/login-methods into goutamnextflow 2026-07-12 21:11:46 +00:00
tanweer919 3e79b3bf31 Merge pull request 'fix(auth): session persists on reload (gate redirect on boot-ready)' (#7) from feat/login-methods into goutamnextflow 2026-07-12 21:05:20 +00:00
tanweer919 e08fa357f7 Merge pull request 'chore: remove debug logging' (#6) from feat/login-methods into goutamnextflow 2026-07-12 20:47:30 +00:00
tanweer919 e37ef375eb Merge pull request 'fix(auth): PKCE verifier (SDK 0.2.1) — Google sign-in' (#5) from feat/login-methods into goutamnextflow 2026-07-12 20:32:32 +00:00
tanweer919 66118ff63f Merge pull request 'fix(auth): surface OAuth error' (#4) from feat/login-methods into goutamnextflow 2026-07-12 20:15:17 +00:00
tanweer919 6d0c8890d3 Merge pull request 'fix(auth): complete Google OAuth after SDK boot' (#3) from feat/login-methods into goutamnextflow 2026-07-12 20:01:53 +00:00
tanweer919 e9ec33d412 Merge pull request 'feat(auth): Google-only social; email/password sign-up; phone login' (#2) from feat/login-methods into goutamnextflow 2026-07-11 15:19:06 +00:00
tanweer919 2a653b807b Merge pull request 'feat: AppShell integration — auth, email/SMS OTP login, live multi-role team management' (#1) from tanweer919/lynkeduppro-crm:feat/appshell-integration into goutamnextflow 2026-07-11 14:24:59 +00:00
13 changed files with 1479 additions and 139 deletions
+1
View File
@@ -41,3 +41,4 @@ yarn-error.log*
next-env.d.ts
.vercel
.env*.local
+780 -20
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.3",
"@abe-kap/appshell-sdk": "^0.2.6",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import "@/components/portal/portal.css";
import "./legal.css";
import { COMPANY } from "./legal-config";
export const metadata: Metadata = {
title: `${COMPANY} — Legal & SMS`,
description: `${COMPANY} SMS program opt-in, Privacy Policy and Terms of Service.`,
};
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<div className="legal-root">
<header className="legal-bar">
<Link href="/portal/login" aria-label={`${COMPANY} home`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt={`${COMPANY} logo`} />
</Link>
<nav>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
</nav>
</header>
{children}
<footer className="legal-foot">
<div>© {new Date().getFullYear()} {COMPANY}. All rights reserved.</div>
<div style={{ marginTop: 8 }}>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
</div>
</footer>
</div>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Shared business details for the public compliance pages (SMS opt-in, Privacy,
* Terms). Edit these in one place to keep every page and disclosure consistent.
* Replace SMS_SENDER with your live Twilio number once the campaign is approved.
*/
export const COMPANY = "LynkedUp Pro";
export const SUPPORT_EMAIL = "support@lynkeduppro.com";
export const PRIVACY_EMAIL = "privacy@lynkeduppro.com";
export const SMS_SENDER = "(555) 010-0100"; // TODO: replace with your Twilio A2P number
export const SITE_URL = "https://lynkeduppro-crmnew.vercel.app";
export const LAST_UPDATED = "July 13, 2026";
+125
View File
@@ -0,0 +1,125 @@
/* Public compliance pages (SMS opt-in, Privacy, Terms). Reuses the portal design
tokens (--bg, --text, --primary…) from portal.css for a consistent look, with
prose styling tuned for long-form legal copy. */
.legal-root {
min-height: 100vh;
background: var(--bg, #0b0c11);
color: var(--text, #f3f4f8);
font-family: var(--font-ui, "Inter", system-ui, sans-serif);
display: flex;
flex-direction: column;
}
.legal-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.09));
position: sticky;
top: 0;
background: color-mix(in srgb, var(--bg, #0b0c11) 88%, transparent);
backdrop-filter: blur(10px);
z-index: 5;
}
.legal-bar img { height: 30px; width: auto; }
.legal-bar nav { display: flex; gap: 20px; flex-wrap: wrap; }
.legal-bar nav a {
color: var(--muted, #a3a8b5);
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.legal-bar nav a:hover { color: var(--text, #f3f4f8); }
.legal-bar nav a.on { color: var(--primary-2, #fb923c); }
.legal-wrap { flex: 1; width: 100%; max-width: 820px; margin: 0 auto; padding: 44px 24px 80px; }
.legal-wrap h1 { font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 6px; }
.legal-wrap .updated { color: var(--faint, #6c7280); font-size: 13px; margin: 0 0 30px; }
.legal-wrap h2 {
font-size: 19px; font-weight: 700; margin: 34px 0 10px;
padding-top: 20px; border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
}
.legal-wrap h2:first-of-type { border-top: none; padding-top: 0; }
.legal-wrap p, .legal-wrap li { color: var(--muted, #a3a8b5); font-size: 15px; line-height: 1.7; }
.legal-wrap p { margin: 0 0 12px; }
.legal-wrap ul { margin: 0 0 12px; padding-left: 20px; }
.legal-wrap li { margin: 0 0 6px; }
.legal-wrap strong { color: var(--text, #f3f4f8); font-weight: 600; }
.legal-wrap a { color: var(--primary-2, #fb923c); }
/* Callout box for the mandatory SMS disclosures — makes them easy for a reviewer to find. */
.legal-callout {
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 16px 18px;
margin: 8px 0 18px;
}
.legal-callout p:last-child { margin-bottom: 0; }
.legal-foot {
border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
padding: 22px 24px;
text-align: center;
color: var(--faint, #6c7280);
font-size: 13px;
}
.legal-foot a { color: var(--muted, #a3a8b5); text-decoration: none; margin: 0 10px; }
.legal-foot a:hover { color: var(--text, #f3f4f8); }
/* SMS opt-in form */
.optin-card {
border: 1px solid var(--line, rgba(255, 255, 255, 0.09));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 22px;
margin-top: 22px;
}
.optin-field { margin-bottom: 16px; }
.optin-field label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 7px; color: var(--text, #f3f4f8); }
.optin-input {
width: 100%;
background: var(--ink, #121319);
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
border-radius: 10px;
padding: 13px 14px;
color: var(--text, #f3f4f8);
font-size: 15px;
font-family: inherit;
}
.optin-input:focus { outline: 2px solid var(--primary, #f97316); outline-offset: 1px; border-color: transparent; }
.optin-consent { display: flex; gap: 11px; align-items: flex-start; margin: 6px 0 4px; }
.optin-consent input { margin-top: 3px; width: 18px; height: 18px; flex: none; accent-color: var(--primary, #f97316); }
.optin-consent label { font-size: 13.5px; line-height: 1.6; color: var(--muted, #a3a8b5); }
.optin-submit {
width: 100%;
margin-top: 18px;
background: var(--primary, #f97316);
color: #fff;
border: none;
border-radius: 10px;
padding: 14px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.optin-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.optin-fine { font-size: 12.5px; color: var(--faint, #6c7280); line-height: 1.6; margin-top: 14px; }
.optin-done {
border: 1px solid color-mix(in srgb, var(--green, #34d399) 45%, transparent);
background: color-mix(in srgb, var(--green, #34d399) 12%, transparent);
color: #a7f3d0;
border-radius: var(--radius-sm, 13px);
padding: 18px 20px;
margin-top: 22px;
font-size: 14.5px;
line-height: 1.6;
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, PRIVACY_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Privacy Policy — ${COMPANY}`,
description: `How ${COMPANY} collects, uses, and protects your information, including SMS/text messaging.`,
};
export default function PrivacyPage() {
return (
<main className="legal-wrap">
<h1>Privacy Policy</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
This Privacy Policy explains how {COMPANY} (&quot;{COMPANY},&quot; &quot;we,&quot;
&quot;us&quot;) collects, uses, and protects your information when you use our
website, portal, and services, including our text message (SMS) program.
</p>
<h2>Information we collect</h2>
<ul>
<li><strong>Account information</strong> name, email address, and mobile phone number you provide when registering or signing in.</li>
<li><strong>Property and service information</strong> details you share to request roof inspections, estimates, and reports.</li>
<li><strong>Usage and device information</strong> log data, device and browser type, and similar technical information collected automatically.</li>
</ul>
<h2>How we use your information</h2>
<ul>
<li>To create and secure your account, including sending one-time verification codes (OTP).</li>
<li>To provide, maintain, and improve our services.</li>
<li>To communicate with you about your account, appointments, estimates, and support requests.</li>
<li>To comply with legal obligations and protect against fraud and abuse.</li>
</ul>
<h2>SMS / text messaging</h2>
<div className="legal-callout">
<p>
<strong>
We do not sell, rent, or share your mobile phone number, or your SMS opt-in
consent, with any third parties or affiliates for their marketing or
promotional purposes.
</strong>
</p>
<p>
Mobile information collected for the purpose of sending text messages is used
only to deliver the messages you opted into and is not shared with third parties
for marketing. We may share your number only with service providers (such as our
messaging platform) strictly to deliver the messages, and as required by law.
</p>
<p>
<strong>Message frequency varies</strong> verification codes are sent when you
request them (for example, each time you sign in or register), and account or
service notifications are occasional. <strong>Message and data rates may
apply</strong>, depending on your mobile carrier and plan.
</p>
<p>
You can opt out at any time by replying <strong>STOP</strong> to any message, and
get help by replying <strong>HELP</strong>. See our{" "}
<Link href="/terms">Terms of Service</Link> and the{" "}
<Link href="/sms-opt-in">SMS opt-in page</Link> for full program details.
</p>
</div>
<h2>How we share information</h2>
<p>
We do not sell your personal information. We share information only with service
providers who help us operate the service (for example, cloud hosting,
authentication, and messaging providers), and when required by law or to protect
our rights. As stated above, mobile phone numbers and SMS consent are never shared
with third parties or affiliates for marketing purposes.
</p>
<h2>Data retention and security</h2>
<p>
We retain personal information for as long as your account is active or as needed to
provide the service and meet legal obligations. We use administrative, technical,
and physical safeguards designed to protect your information; no method of
transmission or storage is completely secure.
</p>
<h2>Your choices and rights</h2>
<p>
You may access or update your account information at any time, opt out of SMS by
replying STOP, and request deletion of your account by contacting us. Depending on
your location, you may have additional rights under applicable privacy laws.
</p>
<h2>Contact us</h2>
<p>
Questions about this Privacy Policy? Email us at{" "}
<a href={`mailto:${PRIVACY_EMAIL}`}>{PRIVACY_EMAIL}</a> or{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { COMPANY, SMS_SENDER, SUPPORT_EMAIL } from "../legal-config";
/**
* Public SMS opt-in web form for A2P 10DLC campaign verification. Contains every
* element Twilio/CTIA require: phone field, an un-pre-checked consent checkbox, a
* description of the messages, frequency, rates disclaimer, HELP/STOP instructions,
* and links to the Terms and Privacy Policy.
*/
export default function SmsOptInPage() {
const [phone, setPhone] = useState("");
const [consent, setConsent] = useState(false);
const [done, setDone] = useState(false);
const digits = phone.replace(/\D/g, "");
const valid = digits.length >= 10 && consent;
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid) return;
// Records the opt-in. Consent + timestamp should be persisted server-side for your
// records; this confirmation is the user-facing acknowledgement.
setDone(true);
}
return (
<main className="legal-wrap">
<h1>{COMPANY} SMS Alerts</h1>
<p className="updated">Sign up to receive text messages from {COMPANY}.</p>
<p>
At {COMPANY}, we send text messages to help you use and secure your account. By
opting in below you&apos;ll receive <strong>account and service messages</strong>,
including:
</p>
<ul>
<li>One-time verification codes (OTP) when you sign in or register</li>
<li>Sign-in and security alerts</li>
<li>Roof inspection scheduling and status updates</li>
<li>Estimate and report notifications</li>
</ul>
{done ? (
<div className="optin-done" role="status">
<strong>You&apos;re signed up.</strong> We&apos;ll text account and service
messages to {phone}. Reply <strong>STOP</strong> at any time to unsubscribe, or{" "}
<strong>HELP</strong> for help.
</div>
) : (
<form className="optin-card" onSubmit={onSubmit}>
<div className="optin-field">
<label htmlFor="sms-phone">Mobile phone number</label>
<input
id="sms-phone"
className="optin-input"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="+1 (555) 010-0100"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
</div>
{/* Consent checkbox — MUST NOT be pre-checked (starts false). */}
<div className="optin-consent">
<input
id="sms-consent"
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<label htmlFor="sms-consent">
I agree to receive account and service text messages (including one-time
verification codes) from {COMPANY} at the number provided. Consent is not a
condition of purchase. Message frequency varies. Message and data rates may
apply. Reply HELP for help and STOP to unsubscribe. See our{" "}
<Link href="/terms">Terms of Service</Link> and{" "}
<Link href="/privacy">Privacy Policy</Link>.
</label>
</div>
<button className="optin-submit" type="submit" disabled={!valid}>
Yes, sign me up!
</button>
<p className="optin-fine">
Message frequency varies. Message and data rates may apply. Reply{" "}
<strong>HELP</strong> for help or <strong>STOP</strong> to cancel at any time.
Carriers are not liable for delayed or undelivered messages.
</p>
</form>
)}
<h2>Program details</h2>
<ul>
<li><strong>Program name:</strong> {COMPANY} Account &amp; Service Alerts</li>
<li><strong>Message types:</strong> verification codes (OTP), security alerts, appointment and inspection updates, estimate notifications</li>
<li><strong>Message frequency:</strong> varies codes are sent when you request them (e.g. each sign-in or registration); notifications are occasional</li>
<li><strong>Cost:</strong> Message and data rates may apply, per your mobile plan</li>
</ul>
<h2>How to opt out or get help</h2>
<p>
Reply <strong>STOP</strong> to any message to unsubscribe you&apos;ll get one
confirmation and no further texts. Reply <strong>HELP</strong> for help, or contact
us at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
{SMS_SENDER ? <> or {SMS_SENDER}</> : null}. Messages are sent from {COMPANY}.
</p>
<h2>Your privacy</h2>
<p>
<strong>
We do not sell, rent, or share your mobile phone number or your SMS opt-in
consent with any third parties or affiliates for their marketing purposes.
</strong>{" "}
See our <Link href="/privacy">Privacy Policy</Link> for full details.
</p>
</main>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Terms of Service — ${COMPANY}`,
description: `The terms governing your use of ${COMPANY}, including the SMS/text messaging program.`,
};
export default function TermsPage() {
return (
<main className="legal-wrap">
<h1>Terms of Service</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the {COMPANY}{" "}
website, portal, and services (the &quot;Service&quot;). By using the
Service you agree to these Terms.
</p>
<h2>Use of the Service</h2>
<p>
You must provide accurate information, keep your credentials secure, and use the
Service only for lawful purposes and in accordance with these Terms. You are
responsible for activity that occurs under your account.
</p>
<h2>Accounts and verification</h2>
<p>
To protect your account we may verify your identity, including by sending one-time
codes to your email or mobile number. You agree to receive these verification
messages as part of using the Service.
</p>
<h2>SMS / text messaging program</h2>
<div className="legal-callout">
<p>
By opting in on our <Link href="/sms-opt-in">SMS opt-in page</Link> or by
providing your mobile number and agreeing to receive texts, you consent to
receive <strong>account and service text messages</strong> from {COMPANY},
including one-time verification codes (OTP), security alerts, appointment and
inspection updates, and estimate notifications.
</p>
<p>
<strong>Message frequency varies.</strong> <strong>Message and data rates may
apply.</strong> Reply <strong>HELP</strong> for help or <strong>STOP</strong> to
unsubscribe at any time; after you send STOP we will send one confirmation and no
further messages. Carriers are not liable for delayed or undelivered messages.
</p>
<p>
We do not sell, rent, or share your mobile number or SMS consent with third
parties or affiliates for marketing. See our{" "}
<Link href="/privacy">Privacy Policy</Link> for details.
</p>
</div>
<h2>Acceptable use</h2>
<p>
You may not misuse the Service, attempt to access accounts or data you are not
authorized to access, interfere with the Service&apos;s operation, or use it to
send unlawful, harmful, or infringing content.
</p>
<h2>Disclaimers and limitation of liability</h2>
<p>
The Service is provided &quot;as is&quot; without warranties of any kind. To the
fullest extent permitted by law, {COMPANY} is not liable for any indirect,
incidental, or consequential damages arising from your use of the Service.
</p>
<h2>Changes to these Terms</h2>
<p>
We may update these Terms from time to time. Continued use of the Service after
changes take effect constitutes acceptance of the updated Terms.
</p>
<h2>Contact us</h2>
<p>
Questions about these Terms? Email us at{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+20 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
@@ -10,18 +10,34 @@ import { isShellConfigured } from "@/lib/appshell";
/**
* Post-OAuth onboarding — a first-time Google user completes their CRM profile
* (everything except email, which Google already verified). Only reachable while
* authenticated; unauthenticated visitors are sent back to sign in.
* (everything except email, which Google already verified).
*
* This screen is ONLY a step in the OAuth → onboarding handoff: the login page
* stashes the verified email in `sessionStorage` right before routing here. Any
* other way in — a typed URL, a fresh tab, a lost session — has no handoff hint
* (and possibly no session), so we bounce back to sign in rather than showing an
* empty form.
*/
export default function OnboardingPage() {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
if (isShellConfigured() && ready && status === "unauthenticated") router.replace("/portal/login");
if (!isShellConfigured()) { setAllowed(true); return; } // local dev without the Shell
if (!ready) return; // wait for session restore
let hasHandoff = false;
try { hasHandoff = !!sessionStorage.getItem("onboard_email"); } catch { /* ignore */ }
if (status === "unauthenticated" || !hasHandoff) {
router.replace("/portal/login");
return;
}
setAllowed(true);
}, [ready, status, router]);
if (!allowed) return <main className="portal-main"><span className="portal-grid" /></main>;
return (
<main className="portal-main">
<span className="portal-grid" />
+29 -12
View File
@@ -11,6 +11,7 @@ import {
import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP } from "@/lib/otp";
// Map the portal's social button ids to Supabase OAuth provider ids.
const OAUTH_PROVIDER: Record<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
@@ -24,7 +25,7 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() {
const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState("");
@@ -42,7 +43,7 @@ export function LoginFlow() {
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
replace("otp");
push("otp"); // push (not replace) so Back returns to the identify screen
}
/* ---- history hash sync ---- */
@@ -86,13 +87,16 @@ export function LoginFlow() {
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
router.replace(registered ? "/dashboard" : "/portal/onboarding");
if (registered) { router.replace("/dashboard"); return; }
// Capture the verified email now (session is fresh) so onboarding prefills it.
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
})
.catch(() => {
setFlash("Google sign-in didn't complete. Please try again.");
replace("identify");
});
}, [ready, completeOAuthLogin, router, sdk]);
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
/* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
@@ -131,7 +135,7 @@ export function LoginFlow() {
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
setOtpChannel("email");
replace("password");
push("password"); // push (not replace) so Back returns to the email screen, not off-page
return;
}
push("connecting");
@@ -298,7 +302,10 @@ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
</button>
</form>
)}
{isShellConfigured() && (
{/* Phone (SMS) sign-in needs a deliverable one-time code — hidden while SMS is
mocked, since a faked code can't mint a real session. Email + password + Google
all work without SMS. */}
{isShellConfigured() && !MOCK_OTP && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div>
@@ -447,12 +454,22 @@ function OtpVerify({ account, email, initialChannel = "email", remember, setReme
<div>
<StepBack onClick={onBack} />
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<p className="sub">{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}</p>
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
{!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
</div>
<p className="sub">
{!shell
? "Enter the 6-digit code we sent you to finish signing in."
: channel === "sms"
? "We'll text a 6-digit code to sign in."
: "We'll email you a 6-digit code to sign in."}
</p>
{/* In Shell mode the channel is fixed by how you signed in (email vs phone) —
no toggle, so an email sign-in never shows a stray SMS option. */}
{!shell && (
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
</div>
)}
{shell && channel === "sms" && !sent ? (
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
+143 -102
View File
@@ -13,6 +13,7 @@ import {
} from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP, DEMO_OTP } from "@/lib/otp";
type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
@@ -25,10 +26,14 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter();
const { register, setPassword, getUserEmail } = useAuth();
const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth();
const { sdk } = useAppShell();
const [step, setStep] = useState(0);
const [submitErr, setSubmitErr] = useState("");
// Verifying a phone via Supabase needs a live session. Onboarding already has one
// (Google OAuth); registration creates the account when leaving the Account step,
// then attaches + verifies the phone on it. `creating` guards the Account button.
const [creating, setCreating] = useState(false);
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
const [regAddr, setRegAddr] = useState<Addr>({});
@@ -50,18 +55,19 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false);
// verify step
const [emailVerified, setEmailVerified] = useState(false);
// verify step (email is trusted without an OTP — see verifyValid below)
const [phoneVerified, setPhoneVerified] = useState(false);
// Onboarding: prefill the verified email from the Google session (the ACE omits it).
// Onboarding: prefill the verified email from the session-storage hint the login
// page stashed at OAuth time, then confirmed via the live Supabase session.
useEffect(() => {
if (!onboard) return;
try { const cached = sessionStorage.getItem("onboard_email"); if (cached) setEmail(cached); } catch { /* ignore */ }
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS;
const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS;
const isSelf = relationship === "Customer" || relationship === "Owner";
const isEmployee = relationship === "Employee";
@@ -74,7 +80,26 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
const step2Valid = emailVerified && phoneVerified;
// Email is already trusted in both flows (registration: Supabase auto-confirms on
// signup; onboarding: Google-verified), so the Verify step only gates on the phone.
const verifyValid = phoneVerified;
// Registration: create the auth account when leaving the Account step, so the phone
// can be attached + verified against a live session at the Verify step. Onboarding
// is already authenticated, so it just advances.
async function leaveAccountStep() {
if (onboard || !isShellConfigured() || sso) { setStep(1); return; }
setSubmitErr("");
setCreating(true);
try {
await register(email, pw);
setStep(1);
} catch {
setSubmitErr("We couldn't create that account. The email may already be registered.");
} finally {
setCreating(false);
}
}
async function finish() {
const finalEmail = sso?.email || email;
@@ -89,13 +114,12 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
if (isShellConfigured()) {
setSubmitErr("");
// 1) Auth account. Onboarding users are already authenticated via Google — set
// a password so email+password login works too. Everyone else registers now.
if (onboard) {
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
} else if (!sso) {
try { await register(finalEmail, pw); }
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
// 1) Auth account already exists at this point — registration created it when
// leaving the Account step; onboarding users signed in via Google. Onboarding
// additionally sets a password so email+password login works too (the phone was
// just verified against the same live session).
if (onboard && pw) {
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
}
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
@@ -118,6 +142,8 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
}
}
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
router.push("/dashboard");
}
@@ -126,34 +152,36 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
<Stepper current={step} labels={STEP_LABELS} />
{step === 0 && (
<StepAccount
onboard={onboard}
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")}
/>
)}
{step === 1 && !onboard && (
<StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{((onboard && step === 1) || (!onboard && step === 2)) && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
<StepAccount
onboard={onboard} creating={creating}
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/>
</>
)}
{step === 1 && (
<StepVerify
cc={cc} phone={phone} country={country}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{step === 2 && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
</>
)}
</div>
@@ -171,7 +199,7 @@ function StepAccount(p: {
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean;
}) {
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
@@ -181,6 +209,7 @@ function StepAccount(p: {
const valid = EMAIL_RE.test(p.email);
return (
<div>
<StepBack onClick={p.toLogin} />
<h1>Create your account</h1>
<p className="sub">Enter your email to get started.</p>
<div style={{ marginTop: 20 }}>
@@ -202,15 +231,19 @@ function StepAccount(p: {
return (
<div>
{/* Non-onboarding users can step back to the email screen; onboarding starts
here (email came from Google), so there's nothing to go back to. */}
{!p.onboard && <StepBack onClick={() => setPhase("sso")} />}
<h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p>
<div style={{ marginTop: 16 }}>
{p.sso ? (
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
) : (
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div>
)}
<div className="field" style={{ marginTop: 16 }}>
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} disabled readOnly aria-label="Email address" />
</div>
<span className="faint" style={{ fontSize: 12 }}>{p.onboard ? "Verified with Google — this can't be changed." : "The email you're registering with."}</span>
</div>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
@@ -308,7 +341,9 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid || p.creating} onClick={p.onContinue}>
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <Icon name="arrowR" size={16} />}
</button>
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -318,10 +353,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — VERIFY ====================== */
/* ====================== STEP 1 — VERIFY PHONE ======================
Email is already trusted (registration: Supabase auto-confirms on signup;
onboarding: Google-verified), so this step only verifies the phone via a real
SMS OTP: addPhone() → Twilio texts a code → verifyPhone() confirms it. Both
run against the live Supabase session established in the previous step. */
function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
cc: string; phone: string; country: typeof countryCodes[number];
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
@@ -329,12 +367,11 @@ function StepVerify(p: {
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1>
<p className="sub">Confirm both so we can secure your account.</p>
<h1>Verify your phone</h1>
<p className="sub">We&apos;ll text a one-time code to confirm your number. Your email is already verified.</p>
<div className="col gap-4" style={{ marginTop: 16 }}>
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
<PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
@@ -343,40 +380,53 @@ function StepVerify(p: {
);
}
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string;
type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
cc: string; country: typeof countryCodes[number]; initialPhone: string;
verified: boolean; onVerified: () => void;
}) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone);
const [via, setVia] = useState<"primary" | "wa">("primary");
const { addPhone, verifyPhone } = useAuth();
const [value, setValue] = useState(initialPhone);
const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false);
const primaryLabel = kind === "email" ? "Email" : "SMS";
const [otpError, setOtpError] = useState("");
const e164 = `${cc}${value.replace(/\D/g, "")}`;
function send() {
async function send() {
if (value.replace(/\D/g, "").length !== country.digits) { setState("invalid_mobile"); return; }
setState("sending");
setTimeout(() => {
if (kind === "email") {
if (!EMAIL_RE.test(value)) return setState("invalid_email");
if (value.includes("full")) return setState("mailbox_full");
if (value.includes("bo")) return setState("bounce_risk");
return setState("ok");
} else {
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile");
return setState("ok");
}
}, 700);
setOtpError("");
if (MOCK_OTP) { setState("sent"); return; } // demo: skip Twilio entirely
try {
await addPhone(e164); // Supabase → Twilio sends the SMS OTP
setState("sent");
} catch {
setState("send_error");
}
}
async function submit(code: string) {
setOtpError("");
if (MOCK_OTP) {
if (code === DEMO_OTP) onVerified();
else setOtpError(`Demo mode — enter ${DEMO_OTP} to verify.`);
return;
}
try {
await verifyPhone(e164, code); // confirm the OTP (phone_change)
onVerified();
} catch {
setOtpError("That code didn't match. Check the SMS and try again.");
}
}
if (verified) {
return (
<div className="vchannel verified">
<div className="row between">
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile</span>
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{cc} {value}</p>
</div>
);
}
@@ -384,43 +434,34 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
return (
<div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile number</span>
</div>
{kind === "email" ? (
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" />
) : (
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
{cc}
</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
)}
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
{cc}
</span>
<input className="input grow" inputMode="numeric" value={value} disabled={state === "sent"} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
<div className="row between" style={{ marginTop: 12 }}>
<div className="seg">
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button>
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
<span className="faint" style={{ fontSize: 12 }}>{MOCK_OTP ? "Demo verification (no SMS sent)." : "Standard SMS rates may apply."}</span>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
{state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
</button>
</div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>}
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full try another email.</FlashNote></div>}
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
{state === "sent" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>{MOCK_OTP ? `Demo mode — enter ${DEMO_OTP} to verify (SMS temporarily disabled).` : `Code sent to ${cc} ${value} by SMS.`}</p>}
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
{state === "send_error" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Couldn&apos;t send the code. Check the number and try again.</FlashNote></div>}
{state === "ok" && (
{state === "sent" && (
<div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
<OtpBoxes onComplete={submit} error={!!otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
</div>
)}
</div>
+16
View File
@@ -0,0 +1,16 @@
/**
* Demo OTP fallback, shared by the login and registration flows.
*
* While the Twilio SMS sender is down we can't deliver real one-time codes. When
* MOCK_OTP is on, phone verification steps skip Supabase/Twilio and accept a fixed
* DEMO_OTP instead. Flip NEXT_PUBLIC_MOCK_OTP to "false" (or remove it) to restore
* real SMS — no other change needed.
*
* IMPORTANT: this only substitutes for a *secondary* verification (e.g. confirming a
* phone during registration/onboarding, where the session already exists). It cannot
* mock a *login* whose sole credential is the OTP — there the OTP verification is what
* mints the session, and a faked code produces no session (the dashboard's AuthGate
* would bounce the user straight back). Passwordless login therefore stays real.
*/
export const MOCK_OTP = process.env.NEXT_PUBLIC_MOCK_OTP === "true";
export const DEMO_OTP = "123456";