66 Commits

Author SHA1 Message Date
maaz519 adb5a6bb7b feat(messenger): reactions, replies, typing, seen ticks + UX fixes
Wire the IIOS kernel client's existing realtime affordances into the
Messenger UI and fix three display issues.

- Bubble contrast: incoming bubbles used --panel-2, which equals --bg in
  the dark theme (both #060608) → invisible. Use --panel + a border.
- DM title: mapped all participants (incl. self → unknown-id fallback),
  producing "User d10888, Maaz Ahmed". Now shows the counterpart only.
- Live sidebar preview: lastMessage/time update on any inbound socket
  message, reconciled with a debounced conversation-list refetch.
- Typing indicator: throttled typing() send + auto-expiring "typing…" line.
- Read receipts: markRead() on open/new message; "Sent"/"Seen" under the
  last outgoing message. Receipt event carries no threadId, so it is a
  global stream filtered to my own messages by actor id.
- Reactions: emoji picker on hover, chips with counts, live via annotation.
- Reply/quote: parentInteractionId round-trips; quoted parent renders above
  the reply and in a composer quote bar.

Presence (online + last-seen) is intentionally not included — the kernel
has no presence receive-event yet; that needs an IIOS change + client release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 16:52:29 +05:30
maaz519 2056592d51 Merge pull request 'feat: Messenger + Inbox on the AppShell data door + live IIOS socket' (#28) from feat/messenger-inbox into goutamnextflow
Reviewed-on: #28
2026-07-15 21:23:37 +00:00
maaz519 49b04e9710 feat: Messenger + Inbox on the AppShell data door + live IIOS socket
New "Communication" area in the dashboard, built on the existing appshell-sdk wiring
(useQuery/sdk.command) like Team, with the IIOS MessageSocket for live streaming.

- messenger-api.ts / inbox-api.ts: mock (demo) + live (crm.messenger.* / crm.inbox.*)
  behind one interface, switched by isShellConfigured().
- messenger-socket.tsx: MessengerSocketProvider — one IIOS MessageSocket per panel
  (openThread history + live on("message") + send). REST 4s poll is the automatic
  fallback when the socket isn't connected.
- messenger.tsx: conversation list ⇄ thread + composer + new-chat people picker
  (DM 1 person / group 2+); inbox.tsx: filterable feed with Done/Snooze/Archive.
- sidebar: Communication group (Messenger + Inbox, always-visible); dashboard: panel switch.
- .npmrc: add the @insignia Gitea registry for @insignia/iios-kernel-client.

Works on mock immediately; goes live once NEXT_PUBLIC_SUPABASE_URL + the BFF + be-crm are set.
tsc clean; next build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:45:47 +05:30
tanweer919 e7c042fb1a Merge pull request 'refactor(email): move invite email to be-crm (AppShell)' (#27) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-14 10:09:03 +00:00
tanweer919 9eb234935d refactor(email): remove frontend invite email; be-crm sends it now
Per AppShell, email delivery lives in the domain API. Delete the frontend
/api/email/invite route + invite-email template, and the team-management email
wiring. be-crm now emails invitees on create/resend. Copy link stays as a
fallback (invite token still returned in the pending-invite list).
2026-07-14 15:38:53 +05:30
tanweer919 fa538355ae Merge pull request 'fix(login): readable email-OTP send error' (#26) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 15:14:24 +00:00
tanweer919 1a2043eaae fix(login): readable email-OTP send error instead of raw '{}'
gotrue can throw an error whose message is an unhelpful JSON blob (e.g. 500
'Error sending magic link email' when Supabase SMTP isn't configured). Show a
readable, actionable message instead of rendering the raw payload.
2026-07-13 20:44:05 +05:30
tanweer919 70831e189d Merge pull request 'fix(team): real member names + emails' (#25) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 15:03:08 +00:00
tanweer919 eeded95940 fix(team): show member real name + email (no more principal UUIDs)
Use displayName + email now returned by be-crm member search (from CRM
registration). Falls back to the ACE for the current user, then job title —
never the raw principal id as the email.
2026-07-13 20:33:01 +05:30
tanweer919 0643b8c9b2 Merge pull request 'fix(invite): route register vs sign-in + prefill email (public lookup)' (#24) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 14:55:33 +00:00
tanweer919 be4bd5f41d fix(invite): look up the invitation and route correctly (register vs sign in)
The invite link is token-only, so the landing page couldn't tell the invited
email or whether an account existed — it dumped everyone on an empty register
screen. Now it calls a public lookup (/api/invite/lookup → be-crm) and:
- first-time invitee → register with the email prefilled + locked;
- email already registered → sign in with the email prefilled;
- signed-in + registered → accept immediately; signed-in + no profile → onboarding.
Login now redeems the pending invite after sign-in (password/OTP/OAuth), so an
existing user's invite is accepted on login (not only when already logged in).
2026-07-13 20:25:25 +05:30
tanweer919 75417cb4f1 Merge pull request 'feat(email): invitation emails via Twilio (Vercel route)' (#23) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 14:18:31 +00:00
tanweer919 cc71516278 feat(email): send team invitations from a Vercel serverless route (Twilio)
- POST /api/email/invite: sends the invite email via the Twilio Emails API using
  server-only TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN (Vercel env), from
  EMAIL_FROM_ADDRESS (default support@lynkedup.dev). Fixed invite template; gated
  on a session cookie so it isn't an open relay.
- Team Management emails the invite on create and resend (token now returned by
  both), with the Copy-link as a fallback. Mock mode skips email.
2026-07-13 19:48:19 +05:30
tanweer919 6606a55207 Merge pull request 'feat(rbac): invitation-based membership + nav gating; no role at registration' (#22) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 10:56:17 +00:00
tanweer919 103f3ae3a1 feat(rbac): no role at registration; invitation-based membership; nav gating
- Registration no longer asks for a role/persona (removed the role select +
  allottee block); crm.account.register sends no persona.
- New users have no permissions → the sidebar now shows only Dashboard + Profile
  for them, gated on crm.account.me (membership + permissions). Members see the
  areas their permissions allow.
- Add /portal/invite?token=… : accepts the invite for a signed-in registered
  user, or routes an unregistered invitee through register/onboarding, which
  redeems the stashed token on completion (granting the invited role).
- Team Management: 'Copy link' on pending invites builds the invite link from
  the invitation token (no email delivery yet).
2026-07-13 16:25:53 +05:30
tanweer919 661e291fae Merge pull request 'fix(legal): opt-in page spacing' (#21) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 09:30:18 +00:00
tanweer919 68bf137d11 fix(legal): explicit spacing after inline elements on SMS opt-in page 2026-07-13 14:59:59 +05:30
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 948abf75fd feat(auth): first-time Google onboarding (option 3)
After a Google sign-in with no CRM profile, route to /portal/onboarding — the
registration flow in mode='onboard': email skipped (Google-verified, prefilled
via getUserEmail), sets a password (updateUser) so email+password login also
works, skips the OTP-verify step, and persists the profile via crm.account.register.
Existing-profile users go straight to the dashboard. Bumps SDK ^0.2.3.
2026-07-13 03:35:55 +05:30
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 34cb44cfa8 feat(auth): gate Google sign-in on an existing CRM profile
Google is sign-IN only: after OAuth, check crm.account.registrationStatus; if the
account has no CRM profile, sign back out and show an error on the login screen
instead of admitting a profile-less user. (Onboarding to collect the profile comes later.)
2026-07-13 03:22:04 +05:30
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 f38fa80bde polish(dashboard): show real ACE role in user chip instead of static 'Property Owner' 2026-07-13 03:03:17 +05:30
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 86af77c92b feat(dashboard): real user in sidebar + sign-out; strip demo/mock hints
- Sidebar footer now shows the ACE identity (was static 'James Carter') with a
  Sign-out menu; topbar user gets a Sign-out dropdown too. Both call useAuth().logout.
- Remove all visible 'Demo:' hints from login + register and the 'Demo mode' subtitle.
2026-07-13 02:57:51 +05:30
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 27e7bdf8f5 fix(auth): bump appshell-sdk 0.2.2 (ready-after-restore) — session persists on reload 2026-07-13 02:41:39 +05:30
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 64be4b9b3f fix(auth): don't redirect from dashboard until SDK boot completes
AuthGate redirected to /portal/login whenever status !== authenticated, which
during boot (before restore() resolves) dropped a valid session on reload. Gate
the redirect on useAppShell().ready so we only bounce once auth has resolved.
2026-07-13 02:35:13 +05:30
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 5186497c50 chore(auth): remove debug logging from OAuth + registration error paths 2026-07-13 02:17:22 +05:30
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 0e8a207640 fix(auth): bump appshell-sdk 0.2.1 (PKCE verifier fix); tidy OAuth error handling 2026-07-13 02:02:24 +05:30
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 7570fe7be3 fix(auth): surface the real OAuth completion error (console + flash) 2026-07-13 01:45:10 +05:30
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 6e1a895e7e fix(auth): complete Google OAuth after SDK boot (was stuck on ?code=…#identify)
The OAuth-return effect ran on mount before AppShell finished booting, so
completeOAuthLogin no-oped (sdk.auth undefined → null) and fell back to identify,
never retrying. Gate it on useAppShell().ready so it runs once auth is up, and
strip ?code= from the URL on success.
2026-07-13 01:31:37 +05:30
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 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- SocialButtons: only Google (Microsoft/Apple hidden).
- Sign-up: remove all social (email/password only).
- Login: keep Google + email/password (single step) + email/SMS OTP; add a direct
  'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to
  'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
2026-07-11 20:48:47 +05:30
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
tanweer919 339e2b007a feat(register): persist full registration to crm.account.register
Lift StepAddress/AddressBlock state up so finish() can send the complete
sign-up payload (name, phone, persona, allottee id/names, registered+mailing
address, consent) to be-crm crm.account.register after auth. Config-gated;
non-fatal if the domain persist fails (auth account already created).
2026-07-11 06:19:49 +05:30
tanweer919 565019a75c feat(team+auth): live multi-role team management + email/SMS OTP login
- team-management: new useTeamData() data layer serves live be-crm data door
  (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite
  commands) or the mock fallback, gated on isShellConfigured(). A member can
  hold MANY roles: role chips + multi-select picker in Edit/Invite modals.
- login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless)
  via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp.
- bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
2026-07-11 04:44:54 +05:30
tanweer919 1d37593102 feat(appshell): integrate @abe-kap/appshell-sdk (strangler, mock fallback)
Wire the AppShell SDK for auth/identity across the CRM, gated behind
isShellConfigured() so the existing mock portal + static demo user stay
the fallback until the Shell BFF is configured.

- providers.tsx: app-wide <AppShellProvider> (auth passed only when Supabase env set)
- next.config.ts: transpilePackages + /shell -> BFF rewrite (keeps /api/geo intact)
- login: password -> login(), social -> loginWithOAuth(), OAuth return -> completeOAuthLogin()
- register: email/password sign-ups call register() on finish
- dashboard: AuthGate bounces unauthenticated visitors; topbar shows ACE identity
- docs/APPSHELL_INTEGRATION.md, .env.local.example, .npmrc for GitHub Packages
2026-07-09 20:51:17 +05:30
Goutam0612 9675bf014d Sidebar light-theme fix; remove Property step from registration
- dashboard sidebar now uses the --sidebar token so it turns white in
  light mode instead of staying black; active nav item stays readable
- registration: drop the Property / Job ID step (job ID + find-by-address),
  flow is now Account -> Verify -> Address with steps renumbered

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:39:03 +05:30
Goutam 25420bef8e Team roles: construction personas with attire notes; fix dark-theme dropdown
- Replace roles with the 10 Construction Industry personas (Owner, Admin,
  Sales Manager, Sales Rep, Project Manager, Site Supervisor, Canvasser,
  Team Lead, Subcontractor, Other), each with description, attire &
  appearance cues and a common-overlap note
- Render Attire & appearance + Common overlap sections on each role card
- Remap members/invites to new role ids; add canvasser, team-lead and
  subcontractor members so every persona shows
- Set color-scheme per theme and style <select> options so the role
  dropdown is legible in dark mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:39:14 +05:30
Goutam f1a7bdc8ce Role-aware registration: Customer/Employee/Owner/Contractor/Sub-Con/Vendor
- New "Your role" options matching the portal roles
- Customer/Owner = self; Employee shows an Employee ID field;
  Contractor/Sub-Con/Vendor show their ID + property owner name
- Validation adapts per role

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:14:12 +05:30
Goutam b79d925552 Show real flag image in registration phone field
Flag emojis don't render on Windows, so derive an ISO code from the
emoji's regional indicators and load a flagcdn image in front of the
country-code selector (Account + Verify steps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:00:37 +05:30
Goutam 0b76c97445 Redesign Team, Support, AI & Rules; solid-orange brand system
- Team Management: crew hero, member gallery + list, role rings, invites
- Support Center: command-center Overview (live status, pulse ribbon, signal)
- AI Assistant: animated orb rings + textured stage
- Rules: rulebook cards (watermark, uniform orange), 3-up responsive grid
- Brand: --grad-brand now solid rgba(253,169,19,.92), white text/icons on
  orange, no orange gradients; dark surfaces (--card-grad/--panel-2) #060608
- Segmented tabs get an on-brand orange active state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:40:29 +05:30
Goutam d7eb82f182 Redesign Support as bento hub, restore full sidebar, Western names
- Support Center overview rebuilt as a bento grid: large live-chat hero
  tile, support team tile, mini channel tiles and a recent-tickets strip.
- Restore full workspace sidebar (Workspace/Team/Profile groups) with a
  premium golden active state; unbuilt modules show a Coming Soon view.
- Use Western names across the UI (James Carter; Emma Wilson, Liam Foster,
  Sophie Turner, Noah Mitchell) incl. tickets, threads, portal demo user.
- Remove the Allottee card from Profile; add a clean Account & identity
  card and live-editable contacts.
- Relabel "Plot" -> "House number" everywhere; drop the allotment chip in
  favour of the property category.
- Premium visual pass: brand gradients, depth, glow accents, pill tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:19:07 +05:30
Goutam 3ab6768c1c Replace dashboard with Profile, Support Center & Rules modules
- Remove old dashboard stats/charts content; keep sidebar + header shell
  which now routes between Profile, Support Center and Rules views.
- Profile: hero card + 6 tabs (Personal Info with verify-to-reveal +
  editable contacts via OTP, KYC two-doc rule + completion %, Security
  password policy + 2FA, Notifications matrix + contact windows, Privacy
  consents, Devices + activity timeline).
- Support Center: 5 channel cards, support team, Message Center with
  typing, New Ticket category->department routing + attachment rules,
  My Tickets + status-timeline modal, Live Chat handshake, Callback/Email
  modals, Help Center search + accordion.
- Rules: the 1-13 business-rules checklist.
- Shared design system (Icon, Avatar, PageHead, Pill, Toggle, OtpField,
  Modal, Toast, Field, Tabs) + full mock data in account-data.ts.
- Premium dark visual pass: brand gradients, depth, glow accents, pill tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:52:34 +05:30
50 changed files with 8091 additions and 1017 deletions
+16
View File
@@ -0,0 +1,16 @@
# appshell-sdk / Shell BFF config. Leave unset to keep the existing mock portal
# (the app still runs). Set these once the Shell BFF is deployed for real auth.
# Browser-safe Supabase config (the anon key is PUBLISHABLE — never service_role):
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-anon-key>
# Where the browser reaches the Shell BFF (same-origin path; rewritten to BFF_ORIGIN).
NEXT_PUBLIC_BFF_BASE_URL=/shell
# The deployed Shell BFF origin — the /shell/* rewrite proxies here (see next.config.ts).
BFF_ORIGIN=http://localhost:4000
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
# locally: export NODE_AUTH_TOKEN=<token> before npm install
# Vercel: set NODE_AUTH_TOKEN as a project env var
+3
View File
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.vercel
.env*.local
+6
View File
@@ -0,0 +1,6 @@
@abe-kap:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# @insignia/* (iios-kernel-client — the MessageSocket live stream) resolve from the
# self-hosted Gitea npm registry. Install needs a token with `read:package` in GITEA_TOKEN.
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
+57
View File
@@ -0,0 +1,57 @@
# AppShell integration (lynkeduppro-crm)
This app now consumes **`@abe-kap/appshell-sdk`** for authentication, identity, and
(eventually) the data door. The integration follows a **strangler pattern**: the SDK
is wired in everywhere, but every real call is gated behind `isShellConfigured()`. When
the Shell isn't configured (no `NEXT_PUBLIC_SUPABASE_URL`), the app behaves exactly as
before — the existing mock portal and static demo user remain the fallback, so the
deployed demo keeps working until the Shell BFF is live.
## What flips it on
Set these (see `.env.local.example`) and redeploy:
| Var | Purpose |
| --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | Presence flips the app from mock → real auth (the `isShellConfigured()` switch). |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe publishable anon key. |
| `NEXT_PUBLIC_BFF_BASE_URL` | Where the browser reaches the BFF (default `/shell`). |
| `BFF_ORIGIN` | Deployed Shell BFF origin; the `/shell/*` rewrite proxies here. |
| `NODE_AUTH_TOKEN` | `read:packages` token to install the SDK from GitHub Packages (local + Vercel). |
## How it's wired
- **`src/app/providers.tsx`** — mounts `<AppShellProvider>` app-wide (via the root
layout). `auth` is passed **only when Supabase is configured**; otherwise the provider
mounts without auth and `useAuth()` reports `unauthenticated` (never crashes).
- **`next.config.ts`** — `transpilePackages` for the SDK + a `/shell/* → ${BFF_ORIGIN}/api/*`
rewrite so the HttpOnly session cookie flows same-origin. We use `/shell` (not `/api`)
to avoid clobbering the existing `/api/geo` route.
- **Login** (`components/portal/login-flow.tsx`) — password sign-in → `login()`,
social buttons → `loginWithOAuth()` (google/microsoft→azure/apple), and the OAuth
return is finished with `completeOAuthLogin()`. A resolved ACE goes straight to
`/dashboard` (Supabase handles MFA server-side; no mock 2-step).
- **Register** (`components/portal/register-flow.tsx`) — email/password sign-ups call
`register()` on finish. (SSO sign-ups already hold a session from OAuth.)
- **Dashboard gate** (`components/dashboard/auth-gate.tsx`) — bounces unauthenticated
visitors to the portal; pass-through when the Shell is off.
- **Topbar** (`components/dashboard/topbar.tsx`) — shows the real identity from the
App Context Envelope (`useAuth().user`), falling back to the static demo user.
## Local build note
`next build` needs Node ≥ 20.9. Installing the SDK needs a GitHub Packages token; for a
token-free local build you can symlink the workspace copy:
```
mkdir -p node_modules/@abe-kap
ln -s /abs/path/to/appshell-sdk node_modules/@abe-kap/appshell-sdk
```
## Not yet wired (follow-ups)
- Register-via-OAuth still uses the mock `startSocial` (login OAuth is real).
- Email/phone OTP steps in register are still mock (SDK has `sendEmailOtp`/`verifyEmailOtp`).
- Passkey enrollment, tenant switcher, logout menu, and the data door (`useQuery`/
`command`) are available in the SDK but not surfaced in the UI yet.
- Backend integration (frontend ↔ be-crm through the data door) is deliberately deferred.
+9 -1
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
async rewrites() {
const bff = process.env.BFF_ORIGIN ?? "http://localhost:4000";
return [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }];
},
}; };
export default nextConfig; export default nextConfig;
+880 -21
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,8 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "16.2.9", "next": "16.2.9",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

+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>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
// Server-side proxy to be-crm's public invitation lookup, so the invite landing page
// can learn the invited email + roles and whether an account exists — before the
// invitee has a session. Server-to-server avoids CORS. No secrets involved (the token
// is the only credential, and it was emailed to the invitee).
export const runtime = "nodejs";
const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, "");
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token") ?? "";
if (token.length < 16 || token.length > 256) {
return NextResponse.json({ ok: false, status: "invalid" }, { status: 400 });
}
try {
const res = await fetch(`${CRM_BASE_URL}/public/invitations/lookup?token=${encodeURIComponent(token)}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
}
}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,5 +1,10 @@
import { Dashboard } from "@/components/dashboard/dashboard"; import { Dashboard } from "@/components/dashboard/dashboard";
import { AuthGate } from "@/components/dashboard/auth-gate";
export default function DashboardPage() { export default function DashboardPage() {
return <Dashboard />; return (
<AuthGate>
<Dashboard />
</AuthGate>
);
} }
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { Providers } from "./providers";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -31,7 +32,9 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { CookieBanner, Spinner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
interface InviteInfo { ok: boolean; status?: string; email?: string; roleNames?: string[]; hasAccount?: boolean }
/**
* Team invitation landing page (/portal/invite?token=). It looks the token up
* (public, pre-auth) to learn the invited email and whether an account exists, then:
* - signed in + registered accept now (creates the membership with the invited role);
* - signed in, no CRM profile onboarding, which redeems the token on finish;
* - not signed in + email already has an account sign in (email prefilled);
* - not signed in + first-time invitee register (email prefilled + locked).
* The register/login/onboarding flows redeem the stashed token on completion.
*/
export default function InvitePage() {
const router = useRouter();
const { status, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [error, setError] = useState("");
const started = useRef(false);
useEffect(() => {
let token = "";
try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ }
if (!token) { setError("This invitation link is invalid or incomplete."); return; }
if (!isShellConfigured()) { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } router.replace("/portal/register"); return; }
if (!ready || started.current) return;
started.current = true;
(async () => {
try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
// Look the invitation up (pre-auth) to get the email + account state.
let info: InviteInfo = { ok: false };
try {
const res = await fetch(`/api/invite/lookup?token=${encodeURIComponent(token)}`, { cache: "no-store" });
info = await res.json();
} catch { /* treat as unavailable below */ }
if (!info.ok) {
const msg = info.status === "expired" ? "This invitation has expired. Ask for a new one."
: info.status === "accepted" ? "This invitation has already been used."
: info.status === "revoked" ? "This invitation was revoked."
: "This invitation link is invalid.";
setError(msg);
try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ }
return;
}
if (info.email) { try { sessionStorage.setItem("invite_email", info.email); } catch { /* ignore */ } }
// Signed in already: accept if registered, else finish onboarding first.
if (status === "authenticated") {
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
if (!st?.registered) {
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
return;
}
} catch { /* fall through to accept */ }
try {
await sdk.command("crm.team.invitation.accept", { token });
try { sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_email"); } catch { /* ignore */ }
router.replace("/dashboard");
} catch {
setError("We couldn't accept this invitation — it may have expired or already been used.");
}
return;
}
// Not signed in: existing account → sign in; first-time invitee → register.
router.replace(info.hasAccount ? "/portal/login" : "/portal/register");
})();
}, [ready, status, router, sdk, getUserEmail]);
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
{error ? (
<>
<h1>Invitation problem</h1>
<p className="sub">{error}</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => router.replace("/portal/login")}>
Go to sign in
</button>
</>
) : (
<div className="interstitial">
<Spinner lg />
<h1 style={{ fontSize: 20, marginTop: 8 }}>Checking your invitation</h1>
<p className="sub">One moment while we set things up.</p>
</div>
)}
</div>
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
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";
import { RegisterFlow } from "@/components/portal/register-flow";
import { CookieBanner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
/**
* Post-OAuth onboarding a first-time Google user completes their CRM profile
* (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()) { 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" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<RegisterFlow mode="onboard" />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
// AppShell runs in the browser (boot, React context), so the provider is a
// Client Component. When Supabase env is NOT configured (no BFF yet) we mount the
// provider WITHOUT `auth` — `useAuth()` still works (returns nulls / status
// "unauthenticated"), and the existing mock portal remains the fallback. Once the
// env is set + the Shell BFF is deployed, real auth flows through appshell-sdk.
import { AppShellProvider } from "@abe-kap/appshell-sdk/react";
import type { AppShellOptions } from "@abe-kap/appshell-sdk";
import type { ReactNode } from "react";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const bffBaseUrl = process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell";
const options: AppShellOptions = supabaseUrl
? {
auth: {
supabaseUrl,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
appId: "crm-web",
},
bffBaseUrl,
}
: { bffBaseUrl };
export function Providers({ children }: { children: ReactNode }) {
return <AppShellProvider options={options}>{children}</AppShellProvider>;
}
+566
View File
@@ -0,0 +1,566 @@
// ============================================================
// LynkedUp Pro — Account / Profile / Support mock data (D).
// Everything here is a client-side mock so the screens are
// fully clickable without a backend. Ready-to-paste shapes.
//
// Anchored to the same demo identity used by the auth portal:
// James Carter · Allotment YEA-654321 · House 181.
// ============================================================
/* ---------------------------------------------------------- */
/* helpers */
/* ---------------------------------------------------------- */
export function maskEmail(email: string): string {
const [user = "", domain = ""] = email.split("@");
if (!domain) return email;
if (user.length <= 2) return `${user[0] ?? ""}••@${domain}`;
return `${user[0]}${"•".repeat(Math.max(4, user.length - 2))}${user[user.length - 1]}@${domain}`;
}
export function maskPhone(phone: string): string {
const tail = phone.replace(/\D/g, "").slice(-2);
return `+91 ••••• ••${tail}`;
}
/* ---------------------------------------------------------- */
/* USER — the signed-in account holder */
/* ---------------------------------------------------------- */
export type RevealField = { label: string; masked: string; full: string };
export type User = {
id: string;
name: string;
firstName: string;
lastName: string;
initials: string;
role: string;
avatarGradient: string;
// property / allotment
allotmentNo: string;
plot: string; // house number — verify-to-reveal key
sector: string;
pocket: string;
category: string;
size: string;
// allottee vs account holder
isAllottee: boolean;
relationship: string; // "Self" when account holder IS allottee
allotteeName: string;
// contact (masked until verified)
email: RevealField;
mobile: RevealField;
whatsapp: RevealField;
pan: RevealField;
aadhaar: RevealField;
memberSince: string;
status: "active" | "suspended";
};
export const user: User = {
id: "usr_LUP_00181",
name: "James Carter",
firstName: "James",
lastName: "Carter",
initials: "JC",
role: "Property Owner",
avatarGradient: "linear-gradient(135deg,#fda913,#fd6d13)",
allotmentNo: "YEA-654321",
plot: "181",
sector: "Sector 18",
pocket: "Pocket B",
category: "Residential",
size: "300 sq.m",
isAllottee: true,
relationship: "Self",
allotteeName: "James Carter",
email: { label: "Email", masked: maskEmail("james.carter@example.com"), full: "james.carter@example.com" },
mobile: { label: "Mobile", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
whatsapp: { label: "WhatsApp", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
pan: { label: "PAN", masked: "•••••••• 12F", full: "ABCPS1234F" },
aadhaar: { label: "Aadhaar", masked: "•••• •••• 9012", full: "4321 8765 9012" },
memberSince: "March 2021",
status: "active",
};
/* ---------------------------------------------------------- */
/* COUNTRY CODES */
/* ---------------------------------------------------------- */
export type CountryCode = { code: string; flag: string; name: string; digits: number; example: string };
export const countryCodes: CountryCode[] = [
{ code: "+91", flag: "🇮🇳", name: "India", digits: 10, example: "98765 43210" },
{ code: "+1", flag: "🇺🇸", name: "United States", digits: 10, example: "201 555 0123" },
{ code: "+44", flag: "🇬🇧", name: "United Kingdom", digits: 10, example: "7400 123456" },
{ code: "+971", flag: "🇦🇪", name: "UAE", digits: 9, example: "50 123 4567" },
{ code: "+65", flag: "🇸🇬", name: "Singapore", digits: 8, example: "8123 4567" },
{ code: "+61", flag: "🇦🇺", name: "Australia", digits: 9, example: "412 345 678" },
];
/* ---------------------------------------------------------- */
/* KYC */
/* ---------------------------------------------------------- */
export type KycUse = "id" | "address" | "any";
export type KycDocType = {
id: string;
label: string;
use: KycUse; // what this document can prove
hint: string;
formats: string; // accepted attachment formats
maxMb: number;
};
// Note rule: an ID document and an ADDRESS document must be two
// DIFFERENT document types. A passport (use:"any") may satisfy
// either slot but not both at once.
export const kycDocTypes: KycDocType[] = [
{ id: "aadhaar", label: "Aadhaar Card", use: "any", hint: "Front & back, masked first 8 digits", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "pan", label: "PAN Card", use: "id", hint: "Clear photo of the front", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "passport", label: "Passport", use: "any", hint: "Photo page, must be valid", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "voter", label: "Voter ID", use: "id", hint: "Front & back", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "dl", label: "Driving Licence", use: "id", hint: "Front & back, not expired", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "utility", label: "Utility Bill", use: "address", hint: "Electricity / water, < 3 months old", formats: "PDF, JPG, PNG", maxMb: 8 },
{ id: "bank", label: "Bank Statement", use: "address", hint: "First page with address, < 3 months", formats: "PDF", maxMb: 8 },
{ id: "rent", label: "Registered Rent Agreement", use: "address", hint: "All pages, registered copy", formats: "PDF", maxMb: 10 },
];
export type KycSlot = {
key: "photo" | "id" | "address";
title: string;
required: boolean;
docId: string | null; // chosen kycDocTypes id
fileName: string | null;
status: "missing" | "uploaded" | "verified" | "rejected";
note?: string;
};
export const kyc: { completion: number; slots: KycSlot[] } = {
completion: 33,
slots: [
{ key: "photo", title: "Live Photo / Selfie", required: true, docId: null, fileName: "selfie_rajesh.jpg", status: "verified", note: "Face match passed" },
{ key: "id", title: "Identity Proof", required: true, docId: "pan", fileName: "pan_front.pdf", status: "uploaded", note: "Under review" },
{ key: "address", title: "Address Proof", required: true, docId: null, fileName: null, status: "missing" },
],
};
/* ---------------------------------------------------------- */
/* NOTIFICATIONS — channels + per-destination matrix */
/* ---------------------------------------------------------- */
export type NotifCategory = {
id: string;
label: string;
desc: string;
locked?: boolean; // statutory / security — cannot disable
email: boolean;
sms: boolean;
whatsapp: boolean;
push: boolean;
};
export const contactPrefs: NotifCategory[] = [
{ id: "security", label: "Security & sign-in alerts", desc: "New device, password and 2FA changes", locked: true, email: true, sms: true, whatsapp: false, push: true },
{ id: "kyc", label: "KYC & verification", desc: "Document status and re-verification requests", email: true, sms: true, whatsapp: true, push: true },
{ id: "tickets", label: "Support tickets", desc: "Replies, status changes and resolutions", email: true, sms: false, whatsapp: true, push: true },
{ id: "billing", label: "Billing & dues", desc: "Invoices, payment reminders and receipts", email: true, sms: true, whatsapp: false, push: false },
{ id: "inspections", label: "Inspections & schedule", desc: "Upcoming visits and rescheduling", email: true, sms: false, whatsapp: true, push: true },
{ id: "marketing", label: "Offers & product news", desc: "Promotions, surveys and newsletters", email: false, sms: false, whatsapp: false, push: false },
];
export type ContactDestination = { id: string; channel: "email" | "sms" | "whatsapp"; label: string; value: string; verified: boolean; primary: boolean };
export const contactDestinations: ContactDestination[] = [
{ id: "e1", channel: "email", label: "Personal email", value: "james.carter@example.com", verified: true, primary: true },
{ id: "e2", channel: "email", label: "Work email", value: "j.carter@acme.co", verified: false, primary: false },
{ id: "s1", channel: "sms", label: "Registered mobile", value: "+91 98765 43012", verified: true, primary: true },
{ id: "w1", channel: "whatsapp", label: "WhatsApp", value: "+91 98765 43012", verified: true, primary: true },
];
export const contactTimezones = [
"Asia/Kolkata (IST · GMT+5:30)",
"Asia/Dubai (GST · GMT+4:00)",
"Europe/London (GMT/BST)",
"America/New_York (ET)",
"Asia/Singapore (SGT · GMT+8:00)",
"Australia/Sydney (AET)",
];
// Contact-time windows the user is willing to be reached in.
export type ContactTime = { id: string; label: string; range: string; enabled: boolean };
export const contactTimes: ContactTime[] = [
{ id: "morning", label: "Morning", range: "08:00 12:00", enabled: true },
{ id: "afternoon", label: "Afternoon", range: "12:00 16:00", enabled: true },
{ id: "evening", label: "Evening", range: "16:00 20:00", enabled: true },
{ id: "night", label: "Night", range: "20:00 22:00", enabled: false },
];
/* ---------------------------------------------------------- */
/* SECURITY — login activity / devices */
/* ---------------------------------------------------------- */
export type LoginActivity = {
id: string;
device: string;
os: string;
kind: "desktop" | "mobile" | "tablet";
browser: string;
location: string;
ip: string;
current: boolean;
trusted: boolean;
lastActive: string;
event: "sign-in" | "password-change" | "2fa-enabled" | "new-device" | "sign-out";
};
export const loginActivity: LoginActivity[] = [
{ id: "d1", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: true, trusted: true, lastActive: "Active now", event: "sign-in" },
{ id: "d2", device: "iPhone 15", os: "iOS 18.3", kind: "mobile", browser: "Safari", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "2 hours ago", event: "sign-in" },
{ id: "d3", device: "Windows PC", os: "Windows 11", kind: "desktop", browser: "Edge 131", location: "Gurugram, IN", ip: "182.71.x.x", current: false, trusted: false, lastActive: "Yesterday, 9:14 PM", event: "new-device" },
{ id: "d4", device: "iPad Air", os: "iPadOS 18", kind: "tablet", browser: "Safari", location: "Dubai, AE", ip: "94.205.x.x", current: false, trusted: false, lastActive: "3 days ago", event: "sign-in" },
{ id: "d5", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "5 days ago", event: "password-change" },
];
/* ---------------------------------------------------------- */
/* PRIVACY & CONSENT */
/* ---------------------------------------------------------- */
export type ConsentItem = { id: string; label: string; desc: string; granted: boolean; locked?: boolean };
export type ConsentGroup = { id: string; title: string; items: ConsentItem[] };
export const consentGroups: ConsentGroup[] = [
{
id: "essential",
title: "Essential & statutory",
items: [
{ id: "tos", label: "Terms of Use & service agreement", desc: "Required to operate your account.", granted: true, locked: true },
{ id: "kyc-process", label: "Identity verification processing", desc: "Process KYC documents to meet regulatory obligations.", granted: true, locked: true },
],
},
{
id: "operational",
title: "Operational data sharing",
items: [
{ id: "authority", label: "Share records with the authority", desc: "Sync allotment & inspection records with the development authority.", granted: true },
{ id: "processors", label: "Trusted processors", desc: "Allow vetted vendors to process data on our behalf.", granted: true },
],
},
{
id: "marketing",
title: "Marketing & analytics",
items: [
{ id: "analytics", label: "Product analytics", desc: "Help improve the portal with anonymous usage data.", granted: true },
{ id: "personalize", label: "Personalised recommendations", desc: "Tailor content based on your activity.", granted: false },
{ id: "promos", label: "Promotional messages", desc: "Offers, surveys and partner news.", granted: false },
],
},
];
/* ---------------------------------------------------------- */
/* SUPPORT — channels, team, tickets, threads, help */
/* ---------------------------------------------------------- */
export type SupportChannel = {
id: string;
title: string;
desc: string;
icon: string; // Icon name
accent: string; // token var
meta: string; // SLA / availability
action: string; // CTA label
status: "online" | "busy" | "offline";
};
export const supportChannels: SupportChannel[] = [
{ id: "chat", title: "Live Chat", desc: "Instant help from a support agent", icon: "chat", accent: "var(--orange)", meta: "Avg. wait < 2 min", action: "Start chat", status: "online" },
{ id: "ticket", title: "Raise a Ticket", desc: "Track a request end-to-end", icon: "ticket", accent: "var(--blue)", meta: "First reply in 4 hrs", action: "New ticket", status: "online" },
{ id: "callback", title: "Request Callback", desc: "We'll call you in your time window", icon: "phone", accent: "var(--green)", meta: "Within working hours", action: "Schedule", status: "online" },
{ id: "email", title: "Email Support", desc: "care@lynkeduppro.com", icon: "mail", accent: "var(--purple)", meta: "Reply in 1 business day", action: "Send email", status: "online" },
{ id: "help", title: "Help Center", desc: "Guides, FAQs and how-tos", icon: "book", accent: "var(--cyan)", meta: "120+ articles", action: "Browse", status: "online" },
];
export type SupportAgent = {
id: string;
name: string;
initials: string;
role: string;
team: string;
gradient: string;
status: "online" | "away" | "offline";
rating: number;
};
export const supportTeam: SupportAgent[] = [
{ id: "a1", name: "Emma Wilson", initials: "EW", role: "Senior Support Lead", team: "Accounts & KYC", gradient: "linear-gradient(135deg,#fda913,#fd6d13)", status: "online", rating: 4.9 },
{ id: "a2", name: "Liam Foster", initials: "LF", role: "Support Specialist", team: "Billing", gradient: "linear-gradient(135deg,#285ef0,#09b9c6)", status: "online", rating: 4.8 },
{ id: "a3", name: "Sophie Turner", initials: "ST", role: "Technical Support", team: "Portal & Login", gradient: "linear-gradient(135deg,#9036e9,#285ef0)", status: "away", rating: 4.7 },
{ id: "a4", name: "Noah Mitchell", initials: "NM", role: "Field Coordinator", team: "Inspections", gradient: "linear-gradient(135deg,#14bc83,#09b9c6)", status: "offline", rating: 4.9 },
];
// Category → department routing + attachment rules for the New Ticket form.
export type TicketCategory = {
id: string;
label: string;
department: string;
sla: string;
requiresAttachment: boolean;
attachmentNote: string;
};
export const ticketCategories: TicketCategory[] = [
{ id: "kyc", label: "KYC / Document verification", department: "Accounts & KYC", sla: "4 business hours", requiresAttachment: true, attachmentNote: "Attach the document in question (PDF/JPG/PNG, ≤ 8 MB)." },
{ id: "login", label: "Login / Account access", department: "Portal & Login", sla: "2 business hours", requiresAttachment: false, attachmentNote: "Screenshots optional (PNG/JPG, ≤ 5 MB)." },
{ id: "billing", label: "Billing & payments", department: "Billing", sla: "1 business day", requiresAttachment: true, attachmentNote: "Attach invoice or receipt (PDF, ≤ 8 MB)." },
{ id: "allotment", label: "Allotment / Property records", department: "Accounts & KYC", sla: "1 business day", requiresAttachment: false, attachmentNote: "Allotment letter optional (PDF, ≤ 10 MB)." },
{ id: "inspection", label: "Inspection / Site visit", department: "Inspections", sla: "1 business day", requiresAttachment: false, attachmentNote: "Site photos optional (JPG/PNG, ≤ 10 MB each)." },
{ id: "other", label: "Something else", department: "General Support", sla: "1 business day", requiresAttachment: false, attachmentNote: "Attachments optional (≤ 8 MB)." },
];
export type TicketStatus = "open" | "in-progress" | "awaiting-customer" | "resolved" | "closed";
export type TicketEvent = { status: TicketStatus | "created"; label: string; at: string; by: string };
export type Ticket = {
id: string;
subject: string;
category: string; // TicketCategory label
department: string;
priority: "low" | "medium" | "high" | "urgent";
status: TicketStatus;
createdAt: string;
updatedAt: string;
agentId: string | null;
timeline: TicketEvent[];
};
export const tickets: Ticket[] = [
{
id: "LUP-48217",
subject: "Aadhaar re-upload not reflecting",
category: "KYC / Document verification",
department: "Accounts & KYC",
priority: "high",
status: "in-progress",
createdAt: "24 Jun 2026",
updatedAt: "28 Jun 2026",
agentId: "a1",
timeline: [
{ status: "created", label: "Ticket created", at: "24 Jun, 10:12 AM", by: "You" },
{ status: "open", label: "Routed to Accounts & KYC", at: "24 Jun, 10:13 AM", by: "System" },
{ status: "in-progress", label: "Emma Wilson picked up the ticket", at: "24 Jun, 11:40 AM", by: "Emma Wilson" },
{ status: "awaiting-customer", label: "Requested a clearer scan", at: "25 Jun, 09:05 AM", by: "Emma Wilson" },
{ status: "in-progress", label: "You re-uploaded the document", at: "28 Jun, 04:22 PM", by: "You" },
],
},
{
id: "LUP-47980",
subject: "Unable to receive login OTP on mobile",
category: "Login / Account access",
department: "Portal & Login",
priority: "urgent",
status: "resolved",
createdAt: "18 Jun 2026",
updatedAt: "19 Jun 2026",
agentId: "a3",
timeline: [
{ status: "created", label: "Ticket created", at: "18 Jun, 08:40 PM", by: "You" },
{ status: "in-progress", label: "Sophie Turner investigating SMS gateway", at: "18 Jun, 09:02 PM", by: "Sophie Turner" },
{ status: "resolved", label: "OTP delivery restored", at: "19 Jun, 10:15 AM", by: "Sophie Turner" },
],
},
{
id: "LUP-47512",
subject: "Duplicate maintenance invoice for June",
category: "Billing & payments",
department: "Billing",
priority: "medium",
status: "awaiting-customer",
createdAt: "12 Jun 2026",
updatedAt: "26 Jun 2026",
agentId: "a2",
timeline: [
{ status: "created", label: "Ticket created", at: "12 Jun, 02:10 PM", by: "You" },
{ status: "in-progress", label: "Liam Foster reviewing billing run", at: "12 Jun, 03:30 PM", by: "Liam Foster" },
{ status: "awaiting-customer", label: "Need bank reference for the second debit", at: "26 Jun, 11:00 AM", by: "Liam Foster" },
],
},
{
id: "LUP-46330",
subject: "Reschedule roof inspection visit",
category: "Inspection / Site visit",
department: "Inspections",
priority: "low",
status: "closed",
createdAt: "02 Jun 2026",
updatedAt: "06 Jun 2026",
agentId: "a4",
timeline: [
{ status: "created", label: "Ticket created", at: "02 Jun, 06:20 PM", by: "You" },
{ status: "in-progress", label: "Noah Mitchell coordinating slot", at: "03 Jun, 10:00 AM", by: "Noah Mitchell" },
{ status: "resolved", label: "Visit moved to 09 Jun, 11 AM", at: "04 Jun, 04:45 PM", by: "Noah Mitchell" },
{ status: "closed", label: "Inspection completed", at: "06 Jun, 01:30 PM", by: "System" },
],
},
];
// Message Center — inbox threads + messages (with a typing flag).
export type ChatMsg = { id: string; from: "me" | "agent"; text: string; at: string };
export type SupportThread = {
id: string;
agentId: string;
subject: string;
preview: string;
unread: number;
pinned: boolean;
typing: boolean;
updatedAt: string;
messages: ChatMsg[];
};
export const supportThreads: SupportThread[] = [
{
id: "t1",
agentId: "a1",
subject: "KYC — Aadhaar re-upload",
preview: "Thanks, I can see the new scan now…",
unread: 2,
pinned: true,
typing: true,
updatedAt: "2 min ago",
messages: [
{ id: "m1", from: "agent", text: "Hi James, this is Emma from Accounts & KYC. I'm looking into ticket LUP-48217.", at: "11:40 AM" },
{ id: "m2", from: "me", text: "Thanks Emma. The earlier scan was blurry so I uploaded a sharper one.", at: "11:42 AM" },
{ id: "m3", from: "agent", text: "Thanks, I can see the new scan now. Reviewing it with the verification team.", at: "11:43 AM" },
],
},
{
id: "t2",
agentId: "a2",
subject: "Billing — duplicate invoice",
preview: "Could you share the bank reference number?",
unread: 0,
pinned: false,
typing: false,
updatedAt: "Yesterday",
messages: [
{ id: "m1", from: "agent", text: "Hello! I'm reviewing the June billing run for the duplicate charge.", at: "3:30 PM" },
{ id: "m2", from: "agent", text: "Could you share the bank reference number for the second debit?", at: "3:32 PM" },
],
},
{
id: "t3",
agentId: "a3",
subject: "Login — OTP not received",
preview: "Glad it's working now. Closing this out.",
unread: 0,
pinned: false,
typing: false,
updatedAt: "19 Jun",
messages: [
{ id: "m1", from: "me", text: "I stopped getting the OTP on my registered mobile last night.", at: "8:40 PM" },
{ id: "m2", from: "agent", text: "There was a gateway issue in your region — fixed now. Please retry.", at: "10:14 AM" },
{ id: "m3", from: "me", text: "Working now, thank you!", at: "10:20 AM" },
{ id: "m4", from: "agent", text: "Glad it's working now. Closing this out.", at: "10:21 AM" },
],
},
];
/* ---------------------------------------------------------- */
/* HELP CENTER */
/* ---------------------------------------------------------- */
export type HelpArticle = { q: string; a: string };
export type HelpTopic = { id: string; title: string; icon: string; accent: string; count: number; articles: HelpArticle[] };
export const helpTopics: HelpTopic[] = [
{
id: "getting-started",
title: "Getting started",
icon: "rocket",
accent: "var(--orange)",
count: 3,
articles: [
{ q: "How do I verify my property details?", a: "Open Profile → Personal Info and click Verify to reveal. Enter your house number (e.g. 181) to unmask and confirm your records." },
{ q: "What does allottee mean if I'm a representative?", a: "If you manage the property on someone's behalf, the account holder and the allottee differ. Your relationship is shown on the Personal Info tab." },
{ q: "How do I update my profile photo?", a: "Use the camera button on your profile header card. JPG or PNG up to 5 MB." },
],
},
{
id: "kyc",
title: "KYC & verification",
icon: "shield",
accent: "var(--blue)",
count: 4,
articles: [
{ q: "Which documents do I need for KYC?", a: "A live photo (mandatory), one identity proof and one address proof. The identity and address documents must be two different document types." },
{ q: "Can I use Aadhaar for both ID and address?", a: "No. ID and address proofs must be different document types. Aadhaar can fill either slot, but not both at the same time." },
{ q: "How long does verification take?", a: "Most documents are reviewed within 4 business hours. You'll be notified on your registered channels." },
{ q: "My document was rejected — what now?", a: "Open the rejected slot, read the reason, and re-upload a clearer copy. A support ticket is created automatically for high-priority cases." },
],
},
{
id: "security",
title: "Login & security",
icon: "lock",
accent: "var(--purple)",
count: 4,
articles: [
{ q: "How do I enable two-factor authentication?", a: "Profile → Security → Two-factor. Enabling 2FA requires both your password and a one-time code for confirmation." },
{ q: "I'm not receiving my OTP", a: "OTPs are sent to your registered mobile. Check signal and spam filters; if it persists, raise a ticket under Login / Account access." },
{ q: "What is the password policy?", a: "At least 8 characters with upper & lower case, a number and a special character — and no common patterns like 1234 or password." },
{ q: "How do I sign out other devices?", a: "Profile → Devices shows every active session. Use Sign out on any device, or Sign out all other devices." },
],
},
{
id: "billing",
title: "Billing & payments",
icon: "card",
accent: "var(--green)",
count: 3,
articles: [
{ q: "Where can I download invoices?", a: "Invoices are emailed to your primary email and available under Billing. Receipts are issued within minutes of payment." },
{ q: "I was charged twice", a: "Raise a Billing ticket and attach the invoice/receipt. Keep your bank reference handy to speed up the reconciliation." },
{ q: "How do I change my billing email?", a: "Add a destination under Notifications and mark it primary for the Billing category in the per-destination matrix." },
],
},
];
/* ---------------------------------------------------------- */
/* RULES — the 113 business-rules checklist */
/* ---------------------------------------------------------- */
export type Rule = { n: number; title: string; detail: string; tag: string; tagColor: string };
export const rules: Rule[] = [
{ n: 1, title: "Verify-to-reveal", detail: "Masked personal details (email, mobile, PAN, Aadhaar) stay hidden until the account holder confirms their house number. For this account the house number is 181.", tag: "Profile", tagColor: "var(--orange)" },
{ n: 2, title: "OTP goes to the registered mobile", detail: "Any sensitive change — email, mobile or password — sends a one-time code to the registered mobile number, never to the new/unverified destination.", tag: "Security", tagColor: "var(--blue)" },
{ n: 3, title: "Allottee ≠ account holder", detail: "When the relationship is not Self, the account holder is a representative and the allottee's name is shown separately. Actions are logged against the account holder.", tag: "Profile", tagColor: "var(--orange)" },
{ n: 4, title: "KYC needs two different documents", detail: "Identity proof and address proof must be two different document types. A single document (e.g. Aadhaar) cannot satisfy both slots at once.", tag: "KYC", tagColor: "var(--purple)" },
{ n: 5, title: "Live photo is mandatory", detail: "A live photo / selfie is required for KYC completion in addition to the ID and address proofs. Completion percentage never reaches 100% without it.", tag: "KYC", tagColor: "var(--purple)" },
{ n: 6, title: "Password policy", detail: "Minimum 8 characters with upper & lower case, a number and a special character. Common patterns like 1234, qwerty or password are rejected.", tag: "Security", tagColor: "var(--blue)" },
{ n: 7, title: "2FA requires password + OTP", detail: "Turning two-factor authentication on or off requires re-entering the password and confirming a one-time code. Authenticator apps and login alerts build on this.", tag: "Security", tagColor: "var(--blue)" },
{ n: 8, title: "Ticket lifecycle", detail: "Tickets move Open → In Progress → Awaiting Customer → Resolved → Closed. Every transition is timestamped and attributed in the status timeline.", tag: "Support", tagColor: "var(--green)" },
{ n: 9, title: "Category → department routing", detail: "Each ticket category auto-routes to the right department (e.g. KYC → Accounts & KYC) with its own SLA shown before submission.", tag: "Support", tagColor: "var(--green)" },
{ n: 10, title: "Attachment rules", detail: "Some categories require an attachment; each defines accepted formats and a maximum file size. The form blocks submission until required attachments are present.", tag: "Support", tagColor: "var(--green)" },
{ n: 11, title: "Contact-time windows + timezone", detail: "Callbacks and proactive messages are only sent inside the contact-time windows the user enables, interpreted in their selected timezone.", tag: "Notifications", tagColor: "var(--cyan)" },
{ n: 12, title: "Statutory consent is locked", detail: "Essential and statutory consents (Terms of Use, KYC processing) cannot be switched off while the account is active. Marketing consents and sub-toggles are fully optional.", tag: "Privacy", tagColor: "var(--red)" },
{ n: 13, title: "New-device login alerts", detail: "Signing in from an untrusted device raises a security alert on the registered channels and lists the device under Devices for review or sign-out.", tag: "Security", tagColor: "var(--blue)" },
];
/* ---------------------------------------------------------- */
/* password strength (shared with Security tab) */
/* ---------------------------------------------------------- */
export type Strength = { score: number; label: "Weak" | "Fair" | "Good" | "Strong"; checks: { ok: boolean; label: string }[] };
export function passwordStrength(pw: string): Strength {
const checks = [
{ ok: pw.length >= 8, label: "At least 8 characters" },
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: "Upper & lower case" },
{ ok: /\d/.test(pw), label: "A number" },
{ ok: /[^A-Za-z0-9]/.test(pw), label: "A special character" },
];
const passed = checks.filter((c) => c.ok).length;
const weakList = ["1234", "abcd", "password", "qwerty", "0000"];
const isWeak = weakList.some((w) => pw.toLowerCase().includes(w));
const score = isWeak ? Math.min(passed, 1) : passed;
const label = score <= 1 ? "Weak" : score === 2 ? "Fair" : score === 3 ? "Good" : "Strong";
return { score, label, checks };
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
// ============================================================
// AI Assistant — "Lynk AI", a creative conversational copilot.
// · Empty state : warm greeting, capability cards, prompt chips
// · Active chat : message thread with a typing indicator and a
// simulated, keyword-aware assistant reply
// · Composer : auto-suggesting prompt row + send / new chat
// All responses are canned + client-side so it demos with no API.
// ============================================================
import { useEffect, useRef, useState } from "react";
import { user } from "./account-data";
import { Avatar, Btn, Icon, Pill, useToast } from "./ui";
type Msg = { id: number; from: "me" | "ai"; text: string };
const CAPABILITIES = [
{ icon: "leads", tone: "var(--orange)", title: "Qualify leads", desc: "Score and summarise new leads, draft the first outreach." },
{ icon: "estimates", tone: "var(--blue)", title: "Build estimates", desc: "Turn scope notes into a clean, line-itemed proposal." },
{ icon: "schedule", tone: "var(--green)", title: "Plan the week", desc: "Suggest a crew schedule around weather and priorities." },
{ icon: "leaderboard", tone: "var(--purple)", title: "Analyse pipeline", desc: "Spot stalled deals and where revenue is leaking." },
];
const PROMPTS = [
{ icon: "leads", text: "Summarise my new leads from this week" },
{ icon: "estimates", text: "Draft an estimate for a 28-square asphalt reroof" },
{ icon: "storm", text: "Which territories were hit by the last storm?" },
{ icon: "pipeline", text: "Show deals stuck in the pipeline over 14 days" },
];
let msgSeq = 1;
export function AiAssistant() {
const toast = useToast();
const [messages, setMessages] = useState<Msg[]>([]);
const [draft, setDraft] = useState("");
const [typing, setTyping] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const started = messages.length > 0;
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, typing]);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
function send(text: string) {
const body = text.trim();
if (!body || typing) return;
setMessages((m) => [...m, { id: msgSeq++, from: "me", text: body }]);
setDraft("");
setTyping(true);
timer.current = setTimeout(() => {
setMessages((m) => [...m, { id: msgSeq++, from: "ai", text: replyFor(body) }]);
setTyping(false);
}, 1100);
}
function reset() {
if (timer.current) clearTimeout(timer.current);
setMessages([]); setDraft(""); setTyping(false);
}
return (
<div className="view ai-view">
<div className="ai-head">
<div className="ai-head-l">
<span className="ai-mark"><Icon name="ai" size={20} /></span>
<div>
<div className="ai-eyebrow">LynkedUp Pro</div>
<h1>Lynk AI <Pill tone="orange">Beta</Pill></h1>
</div>
</div>
<div className="ai-head-actions">
<span className="ai-model"><span className="ai-model-dot" /> Opus 4.8</span>
{started && <Btn variant="outline" size="sm" icon="plus" onClick={reset}>New chat</Btn>}
</div>
</div>
<div className="ai-stage card card-pad-0">
<div className="ai-scroll" ref={scrollRef}>
{!started ? (
<div className="ai-hero">
<div className="ai-orb-wrap">
<span className="ai-orb-ring" aria-hidden />
<span className="ai-orb-ring d2" aria-hidden />
<span className="ai-orb-ring d3" aria-hidden />
<span className="ai-hero-orb"><Icon name="ai" size={34} /></span>
</div>
<h2>{greeting()}, {user.firstName}.</h2>
<p>I&apos;m your roofing copilot. Ask me to summarise leads, draft estimates, plan crews or dig into your pipeline.</p>
<div className="ai-caps">
{CAPABILITIES.map((c) => (
<button className="ai-cap" key={c.title} style={{ ["--rc" as string]: c.tone }} onClick={() => send(`Help me ${c.title.toLowerCase()}`)}>
<span className="ai-cap-ic"><Icon name={c.icon} size={18} /></span>
<span className="ai-cap-title">{c.title}</span>
<span className="ai-cap-desc">{c.desc}</span>
</button>
))}
</div>
<div className="ai-suggest">
<span className="ai-suggest-lbl">Try asking</span>
<div className="ai-chips">
{PROMPTS.map((p) => (
<button className="ai-chip" key={p.text} onClick={() => send(p.text)}>
<Icon name={p.icon} size={14} /> {p.text}
</button>
))}
</div>
</div>
</div>
) : (
<div className="ai-thread">
{messages.map((m) => (
<div className={`ai-msg ${m.from}`} key={m.id}>
{m.from === "ai"
? <span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
: <Avatar initials={user.initials} gradient={user.avatarGradient} size={32} />}
<div className="ai-bubble">
{m.text.split("\n").map((line, i) => <p key={i}>{line}</p>)}
{m.from === "ai" && (
<div className="ai-msg-actions">
<button onClick={() => { navigator.clipboard?.writeText(m.text); toast.push({ tone: "success", title: "Copied to clipboard" }); }} aria-label="Copy"><Icon name="copy" size={13} /></button>
<button onClick={() => toast.push({ tone: "success", title: "Thanks for the feedback" })} aria-label="Good response"><Icon name="check" size={13} /></button>
</div>
)}
</div>
</div>
))}
{typing && (
<div className="ai-msg ai">
<span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
<div className="ai-bubble ai-typing"><span /><span /><span /></div>
</div>
)}
</div>
)}
</div>
<div className="ai-composer">
{started && (
<div className="ai-quickrow">
{PROMPTS.slice(0, 3).map((p) => (
<button key={p.text} className="ai-quick" onClick={() => send(p.text)}><Icon name={p.icon} size={13} /> {p.text}</button>
))}
</div>
)}
<div className="ai-inputbar">
<button className="ai-attach" aria-label="Attach" onClick={() => toast.push({ tone: "info", title: "Attachments", desc: "File uploads land here soon." })}><Icon name="paperclip" size={18} /></button>
<textarea
className="ai-textarea"
rows={1}
placeholder="Ask Lynk AI anything about your roofing business…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(draft); } }}
/>
<button className="ai-send" aria-label="Send" disabled={!draft.trim() || typing} onClick={() => send(draft)}><Icon name="send" size={17} /></button>
</div>
<div className="ai-foot">Lynk AI can make mistakes. Verify estimates and figures before sending to owners.</div>
</div>
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* helpers */
/* ---------------------------------------------------------- */
function greeting(): string {
// Deterministic across renders to avoid hydration drift — anchored to a
// fixed demo time-of-day rather than the live clock.
return "Good evening";
}
// Lightweight keyword router so the demo feels responsive without an API.
function replyFor(q: string): string {
const t = q.toLowerCase();
if (t.includes("lead")) {
return "You have 6 new leads this week — 4 inbound from the storm landing page and 2 referrals.\nTop priority: Mark Reynolds (Sector 18) — full roof replacement, insurance claim already filed. I've drafted a first-touch SMS; want me to queue it?";
}
if (t.includes("estimate") || t.includes("proposal") || t.includes("reroep") || t.includes("reroof")) {
return "Here's a starting estimate for a 28-square asphalt reroof:\n• Tear-off & disposal — $3,920\n• Architectural shingles (28 sq) — $7,560\n• Underlayment, flashing & ridge vent — $2,180\n• Labour (3-day crew) — $5,400\nSubtotal $19,060 · 10% margin → $20,966. Want it itemised into a sendable proposal?";
}
if (t.includes("schedule") || t.includes("crew") || t.includes("week") || t.includes("plan")) {
return "Clear weather TueThu, rain Friday. Suggested plan:\n• Tue: Crew A → 181 Sector 18 (reroof, day 1)\n• Wed: Crew A continues; Crew B → inspections in Pocket B\n• Thu: finish 181, start estimates follow-ups\nShould I block these in Team Schedule?";
}
if (t.includes("storm") || t.includes("territory") || t.includes("weather")) {
return "Last cell tracked through Sectors 14, 18 and Pocket B on 26 Jun with 1.5\" hail. 23 owners in those zones are good storm-canvass targets — 9 already have open claims. Want a canvass route?";
}
if (t.includes("pipeline") || t.includes("stuck") || t.includes("stall") || t.includes("deal")) {
return "3 deals have been idle over 14 days:\n• Bennett reroof — $21k, no contact in 18 days\n• Foster gutters — $4.2k, awaiting estimate\n• Turner inspection — booked, never closed\nThe Bennett deal is your biggest risk. Draft a re-engagement email?";
}
return "Got it. I can pull leads, build estimates, plan crew schedules, read storm/territory data and analyse your pipeline. Tell me which one to start with, or ask a specific question and I'll dig in.";
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
/**
* Client gate for the dashboard. When the Shell is live, an unauthenticated
* visitor is bounced to the portal; while the session is resolving we hold a
* lightweight splash. When the Shell is NOT configured this is a pass-through,
* so the existing mock demo (any login /dashboard) keeps working unchanged.
*/
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const shell = isShellConfigured();
useEffect(() => {
// Only bounce to login once the SDK has finished booting AND restore() has
// resolved to unauthenticated. Redirecting while boot is still in flight would
// drop a perfectly valid session on reload (the status is transiently not-yet
// "authenticated" during boot).
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
}, [shell, ready, status, router]);
if (shell && (!ready || status !== "authenticated")) {
return (
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
</div>
);
}
return <>{children}</>;
}
-180
View File
@@ -1,180 +0,0 @@
"use client";
import { useEffect, useState } from "react";
/* ---- vertical bar chart (HTML/CSS, animated heights) ---- */
export function BarsChart({
labels,
series,
max,
yTicks,
height = 132,
highlight,
barWidth = 7,
}: {
labels: string[];
series: { color: string; values: number[] }[];
max: number;
yTicks?: string[];
height?: number;
highlight?: number; // single-series: index to highlight, others muted
barWidth?: number;
}) {
const [on, setOn] = useState(false);
useEffect(() => {
const t = setTimeout(() => setOn(true), 40);
return () => clearTimeout(t);
}, []);
return (
<div className="chart" style={{ height }}>
{yTicks && (
<div className="chart-y">
{yTicks.map((t) => <span key={t}>{t}</span>)}
</div>
)}
<div className="chart-plot">
{labels.map((lab, i) => (
<div className="bar-group" key={i}>
<div className="bars">
{series.map((s, si) => {
const muted = highlight !== undefined && i !== highlight;
return (
<span
key={si}
className="vbar"
style={{
width: barWidth,
height: on ? `${Math.max(2, (s.values[i] / max) * 100)}%` : "0%",
background: muted ? "var(--track)" : s.color,
transitionDelay: `${i * 35 + si * 20}ms`,
}}
/>
);
})}
</div>
<span className="bar-x">{lab}</span>
</div>
))}
</div>
</div>
);
}
/* ---- mini sparkline bars (stat cards) ---- */
export function Spark({ values, color, height = 40, width = 8 }: { values: number[]; color: string; height?: number; width?: number }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 40); return () => clearTimeout(t); }, []);
const max = Math.max(...values, 1);
return (
<div className="spark" style={{ height }}>
{values.map((v, i) => (
<span key={i} style={{ width, height: on ? `${(v / max) * 100}%` : "0%", background: color, transitionDelay: `${i * 30}ms` }} />
))}
</div>
);
}
/* ---- donut chart ---- */
export function Donut({
segments, total, label, size = 150, thickness = 18,
}: {
segments: { value: number; color: string }[];
total: string;
label: string;
size?: number;
thickness?: number;
}) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
const r = (size - thickness) / 2;
const c = 2 * Math.PI * r;
const sum = segments.reduce((a, s) => a + s.value, 0) || 1;
let offset = 0;
return (
<div className="donut" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--track)" strokeWidth={thickness} />
{segments.map((s, i) => {
const frac = s.value / sum;
const len = on ? frac * c : 0;
const dash = `${len} ${c - len}`;
const el = (
<circle
key={i}
cx={size / 2} cy={size / 2} r={r} fill="none"
stroke={s.color} strokeWidth={thickness} strokeLinecap="round"
strokeDasharray={dash}
strokeDashoffset={-offset * c}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dasharray .9s cubic-bezier(.2,.7,.2,1)" }}
/>
);
offset += frac;
return el;
})}
</svg>
<div className="donut-c">
<div className="dv">{total}</div>
<div className="dl">{label}</div>
</div>
</div>
);
}
/* ---- semicircle gauge ---- */
export function Gauge({
value, max = 100, color, size = 150, label, sub,
}: {
value: number; max?: number; color: string; size?: number; label?: string; sub?: string;
}) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
const sw = 14;
const r = (size - sw) / 2;
const cx = size / 2, cy = size / 2;
const semi = Math.PI * r; // half circumference
const frac = Math.min(1, value / max);
const len = on ? frac * semi : 0;
return (
<div className="gauge" style={{ width: size, height: size / 2 + 16 }}>
<svg width={size} height={size / 2 + 16} viewBox={`0 0 ${size} ${size / 2 + 16}`}>
<path d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`} fill="none" stroke="var(--track)" strokeWidth={sw} strokeLinecap="round" />
<path
d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`}
fill="none" stroke={color} strokeWidth={sw} strokeLinecap="round"
strokeDasharray={`${len} ${semi}`}
style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.7,.2,1)" }}
/>
</svg>
{(label || sub) && (
<div className="gauge-c">
{label && <div className="gv">{label}</div>}
{sub && <div className="gl">{sub}</div>}
</div>
)}
</div>
);
}
/* ---- progress bar ---- */
export function Progress({ value, color, track }: { value: number; color: string; track?: boolean }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
return (
<div className="bar">
<span style={{ width: on ? `${value}%` : "0%", background: color, transition: "width .9s cubic-bezier(.2,.7,.2,1)" }} />
</div>
);
}
/* ---- horizontal segmented bar (pipeline) ---- */
export function HBar({ value, max, color }: { value: number; max: number; color: string }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
return (
<div className="bar" style={{ height: 9 }}>
<span style={{ width: on ? `${(value / max) * 100}%` : "0%", background: color, transition: "width .8s cubic-bezier(.2,.7,.2,1)" }} />
</div>
);
}
+44 -223
View File
@@ -1,258 +1,79 @@
"use client"; "use client";
// ============================================================
// Dashboard shell — premium dark theme. Keeps the sidebar +
// header and swaps the inner content. Profile / Support / Rules
// render real views; other workspace modules show a placeholder.
// ============================================================
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TrendingUp, Cloud, MoreHorizontal, ArrowUpRight } from "lucide-react"; import { Sidebar, NAV_ITEMS } from "./sidebar";
import { Sidebar } from "./sidebar";
import { Topbar } from "./topbar"; import { Topbar } from "./topbar";
import { FigIcon } from "./figicon"; import { ToastProvider, PageHead, Btn, Icon } from "./ui";
import { BarsChart, Spark, Donut, Gauge, Progress, HBar } from "./charts"; import { Profile } from "./profile";
import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { Inbox } from "./inbox";
import "../../app/dashboard/dashboard.css"; import "../../app/dashboard/dashboard.css";
const C = {
orange: "#fda913", orange2: "#fd6d13", yellow: "#ffd60a",
cyan: "#09b9c6", green: "#14bc83", blue: "#285ef0", purple: "#9036e9", red: "#f0563f",
};
const rnd = (v: number, p = 0.12) => Math.max(1, Math.round(v * (1 + (Math.random() * 2 - 1) * p)));
const arr = (base: number[], p = 0.14) => base.map((v) => rnd(v, p));
export function Dashboard() { export function Dashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark"); const [theme, setTheme] = useState<"dark" | "light">("dark");
const [active, setActive] = useState("dashboard"); const [active, setActive] = useState("dashboard");
useEffect(() => { useEffect(() => {
// Sync the persisted theme from localStorage (an external system) on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {} try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
}, []); }, []);
function toggle() { function toggle() {
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; }); setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
} }
// ---- live data ---- const item = NAV_ITEMS.find((i) => i.key === active);
const [pnl, setPnl] = useState({ const title = item?.label ?? "Dashboard";
revenue: [4, 6, 3, 7, 5, 6, 4], gross: [5, 3, 6, 4, 7, 5, 6], const subtitle = item?.subtitle ?? "Workspace module";
comm: [3, 5, 4, 6, 5, 7, 4], net: [4, 6, 5, 3, 6, 4, 7],
});
const [rev, setRev] = useState([5, 7, 4, 8, 6, 9, 16, 7, 5, 8]);
const [sparks, setSparks] = useState({
revenue: [4, 7, 5, 9, 6, 10, 8], leads: [6, 4, 8, 5, 9, 6, 10], meets: [5, 8, 6, 9, 7, 10, 8],
});
useEffect(() => {
const id = setInterval(() => {
setPnl((p) => ({ revenue: arr(p.revenue), gross: arr(p.gross), comm: arr(p.comm), net: arr(p.net) }));
setRev((r) => r.map((v, i) => (i === 6 ? 16 : rnd(v, 0.18))));
setSparks((s) => ({ revenue: arr(s.revenue), leads: arr(s.leads), meets: arr(s.meets) }));
}, 2600);
return () => clearInterval(id);
}, []);
const days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const yk = ["8k", "6k", "4k", "2k", "0"];
return ( return (
<div className="dash-root" data-theme={theme}> <div className="dash-root" data-theme={theme}>
<Sidebar active={active} onSelect={setActive} /> <Sidebar active={active} onSelect={setActive} />
<div className="dash-main"> <div className="dash-main">
<Topbar theme={theme} onToggle={toggle} /> <Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} />
<div className="dash-content"> <div className="dash-content">
<ToastProvider>
{/* ===== Statistics ===== */} {active === "profile" ? <Profile />
<h2 className="sec-title">Statistics</h2> : active === "support" ? <Support />
<div className="grid" style={{ gridTemplateColumns: "1.55fr 1fr", marginBottom: 16 }}> : active === "rules" ? <Rules />
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr" }}> : active === "ai" ? <AiAssistant />
<BigStat icon={<FigIcon name="i_revenue" size={18} />} ic={C.orange} tag="GROSS PROFIT $114K" value="$626K" label="Revenue" spark={sparks.revenue} sc={C.orange} /> : active === "messenger" ? <Messenger />
<BigStat icon={<FigIcon name="i_hotleads" size={18} />} ic={C.red} tag="REQUIRES ACTION" value="20k" label="Hot Leads" spark={sparks.leads} sc={C.purple} /> : active === "inbox" ? <Inbox />
<BigStat icon={<FigIcon name="i_meets" size={18} />} ic={C.cyan} tag="NEXT 7 DAYS" value="70k" label="Upcoming Meets" spark={sparks.meets} sc={C.cyan} /> : active === "team" ? <TeamManagement />
</div> : <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr" }}> </ToastProvider>
<MiniStat icon={<FigIcon name="i_collected" size={16} />} ic={C.cyan} value="$311.3k" label="Collected to Date" sub="32 payments" pill="pill-cyan" />
<MiniStat icon={<FigIcon name="i_ar" size={16} />} ic={C.orange} value="$325.9k" label="Outstanding AR" sub="25 pending" pill="pill-orange" />
<MiniStat icon={<FigIcon name="i_payouts" size={16} />} ic={C.purple} value="$203.7k" label="Pending Payouts" sub="20 pending" pill="pill-blue" />
<MiniStat icon={<FigIcon name="i_vendor" size={16} />} ic={C.blue} value="$432.4k" label="YTD Vendor Spend" sub="57 invoices" pill="pill-blue" />
</div>
</div>
{/* ===== Pipeline / Win / Storm ===== */}
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr", marginBottom: 16 }}>
<div className="dcard">
<div className="card-h"><h3>Open Pipeline</h3><span className="pill pill-orange">27 leads</span></div>
<div className="big-val" style={{ marginBottom: 14 }}>$396K</div>
{[["New", 6], ["Contacted", 7], ["Appointment", 7], ["Estimate", 3]].map(([l, n]) => (
<div className="pl-row" key={l as string}>
<span className="lab">{l}</span>
<HBar value={n as number} max={8} color={C.orange} />
<span className="n">{n}</span>
</div>
))}
</div>
<div className="dcard">
<div className="card-h"><h3>Win Rate</h3><TrendingUp size={16} color={C.green} /></div>
<div className="big-val" style={{ marginBottom: 14 }}>94%</div>
<Progress value={94} color={C.cyan} />
<div className="row" style={{ gap: 16, marginTop: 14, flexWrap: "wrap" }}>
<Legend dot={C.cyan} text="17 Won" />
<Legend dot="var(--muted)" text="18 Decided" />
<Legend dot={C.red} text="1 Lost" />
</div>
</div>
<div className="dcard">
<div className="card-h"><h3>Storm-Attributed Revenue</h3><span className="pill pill-blue">19 Storm Leads</span></div>
<div className="big-val" style={{ marginBottom: 14 }}>24%</div>
<Progress value={24} color={C.blue} />
<div className="lbl" style={{ marginTop: 12 }}>$56K of $626K</div>
</div>
</div>
{/* ===== Roof / Risk / Weather ===== */}
<div className="grid" style={{ gridTemplateColumns: "1fr 1.15fr 1.25fr", marginBottom: 16 }}>
<div className="dcard">
<div className="card-h"><div><h3>Roof Condition</h3><div className="card-sub">Territory health snapshot</div></div></div>
<div className="row" style={{ justifyContent: "center", margin: "6px 0 10px" }}>
<Donut segments={[{ value: 30, color: C.orange }, { value: 14, color: C.cyan }]} total="44" label="Total" />
</div>
<div className="row" style={{ gap: 18, justifyContent: "center" }}>
<Legend dot={C.orange} text="Good" />
<Legend dot={C.cyan} text="Bad" />
</div>
</div>
<div className="dcard">
<div className="card-h"><div><h3>Risk Index</h3><div className="card-sub">Storm Impact Probability</div></div></div>
<div className="row" style={{ justifyContent: "center", margin: "4px 0" }}>
<Gauge value={51} max={100} color={C.blue} size={170} label="51" sub="of 100" />
</div>
<div className="row between" style={{ marginTop: 6 }}>
<Readout big="10 mph" small="Wind Exp." />
<Readout big="52%" small="Vulnerable" color={C.orange} />
</div>
</div>
<div className="dcard">
<div className="row between">
<div>
<div className="row gap-2"><Cloud size={20} color={C.cyan} /><span className="big-val">80&deg;F</span></div>
<div className="lbl">Plano, US</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="card-sub">Updated: 02:48 PM</div>
<div className="row gap-3" style={{ marginTop: 8, justifyContent: "flex-end" }}>
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: "var(--muted)", display: "inline-flex" }}><FigIcon name="i_wind" size={14} /></span> 10 mph</span>
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: C.blue, display: "inline-flex" }}><FigIcon name="i_humidity" size={14} /></span> 82%</span>
</div>
</div>
</div>
<div className="wx-hours">
{[["12 PM", 94], ["5 PM", 79], ["8 PM", 82], ["10 PM", 90], ["1 AM", 92], ["5 AM", 79]].map(([t, te]) => (
<div className="wx-hr" key={t as string}>
<div className="t">{t}</div>
<Cloud size={16} color="var(--faint)" style={{ display: "block", margin: "6px auto 0" }} />
<div className="te">{te}&deg;</div>
</div>
))}
</div>
</div>
</div>
{/* ===== P&L Summary ===== */}
<h2 className="sec-title">P&amp;L Summary</h2>
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr", marginBottom: 16 }}>
<PnlCard title="Revenue" value="$626K" labels={days} yk={yk}
series={[{ color: C.orange, values: pnl.revenue }, { color: C.orange2, values: pnl.gross }]} />
<PnlCard title="Gross Profit" value="$114K" labels={days} yk={yk}
series={[{ color: C.cyan, values: pnl.gross }, { color: C.orange, values: pnl.revenue }]} />
<PnlCard title="Commissions" value="$63K" labels={days} yk={yk}
series={[{ color: C.blue, values: pnl.comm }, { color: "#6f9bff", values: pnl.net }]} />
<PnlCard title="Net Profit" value="$51K" labels={days} yk={yk}
series={[{ color: C.orange, values: pnl.net }, { color: C.blue, values: pnl.comm }]} />
</div>
{/* ===== Revenue Potential / Top Reps ===== */}
<div className="grid" style={{ gridTemplateColumns: "1.7fr 1fr" }}>
<div className="dcard">
<div className="card-h">
<div><h3>Revenue Potential</h3><div className="card-sub">By neighborhood rating</div></div>
<span className="pill pill-orange"><ArrowUpRight size={13} /> live</span>
</div>
<BarsChart
labels={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
series={[{ color: C.orange, values: rev }]}
max={18} highlight={6} height={210} barWidth={26}
yTicks={["$240k", "$180k", "$120k", "$60k", "0"]}
/>
</div>
<div className="dcard">
<div className="card-h"><h3>Top Sales Reps</h3><button className="link-btn">View All</button></div>
{[["Hannah Reyes", "hannah@lynkeduppro.com", C.orange], ["Cody Tatum", "cody@lynkeduppro.com", C.cyan], ["Travis Boone", "travis@lynkeduppro.com", C.blue]].map(([nm, em, col], i) => (
<div className="rep-row" key={nm as string}>
<span className="rk">0{i + 1}</span>
<span className="av" style={{ background: col as string, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{(nm as string).split(" ").map((x) => x[0]).join("")}</span>
<div style={{ flex: 1 }}>
<div className="nm">{nm}</div>
<div className="em">{em}</div>
</div>
<span className="amt">$112,500</span>
</div>
))}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
/* ---- sub components ---- */ // Placeholder for workspace modules that don't have a view yet — keeps the
function BigStat({ icon, ic, tag, value, label, spark, sc }: { icon: React.ReactNode; ic: string; tag: string; value: string; label: string; spark: number[]; sc: string }) { // full navigation coherent and points users to the live sections.
function ComingSoon({ title, icon, onGo }: { title: string; icon: string; onGo: (k: string) => void }) {
return ( return (
<div className="dcard"> <div className="view">
<div className="row between" style={{ marginBottom: 14 }}> <PageHead eyebrow="Workspace" title={title} subtitle="This module is part of the LynkedUp Pro workspace." icon={icon} />
<span className="stat-ic" style={{ background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span> <div className="card coming-soon">
<span className="card-sub" style={{ fontWeight: 600, letterSpacing: "0.04em" }}>{tag}</span> <span className="coming-soon-ic"><Icon name={icon} size={34} /></span>
</div> <h3>{title} is coming soon</h3>
<div className="row between" style={{ alignItems: "flex-end" }}> <p>This area is reserved for the {title} module. In the meantime, the account experience is fully built out.</p>
<div> <div className="coming-soon-actions">
<div className="big-val">{value}</div> <Btn icon="user" onClick={() => onGo("profile")}>Open Profile</Btn>
<div className="lbl">{label}</div> <Btn variant="outline" icon="chat" onClick={() => onGo("support")}>Support Center</Btn>
<Btn variant="ghost" icon="check-circle" onClick={() => onGo("rules")}>Rules</Btn>
</div> </div>
<Spark values={spark} color={sc} />
</div> </div>
</div> </div>
); );
} }
function MiniStat({ icon, ic, value, label, sub, pill }: { icon: React.ReactNode; ic: string; value: string; label: string; sub: string; pill: string }) {
return (
<div className="mini-card">
<div className="top">
<span className="stat-ic" style={{ width: 30, height: 30, background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
<span className={`pill ${pill}`}>{sub}</span>
</div>
<div className="mid-val" style={{ marginTop: 12 }}>{value}</div>
<div className="lbl">{label}</div>
</div>
);
}
function PnlCard({ title, value, labels, yk, series }: { title: string; value: string; labels: string[]; yk: string[]; series: { color: string; values: number[] }[] }) {
return (
<div className="dcard">
<div className="card-h"><h3>{title}</h3><MoreHorizontal size={16} color="var(--faint)" /></div>
<div className="mid-val" style={{ marginBottom: 14 }}>{value}</div>
<BarsChart labels={labels} series={series} max={9} yTicks={yk} height={120} barWidth={5} />
</div>
);
}
function Legend({ dot, text }: { dot: string; text: string }) {
return <span className="row gap-2" style={{ fontSize: 12, color: "var(--muted)" }}><span className="ldot" style={{ background: dot }} /> {text}</span>;
}
function Readout({ big, small, color }: { big: string; small: string; color?: string }) {
return (
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: color || "var(--text)" }}>{big}</div>
<div className="lbl">{small}</div>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { Sparkles } from "lucide-react";
import { figmaIcons } from "./figma-icons";
/** Renders a Figma-exported icon (recolored to currentColor) or a fallback. */
export function FigIcon({ name, size = 18 }: { name: string; size?: number }) {
const svg = figmaIcons[name];
if (!svg) return <Sparkles size={size} />;
return (
<span
className="fig-ic"
style={{ width: size, height: size }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
"use client";
// ============================================================
// Inbox — a personalized work/awareness feed IIOS projects from
// events (mentions, needs-reply, support updates, …), surfaced via
// the be-crm data door (crm.inbox.*). List + filter by state, and
// mark items done / snoozed / archived. Items are created by IIOS's
// projector, never here. Mock when the Shell isn't configured.
// ============================================================
import { useState } from "react";
import { Btn, Icon, PageHead, Pill } from "./ui";
import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api";
const KIND_LABEL: Record<string, string> = {
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
};
const FILTERS: { value: InboxState; label: string }[] = [
{ value: "OPEN", label: "Open" }, { value: "SNOOZED", label: "Snoozed" }, { value: "DONE", label: "Done" }, { value: "ARCHIVED", label: "Archived" },
];
export function Inbox() {
const [filter, setFilter] = useState<InboxState>("OPEN");
const inbox = useInboxData(filter);
return (
<div className="view">
<PageHead eyebrow="Communication" title="Inbox" subtitle="Mentions, replies and updates that need your attention" icon="bell" />
{!inbox.live && (
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
Demo mode running on mock data. It goes live once the Shell + be-crm are connected.
</div>
)}
<div style={{ display: "flex", gap: 6, marginBottom: 14, flexWrap: "wrap" }}>
{FILTERS.map((f) => (
<Btn key={f.value} variant={filter === f.value ? "primary" : "outline"} onClick={() => setFilter(f.value)}>{f.label}</Btn>
))}
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{inbox.loading && <div style={{ padding: 20, color: "var(--muted)" }}>Loading</div>}
{!inbox.loading && inbox.items.length === 0 && (
<div style={{ padding: 28, color: "var(--muted)", textAlign: "center" }}>Nothing here you&apos;re all caught up 🎉</div>
)}
{inbox.items.map((it) => (
<InboxRow
key={it.id} it={it}
onDone={() => inbox.transition(it.id, "DONE")}
onSnooze={() => inbox.transition(it.id, "SNOOZED")}
onArchive={() => inbox.transition(it.id, "ARCHIVED")}
/>
))}
</div>
</div>
);
}
function InboxRow({ it, onDone, onSnooze, onArchive }: { it: UiInboxItem; onDone: () => void; onSnooze: () => void; onArchive: () => void }) {
const isMention = it.kind === "MENTION";
return (
<div style={{ display: "flex", gap: 12, alignItems: "flex-start", padding: "14px 18px", borderBottom: "1px solid var(--border)" }}>
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)" }}>
<Icon name={isMention ? "chat" : "bell"} size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Pill tone={isMention ? "warn" : "muted"}>{KIND_LABEL[it.kind] ?? it.kind}</Pill>
<span style={{ fontWeight: 600 }}>{it.title}</span>
</div>
{it.summary && <div style={{ color: "var(--muted)", fontSize: 13, marginTop: 3 }}>{it.summary}</div>}
</div>
{it.state === "OPEN" ? (
<div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
<Btn variant="ghost" icon="x" onClick={onArchive}>Archive</Btn>
</div>
) : (
<Pill tone="muted">{it.state.toLowerCase()}</Pill>
)}
</div>
);
}
+352
View File
@@ -0,0 +1,352 @@
"use client";
// ============================================================
// Messenger — internal team + client chat, powered by IIOS via
// the be-crm data door (crm.messenger.*). Conversation list ⇄
// thread view + composer, with a "new chat" people picker that
// creates a DM (1 person) or group (2+). DM-vs-group and who-can-
// chat are enforced server-side by IIOS/OPA; this is just UI.
// Live messages, typing, read receipts and reactions come over the
// IIOS socket (Shell mode); mock keeps the demo working offline.
// ============================================================
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
import { useMessengerData, useThread, type Membership, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api";
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
const timeOf = (iso?: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)";
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
const inputStyle: CSSProperties = {
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
};
export function Messenger() {
// One shared IIOS socket for the whole panel (live in Shell mode; no-op in mock).
return (
<MessengerSocketProvider>
<MessengerPanel />
</MessengerSocketProvider>
);
}
function MessengerPanel() {
const m = useMessengerData();
const toast = useToast();
const [selected, setSelected] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
useEffect(() => {
if ((!selected || !m.conversations.some((c) => c.threadId === selected)) && m.conversations[0]) {
setSelected(m.conversations[0].threadId);
}
}, [m.conversations, selected]);
const current = m.conversations.find((c) => c.threadId === selected) ?? null;
return (
<div className="view">
<PageHead
eyebrow="Communication" title="Messenger" subtitle="Chat with your team and clients — direct or in groups" icon="send"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New chat</Btn>}
/>
{!m.live && (
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
Demo mode running on mock data. It goes live once the Shell + be-crm are connected.
</div>
)}
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
<aside style={{ width: 296, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
{m.loading && <div style={{ padding: 16, color: "var(--muted)" }}>Loading</div>}
{!m.loading && m.conversations.length === 0 && (
<div style={{ padding: 16, color: "var(--muted)" }}>No conversations yet. Start a new chat.</div>
)}
{m.conversations.map((c) => (
<ConversationRow key={c.threadId} c={c} active={c.threadId === selected} onClick={() => setSelected(c.threadId)} />
))}
</aside>
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{current ? (
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
) : (
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
<Icon name="send" size={38} />
<p>Select or start a conversation</p>
</div>
)}
</section>
</div>
<NewChatModal
open={newOpen} onClose={() => setNewOpen(false)} directory={m.directory}
onCreate={async (ids, opts) => {
try {
const id = await m.openConversation(ids, opts);
setSelected(id);
setNewOpen(false);
} catch (e) {
toast.push({ tone: "error", title: "Couldn't start chat", desc: (e as Error).message });
}
}}
/>
</div>
);
}
function ConversationRow({ c, active, onClick }: { c: UiConversation; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
display: "flex", gap: 10, alignItems: "center", width: "100%", textAlign: "left",
padding: "10px 14px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
}}
>
<Avatar initials={initialsOf(c.title)} size={38} gradient={c.membership === "group" ? GROUP_GRAD : undefined} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{c.title}</span>
<span style={{ color: "var(--muted)", fontSize: 11, flexShrink: 0 }}>{timeOf(c.lastAt)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
<span style={{ color: "var(--muted)", fontSize: 12.5, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{c.lastMessage ?? "No messages yet"}
</span>
{c.unread > 0 && (
<span style={{ background: "var(--orange)", color: "#fff", borderRadius: 999, fontSize: 11, padding: "1px 7px", flexShrink: 0 }}>{c.unread}</span>
)}
</div>
</div>
</button>
);
}
function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: (id: string) => string; onError: (m: string) => void }) {
const t = useThread(conv.threadId);
const socket = useMessengerSocket();
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
const endRef = useRef<HTMLDivElement>(null);
const typingSentAt = useRef(0);
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
function onDraftChange(v: string) {
setDraft(v);
const now = Date.now();
if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; }
}
async function submit() {
const text = draft.trim();
if (!text || sending) return;
const parent = replyTo?.id;
setDraft(""); setReplyTo(null); setSending(true);
try { await t.send(text, parent ? { parentInteractionId: parent } : undefined); }
catch (e) { setDraft(text); onError((e as Error).message); }
finally { setSending(false); }
}
const typingLabel = t.typingUserIds.length === 1
? `${nameOf(t.typingUserIds[0])} is typing…`
: t.typingUserIds.length > 1 ? "Several people are typing…" : "";
return (
<>
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
<Avatar initials={initialsOf(conv.title)} size={34} gradient={conv.membership === "group" ? GROUP_GRAD : undefined} />
<div>
<div style={{ fontWeight: 600 }}>{conv.title}</div>
<div style={{ color: "var(--muted)", fontSize: 12 }}>
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
</div>
</div>
</header>
<div style={{ flex: 1, overflowY: "auto", padding: 18, display: "flex", flexDirection: "column", gap: 10, background: "var(--bg)" }}>
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages</div>}
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages yet say hello 👋</div>}
{t.messages.map((msg) => (
<MessageBubble
key={msg.id} msg={msg}
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
showStatus={msg.id === lastMineId}
onReact={(emoji) => t.react(msg.id, emoji)}
onReply={() => setReplyTo(msg)}
/>
))}
<div ref={endRef} />
</div>
<div style={{ minHeight: 18, padding: "0 18px", color: "var(--muted)", fontSize: 12, fontStyle: "italic" }}>{typingLabel}</div>
{replyTo && (
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: "var(--orange)", fontWeight: 600 }}>Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}</div>
<div style={{ fontSize: 12.5, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{replyTo.text}</div>
</div>
<button onClick={() => setReplyTo(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Cancel reply">×</button>
</div>
)}
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)" }}>
<input
value={draft} onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
placeholder="Type a message…" style={inputStyle}
/>
<Btn icon="send" onClick={() => void submit()} disabled={sending || !draft.trim()}>Send</Btn>
</footer>
</>
);
}
function MessageBubble({
msg, parent, seen, showStatus, onReact, onReply,
}: {
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean;
onReact: (emoji: string) => void; onReply: () => void;
}) {
const [hover, setHover] = useState(false);
const [picker, setPicker] = useState(false);
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPicker(false); }}
style={{ alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column", alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative" }}
>
{parent && (
<div style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
<span style={{ opacity: 0.8 }}> {parent.text}</span>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
<div style={{
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
padding: "8px 12px", borderRadius: 14,
borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4,
border: msg.mine ? "none" : "1px solid var(--border)",
whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14,
}}>
{msg.text}
</div>
{hover && (
<div style={{ display: "flex", gap: 2, position: "relative" }}>
<button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button>
<button onClick={onReply} title="Reply" style={actionBtnStyle}></button>
{picker && (
<div style={{ position: "absolute", bottom: "100%", [msg.mine ? "right" : "left"]: 0, marginBottom: 4, display: "flex", gap: 2, padding: 4, borderRadius: 999, background: "var(--panel)", border: "1px solid var(--border)", boxShadow: "0 6px 20px rgba(0,0,0,0.35)", zIndex: 5 }}>
{REACTION_EMOJIS.map((e) => (
<button key={e} onClick={() => { onReact(e); setPicker(false); }} style={{ ...actionBtnStyle, fontSize: 16 }}>{e}</button>
))}
</div>
)}
</div>
)}
</div>
{msg.reactions && msg.reactions.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}>
{msg.reactions.map((r) => (
<button
key={r.emoji} onClick={() => onReact(r.emoji)}
style={{
display: "inline-flex", alignItems: "center", gap: 3, padding: "1px 7px", borderRadius: 999, fontSize: 12, cursor: "pointer",
background: r.mine ? "rgba(253,169,19,0.18)" : "var(--panel-2)",
border: `1px solid ${r.mine ? "var(--orange)" : "var(--border)"}`, color: "var(--text)",
}}
>
<span>{r.emoji}</span><span style={{ color: "var(--muted)" }}>{r.count}</span>
</button>
))}
</div>
)}
<div style={{ fontSize: 10.5, color: "var(--muted)", marginTop: 2 }}>
{timeOf(msg.at)}{showStatus && msg.mine ? ` · ${seen ? "Seen" : "Sent"}` : ""}
</div>
</div>
);
}
const actionBtnStyle: CSSProperties = {
background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8,
width: 26, height: 26, display: "grid", placeItems: "center", cursor: "pointer", fontSize: 13, color: "var(--text)", padding: 0,
};
function NewChatModal({
open, onClose, directory, onCreate,
}: {
open: boolean; onClose: () => void; directory: UiPerson[];
onCreate: (ids: string[], opts: { membership: Membership; subject?: string }) => Promise<void>;
}) {
const [picked, setPicked] = useState<string[]>([]);
const [subject, setSubject] = useState("");
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { if (!open) { setPicked([]); setSubject(""); setQ(""); setBusy(false); } }, [open]);
const membership: Membership = picked.length > 1 ? "group" : "dm";
const filtered = directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : [...l, id]));
async function create() {
if (!picked.length || busy) return;
setBusy(true);
await onCreate(picked, { membership, ...(membership === "group" && subject.trim() ? { subject: subject.trim() } : {}) });
setBusy(false);
}
return (
<Modal
open={open} onClose={onClose} title="New conversation"
subtitle={membership === "group" ? "Group chat" : "Direct message"} icon="send"
footer={<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" onClick={() => void create()} disabled={!picked.length || busy}>{busy ? "Starting…" : "Start chat"}</Btn>
</>}
>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 10 }} />
{membership === "group" && (
<Field label="Group name (optional)">
<input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Storm response" style={inputStyle} />
</Field>
)}
<div style={{ maxHeight: 320, overflowY: "auto", marginTop: 8, display: "flex", flexDirection: "column", gap: 2 }}>
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 10 }}>No people found.</div>}
{filtered.map((p) => (
<label key={p.id} style={{
display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 10, cursor: "pointer",
background: picked.includes(p.id) ? "var(--panel-2)" : "transparent",
}}>
<input type="checkbox" checked={picked.includes(p.id)} onChange={() => toggle(p.id)} />
<Avatar initials={initialsOf(p.name)} size={30} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Pill tone="muted">{p.kind}</Pill>
</label>
))}
</div>
</Modal>
);
}
+679
View File
@@ -0,0 +1,679 @@
"use client";
// ============================================================
// Profile — header card + 6 tabs:
// Personal Info · KYC · Security · Notifications · Privacy · Devices
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations,
contactTimezones, contactTimes, loginActivity, consentGroups,
passwordStrength, maskEmail, maskPhone,
type KycSlot, type NotifCategory, type ConsentGroup,
} from "./account-data";
import {
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
StatusDot, Toggle, useToast,
} from "./ui";
const TABS = [
{ value: "personal", label: "Personal Info", icon: "user" },
{ value: "kyc", label: "KYC", icon: "shield-check" },
{ value: "security", label: "Security", icon: "lock" },
{ value: "notifications", label: "Notifications", icon: "bell" },
{ value: "privacy", label: "Privacy & Consent", icon: "privacy" },
{ value: "devices", label: "Devices", icon: "devices" },
];
export function Profile() {
const [tab, setTab] = useState("personal");
return (
<div className="view">
<PageHead
eyebrow="My Account"
title="Profile"
subtitle="Manage your identity, verification, security and preferences."
icon="user"
actions={<Pill tone="green"><StatusDot status="online" /> Account active</Pill>}
/>
<ProfileHeaderCard />
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
<div className="view-body">
{tab === "personal" && <PersonalInfoTab />}
{tab === "kyc" && <KycTab />}
{tab === "security" && <SecurityTab />}
{tab === "notifications" && <NotificationsTab />}
{tab === "privacy" && <PrivacyTab />}
{tab === "devices" && <DevicesTab />}
</div>
</div>
);
}
/* ============================================================ */
/* Header card */
/* ============================================================ */
function ProfileHeaderCard() {
const { push } = useToast();
return (
<div className="card profile-hero">
<div className="profile-hero-bg" />
<div className="profile-hero-main">
<div className="profile-hero-avatar">
<Avatar initials={user.initials} gradient={user.avatarGradient} size={86} square />
<button className="profile-hero-cam" aria-label="Change photo" onClick={() => push({ tone: "info", title: "Photo upload", desc: "Choose a JPG or PNG up to 5 MB." })}>
<Icon name="camera" size={14} />
</button>
</div>
<div className="profile-hero-id">
<div className="profile-hero-name">
{user.name}
<Icon name="check-circle" size={18} className="verified-badge" />
</div>
<div className="profile-hero-role">{user.role} · Member since {user.memberSince}</div>
<div className="profile-hero-chips">
<span className="hero-chip"><Icon name="pin" size={13} /> {user.sector} · {user.pocket}</span>
<span className="hero-chip"><Icon name="owners" size={13} /> {user.category}</span>
<span className="hero-chip accent"><Icon name="check" size={13} /> House {user.plot}</span>
</div>
</div>
</div>
<div className="profile-hero-stats">
<HeroStat value={user.category} label="Category" />
<HeroStat value={user.size} label="House size" />
<HeroStat value="33%" label="KYC complete" accent="var(--orange)" />
</div>
</div>
);
}
function HeroStat({ value, label, accent }: { value: string; label: string; accent?: string }) {
return (
<div className="hero-stat">
<div className="hero-stat-v" style={accent ? { color: accent } : undefined}>{value}</div>
<div className="hero-stat-l">{label}</div>
</div>
);
}
/* ============================================================ */
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
/* ============================================================ */
type ContactKind = "email" | "mobile" | "whatsapp";
function PersonalInfoTab() {
const { push } = useToast();
const [verified, setVerified] = useState(false);
const [plot, setPlot] = useState("");
const [err, setErr] = useState("");
const [change, setChange] = useState<null | ContactKind>(null);
// editable contact values (live-updating after OTP confirmation)
const [contacts, setContacts] = useState({ email: user.email.full, mobile: user.mobile.full, whatsapp: user.whatsapp.full });
function verify() {
if (plot.trim() === user.plot) { setVerified(true); setErr(""); push({ tone: "success", title: "Verified", desc: "Your full details are now visible and editable." }); }
else setErr(`That doesn't match the house number on file (hint: it's ${user.plot}).`);
}
function mask(kind: ContactKind, full: string) {
return kind === "email" ? maskEmail(full) : maskPhone(full);
}
const contactRows: { key: ContactKind; label: string; full: string; masked: string }[] = [
{ key: "email", label: "Email", full: contacts.email, masked: mask("email", contacts.email) },
{ key: "mobile", label: "Mobile", full: contacts.mobile, masked: mask("mobile", contacts.mobile) },
{ key: "whatsapp", label: "WhatsApp", full: contacts.whatsapp, masked: mask("whatsapp", contacts.whatsapp) },
];
return (
<div className="grid-2">
<div className="card">
<div className="card-head">
<h3>Contact details</h3>
{verified
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
: <Pill tone="orange"><Icon name="eye-off" size={13} /> Masked</Pill>}
</div>
{!verified && (
<div className="reveal-gate">
<div className="reveal-gate-ic"><Icon name="lock" size={20} /></div>
<div className="reveal-gate-txt">
<strong>Verify to reveal &amp; edit</strong>
<span>Sensitive details are masked. Enter your house number to unmask and edit them.</span>
</div>
<div className="reveal-gate-form">
<input className="ds-input" placeholder="House number" value={plot} maxLength={6}
onChange={(e) => { setPlot(e.target.value.replace(/\D/g, "")); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && verify()} />
<Btn icon="check" onClick={verify}>Reveal</Btn>
</div>
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
</div>
)}
<dl className="kv-list">
{contactRows.map((f) => (
<div className="kv" key={f.key}>
<dt>{f.label}</dt>
<dd className={verified ? "kv-edit" : "masked"}>
{verified ? f.full : f.masked}
{verified && (
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
)}
</dd>
</div>
))}
</dl>
<div className="card-actions">
<Btn variant="outline" icon="mail" onClick={() => setChange("email")} disabled={!verified}>Change email</Btn>
<Btn variant="outline" icon="phone" onClick={() => setChange("mobile")} disabled={!verified}>Change mobile</Btn>
</div>
{!verified && <p className="muted-note"><Icon name="info" size={13} /> Reveal your details first to edit contact information.</p>}
</div>
<div className="card">
<div className="card-head">
<h3>Account &amp; identity</h3>
{verified
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
: <Pill tone="muted"><Icon name="lock" size={12} /> Protected</Pill>}
</div>
<dl className="kv-list">
<div className="kv"><dt>Full name</dt><dd>{user.name}</dd></div>
<div className="kv"><dt>Role</dt><dd>{user.role}</dd></div>
<div className="kv"><dt>Member since</dt><dd>{user.memberSince}</dd></div>
<div className="kv"><dt>Account status</dt><dd><Pill tone="green"><StatusDot status="online" /> Active</Pill></dd></div>
<div className="kv">
<dt>PAN</dt>
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.pan.full : user.pan.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
</div>
<div className="kv">
<dt>Aadhaar</dt>
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.aadhaar.full : user.aadhaar.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
</div>
</dl>
<p className="muted-note"><Icon name="info" size={13} /> PAN &amp; Aadhaar are managed under the KYC tab and can&apos;t be edited here.</p>
</div>
<ChangeContactModal
kind={change}
current={change ? contacts[change] : ""}
onClose={() => setChange(null)}
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
/>
</div>
);
}
const CONTACT_LABEL: Record<ContactKind, string> = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" };
// OTP-to-registered-mobile flow for changing a contact field. On confirm the
// new value is saved back so the Personal Info card updates live.
function ChangeContactModal({ kind, current, onClose, onSave }: {
kind: null | ContactKind; current: string; onClose: () => void; onSave: (kind: ContactKind, value: string) => void;
}) {
const { push } = useToast();
const [step, setStep] = useState<"enter" | "otp">("enter");
const [val, setVal] = useState("");
const [otp, setOtp] = useState("");
// prefill with the current value whenever a new field is opened
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kind) { setStep("enter"); setVal(current); setOtp(""); }
}, [kind, current]);
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
const isEmail = kind === "email";
const label = kind ? CONTACT_LABEL[kind] : "";
return (
<Modal
open={kind != null} onClose={close}
icon={isEmail ? "mail" : kind === "whatsapp" ? "chat" : "phone"}
title={`Change ${label}`}
subtitle={step === "enter" ? "We'll send a code to your registered mobile to confirm." : `Enter the 6-digit code sent to ${user.mobile.masked}`}
footer={
step === "enter"
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="send" disabled={!val.trim() || val.trim() === current} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: `OTP sent to your registered mobile ${user.mobile.masked}.` }); }}>Send code</Btn></>
: <><Btn variant="ghost" onClick={() => setStep("enter")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={() => { if (kind) onSave(kind, val.trim()); push({ tone: "success", title: `${label[0].toUpperCase()}${label.slice(1)} updated`, desc: "Your change has been confirmed." }); close(); }}>Confirm</Btn></>
}
>
{step === "enter" ? (
<Field label={`New ${label}`} hint="A one-time code is always sent to your existing registered mobile — never to the new destination.">
<input className="ds-input" type={isEmail ? "email" : "tel"} placeholder={isEmail ? "you@example.com" : "+91 90000 00000"} value={val} onChange={(e) => setVal(e.target.value)} />
</Field>
) : (
<div className="otp-wrap">
<OtpField value={otp} onChange={setOtp} />
<button className="link-inline" onClick={() => push({ tone: "info", title: "Code resent" })}>Resend code</button>
</div>
)}
</Modal>
);
}
/* ============================================================ */
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
/* ============================================================ */
function KycTab() {
const { push } = useToast();
const [slots, setSlots] = useState<KycSlot[]>(kycSeed.slots);
const idDoc = slots.find((s) => s.key === "id")?.docId ?? null;
const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null;
const completion = useMemo(() => {
const done = slots.filter((s) => s.status === "verified" || s.status === "uploaded").length;
return Math.round((done / slots.length) * 100);
}, [slots]);
function options(key: KycSlot["key"]) {
const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : [];
return kycDocTypes.filter((d) => want.includes(d.use));
}
function pickDoc(key: KycSlot["key"], docId: string) {
// enforce two-different-documents rule between ID and address
if (key === "id" && docId === addrDoc) { push({ tone: "error", title: "Pick a different document", desc: "Identity and address proofs must be two different documents." }); return; }
if (key === "address" && docId === idDoc) { push({ tone: "error", title: "Pick a different document", desc: "Address and identity proofs must be two different documents." }); return; }
setSlots((s) => s.map((x) => x.key === key ? { ...x, docId } : x));
}
function upload(key: KycSlot["key"]) {
const slot = slots.find((s) => s.key === key)!;
if (key !== "photo" && !slot.docId) { push({ tone: "error", title: "Select a document type first" }); return; }
const name = key === "photo" ? "selfie.jpg" : `${slot.docId}_upload.pdf`;
setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: name, status: "uploaded", note: "Under review" } : x));
push({ tone: "success", title: "Uploaded", desc: "Your document is queued for review." });
}
function remove(key: KycSlot["key"]) {
setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: null, docId: key === "photo" ? null : x.docId, status: "missing", note: undefined } : x));
}
const photoDone = slots.find((s) => s.key === "photo")!.status !== "missing";
return (
<div className="grid-side">
<div className="card">
<div className="card-head">
<h3>Verification documents</h3>
<Pill tone="orange">{completion}% complete</Pill>
</div>
<div className="kyc-progress">
<div className="kyc-progress-bar"><span style={{ width: `${completion}%` }} /></div>
<div className="kyc-progress-legend">
{slots.map((s) => (
<span key={s.key} className={`kyc-leg ${s.status}`}>
<Icon name={s.status === "missing" ? "x" : s.status === "verified" ? "check-circle" : "clock"} size={13} /> {s.title}
</span>
))}
</div>
</div>
{!photoDone && <div className="callout tone-red"><Icon name="alert" size={18} /><div><strong>Live photo is mandatory.</strong><span>KYC cannot be completed without a verified photo.</span></div></div>}
<div className="kyc-slots">
{slots.map((slot) => (
<KycSlotRow key={slot.key} slot={slot} options={options(slot.key)} onPick={(d) => pickDoc(slot.key, d)} onUpload={() => upload(slot.key)} onRemove={() => remove(slot.key)} />
))}
</div>
</div>
<div className="card card-muted">
<div className="card-head"><h3>Rules</h3></div>
<ul className="rule-mini">
<li><Icon name="check" size={14} /> A live photo / selfie is <strong>mandatory</strong>.</li>
<li><Icon name="check" size={14} /> Provide <strong>one ID</strong> and <strong>one address</strong> proof.</li>
<li><Icon name="check" size={14} /> ID and address must be <strong>two different documents</strong>.</li>
<li><Icon name="check" size={14} /> Accepted: PDF, JPG, PNG · size limits per document.</li>
<li><Icon name="check" size={14} /> Review completes within ~4 business hours.</li>
</ul>
<div className="kyc-doc-pairs">
<div className="muted-note"><Icon name="info" size={13} /> Current selection</div>
<div className="pair-row"><span>Identity</span><Pill tone="blue">{kycDocTypes.find((d) => d.id === idDoc)?.label ?? "Not chosen"}</Pill></div>
<div className="pair-row"><span>Address</span><Pill tone="purple">{kycDocTypes.find((d) => d.id === addrDoc)?.label ?? "Not chosen"}</Pill></div>
</div>
</div>
</div>
);
}
function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
slot: KycSlot; options: typeof kycDocTypes; onPick: (d: string) => void; onUpload: () => void; onRemove: () => void;
}) {
const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted";
const docMeta = kycDocTypes.find((d) => d.id === slot.docId);
return (
<div className={`kyc-slot status-${slot.status}`}>
<div className="kyc-slot-ic"><Icon name={slot.key === "photo" ? "camera" : slot.key === "id" ? "user" : "pin"} size={18} /></div>
<div className="kyc-slot-main">
<div className="kyc-slot-top">
<span className="kyc-slot-title">{slot.title}{slot.required && <i className="req">*</i>}</span>
<Pill tone={tone}>{slot.status === "missing" ? "Missing" : slot.status === "uploaded" ? "Under review" : slot.status === "verified" ? "Verified" : "Rejected"}</Pill>
</div>
{slot.key !== "photo" && (
<select className="ds-select" value={slot.docId ?? ""} onChange={(e) => onPick(e.target.value)}>
<option value="" disabled>Select document type</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
)}
{docMeta && <div className="kyc-slot-hint">{docMeta.hint} · {docMeta.formats} · {docMeta.maxMb} MB</div>}
{slot.fileName ? (
<div className="kyc-file">
<Icon name="paperclip" size={14} />
<span className="kyc-file-name">{slot.fileName}</span>
{slot.note && <span className="kyc-file-note">{slot.note}</span>}
<button className="ds-iconbtn sm" aria-label="Remove" onClick={onRemove}><Icon name="trash" size={14} /></button>
</div>
) : (
<button className="kyc-drop" onClick={onUpload}>
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
</button>
)}
</div>
</div>
);
}
/* ============================================================ */
/* Tab: Security */
/* ============================================================ */
function SecurityTab() {
const { push } = useToast();
const [pw, setPw] = useState("");
const [twoFA, setTwoFA] = useState(false);
const [authApp, setAuthApp] = useState(false);
const [alerts, setAlerts] = useState(true);
const [twoFaModal, setTwoFaModal] = useState(false);
const strength = passwordStrength(pw);
return (
<div className="grid-2">
<div className="card">
<div className="card-head"><h3>Password</h3><Pill tone="muted">Updated 5 days ago</Pill></div>
<Field label="New password" hint="Min 8 chars · upper & lower · a number · a special character · no common patterns.">
<input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} />
</Field>
{pw && (
<div className="pw-meter">
<div className="pw-bars">{[0, 1, 2, 3].map((i) => <span key={i} className={i < strength.score ? `lvl lvl-${strength.label.toLowerCase()}` : "lvl"} />)}</div>
<div className="pw-label">{strength.label}</div>
</div>
)}
<ul className="pw-checks">
{strength.checks.map((c) => <li key={c.label} className={c.ok ? "ok" : ""}><Icon name={c.ok ? "check-circle" : "x"} size={14} /> {c.label}</li>)}
</ul>
<div className="card-actions"><Btn icon="key" disabled={strength.score < 3} onClick={() => { push({ tone: "success", title: "Password updated" }); setPw(""); }}>Update password</Btn></div>
</div>
<div className="card">
<div className="card-head"><h3>Two-factor &amp; sign-in</h3></div>
<SettingRow icon="shield-check" title="Two-factor authentication" desc="Requires your password and a one-time code to enable.">
<Toggle checked={twoFA} onChange={(v) => { if (v) setTwoFaModal(true); else { setTwoFA(false); setAuthApp(false); push({ tone: "info", title: "Two-factor disabled" }); } }} label="2FA" />
</SettingRow>
<SettingRow icon="key" title="Authenticator app" desc={authApp ? "Connected · codes from your app" : "Use an app like Google Authenticator"}>
<Toggle checked={authApp} disabled={!twoFA} onChange={(v) => { setAuthApp(v); push({ tone: v ? "success" : "info", title: v ? "Authenticator linked" : "Authenticator removed" }); }} label="Authenticator" />
</SettingRow>
<SettingRow icon="bell" title="Login alerts" desc="Notify me when a new device signs in.">
<Toggle checked={alerts} onChange={(v) => { setAlerts(v); push({ tone: "info", title: v ? "Login alerts on" : "Login alerts off" }); }} label="Login alerts" />
</SettingRow>
{!twoFA && <p className="muted-note"><Icon name="info" size={13} /> Enable two-factor authentication to link an authenticator app.</p>}
</div>
<TwoFaModal open={twoFaModal} onClose={() => setTwoFaModal(false)} onDone={() => { setTwoFA(true); setTwoFaModal(false); push({ tone: "success", title: "Two-factor enabled", desc: "Your account is now protected with 2FA." }); }} />
</div>
);
}
function TwoFaModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
const { push } = useToast();
const [step, setStep] = useState<"password" | "otp">("password");
const [pw, setPw] = useState("");
const [otp, setOtp] = useState("");
function close() { setStep("password"); setPw(""); setOtp(""); onClose(); }
return (
<Modal open={open} onClose={close} icon="shield-check" title="Enable two-factor authentication"
subtitle={step === "password" ? "Confirm your password to continue." : `Enter the code sent to ${user.mobile.masked}.`}
footer={step === "password"
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="arrow" iconRight="arrow" disabled={pw.length < 4} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: "OTP sent to your registered mobile." }); }}>Continue</Btn></>
: <><Btn variant="ghost" onClick={() => setStep("password")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={onDone}>Enable 2FA</Btn></>}
>
{step === "password" ? (
<Field label="Current password"><input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} /></Field>
) : (
<div className="otp-wrap"><OtpField value={otp} onChange={setOtp} /><span className="muted-note">Both password and OTP are required to turn 2FA on.</span></div>
)}
</Modal>
);
}
function SettingRow({ icon, title, desc, children }: { icon: string; title: string; desc: string; children: React.ReactNode }) {
return (
<div className="setting-row">
<span className="setting-ic"><Icon name={icon} size={18} /></span>
<div className="setting-txt"><div className="setting-title">{title}</div><div className="setting-desc">{desc}</div></div>
<div className="setting-ctl">{children}</div>
</div>
);
}
/* ============================================================ */
/* Tab: Notifications */
/* ============================================================ */
function NotificationsTab() {
const { push } = useToast();
const [prefs, setPrefs] = useState<NotifCategory[]>(contactPrefs);
const [tz, setTz] = useState(contactTimezones[0]);
const [times, setTimes] = useState(contactTimes);
const channels: { key: keyof NotifCategory; label: string; icon: string }[] = [
{ key: "email", label: "Email", icon: "mail" },
{ key: "sms", label: "SMS", icon: "phone" },
{ key: "whatsapp", label: "WhatsApp", icon: "chat" },
{ key: "push", label: "Push", icon: "bell" },
];
function toggle(catId: string, ch: keyof NotifCategory) {
setPrefs((p) => p.map((c) => {
if (c.id !== catId) return c;
if (c.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return c; }
return { ...c, [ch]: !c[ch] } as NotifCategory;
}));
}
return (
<div className="grid-side">
<div className="card card-pad-0">
<div className="card-head pad"><h3>Per-channel matrix</h3><span className="muted-note">Choose how each category reaches you</span></div>
<div className="notif-table">
<div className="notif-row notif-head">
<span>Category</span>
{channels.map((c) => <span key={c.key} className="notif-ch"><Icon name={c.icon} size={14} /> {c.label}</span>)}
</div>
{prefs.map((cat) => (
<div className="notif-row" key={cat.id}>
<span className="notif-cat">
<span className="notif-cat-name">{cat.label}{cat.locked && <Icon name="lock" size={12} className="lock-ic" />}</span>
<span className="notif-cat-desc">{cat.desc}</span>
</span>
{channels.map((c) => (
<span key={c.key} className="notif-ch">
<Toggle checked={Boolean(cat[c.key])} disabled={cat.locked} onChange={() => toggle(cat.id, c.key)} label={`${cat.label} ${c.label}`} />
</span>
))}
</div>
))}
</div>
</div>
<div className="card-stack">
<div className="card">
<div className="card-head"><h3>Contact-time window</h3></div>
<Field label="Timezone">
<select className="ds-select" value={tz} onChange={(e) => setTz(e.target.value)}>
{contactTimezones.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<div className="time-windows">
{times.map((t) => (
<button key={t.id} className={`time-win ${t.enabled ? "on" : ""}`} onClick={() => setTimes((s) => s.map((x) => x.id === t.id ? { ...x, enabled: !x.enabled } : x))}>
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
<span className="tw-label">{t.label}</span>
<span className="tw-range">{t.range}</span>
</button>
))}
</div>
<p className="muted-note"><Icon name="info" size={13} /> Callbacks &amp; proactive messages only arrive inside enabled windows, in your timezone.</p>
</div>
<div className="card">
<div className="card-head"><h3>Destinations</h3></div>
<div className="dest-list">
{contactDestinations.map((d) => (
<div className="dest-row" key={d.id}>
<span className="dest-ic"><Icon name={d.channel === "email" ? "mail" : d.channel === "sms" ? "phone" : "chat"} size={15} /></span>
<div className="dest-txt"><div className="dest-label">{d.label}{d.primary && <Pill tone="orange">Primary</Pill>}</div><div className="dest-val">{d.value}</div></div>
{d.verified ? <Pill tone="green"><Icon name="check" size={12} /> Verified</Pill> : <Btn variant="outline" size="sm" onClick={() => push({ tone: "info", title: "Verification sent" })}>Verify</Btn>}
</div>
))}
</div>
<Btn variant="ghost" icon="plus" onClick={() => push({ tone: "info", title: "Add destination" })}>Add destination</Btn>
</div>
</div>
</div>
);
}
/* ============================================================ */
/* Tab: Privacy & Consent */
/* ============================================================ */
function PrivacyTab() {
const { push } = useToast();
const [groups, setGroups] = useState<ConsentGroup[]>(consentGroups);
function toggle(gid: string, iid: string) {
setGroups((gs) => gs.map((g) => g.id !== gid ? g : {
...g,
items: g.items.map((it) => {
if (it.id !== iid) return it;
if (it.locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return it; }
return { ...it, granted: !it.granted };
}),
}));
}
return (
<div className="consent-cols">
{groups.map((g) => (
<div className="card" key={g.id}>
<div className="card-head">
<h3>{g.title}</h3>
{g.id === "marketing" && <Pill tone="muted">Optional</Pill>}
{g.id === "essential" && <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>}
</div>
<div className="consent-list">
{g.items.map((it) => (
<div className={`consent-item ${it.locked ? "locked" : ""}`} key={it.id}>
<div className="consent-txt">
<div className="consent-label">{it.label}{it.locked && <Icon name="lock" size={12} className="lock-ic" />}</div>
<div className="consent-desc">{it.desc}</div>
</div>
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id)} label={it.label} />
</div>
))}
</div>
{g.id === "marketing" && groups.find((x) => x.id === "marketing")!.items.find((i) => i.id === "promos")!.granted && (
<div className="consent-sub">
<div className="muted-note">Promotional sub-preferences</div>
{["Product offers", "Surveys & feedback", "Partner news"].map((s, i) => (
<label className="consent-sub-row" key={s}><span>{s}</span><Toggle checked={i === 0} onChange={() => {}} label={s} /></label>
))}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================ */
/* Tab: Devices */
/* ============================================================ */
function DevicesTab() {
const { push } = useToast();
const [devices, setDevices] = useState(loginActivity);
function signOut(id: string) {
setDevices((d) => d.filter((x) => x.id !== id));
push({ tone: "success", title: "Signed out", desc: "That device no longer has access." });
}
return (
<div className="grid-side">
<div className="card">
<div className="card-head">
<h3>Connected devices</h3>
<Btn variant="outline" size="sm" icon="logout" onClick={() => { setDevices((d) => d.filter((x) => x.current)); push({ tone: "success", title: "Other devices signed out" }); }}>Sign out all others</Btn>
</div>
<div className="device-list">
{devices.filter((d) => d.event !== "password-change").map((d) => (
<div className={`device-row ${d.current ? "current" : ""}`} key={d.id}>
<span className="device-ic"><Icon name={d.kind} size={20} /></span>
<div className="device-txt">
<div className="device-name">{d.device}{d.current && <Pill tone="green">This device</Pill>}{!d.trusted && <Pill tone="orange"><Icon name="alert" size={11} /> New</Pill>}</div>
<div className="device-meta">{d.browser} · {d.os}</div>
<div className="device-meta"><Icon name="pin" size={12} /> {d.location} · {d.ip} · {d.lastActive}</div>
</div>
{!d.current && <button className="ds-iconbtn" aria-label="Sign out" onClick={() => signOut(d.id)}><Icon name="logout" size={16} /></button>}
</div>
))}
</div>
</div>
<div className="card">
<div className="card-head"><h3>Recent activity</h3></div>
<div className="timeline">
{loginActivity.map((a) => (
<div className="tl-item" key={a.id}>
<span className={`tl-dot ev-${a.event}`}><Icon name={a.event === "password-change" ? "key" : a.event === "new-device" ? "alert" : a.event === "2fa-enabled" ? "shield-check" : "check"} size={12} /></span>
<div className="tl-body">
<div className="tl-title">{labelForEvent(a.event)}</div>
<div className="tl-meta">{a.device} · {a.location}</div>
<div className="tl-time">{a.lastActive}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
function labelForEvent(ev: string) {
switch (ev) {
case "password-change": return "Password changed";
case "new-device": return "New device signed in";
case "2fa-enabled": return "Two-factor enabled";
case "sign-out": return "Signed out";
default: return "Successful sign-in";
}
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
// ============================================================
// Rules Checklist — the 113 business rules in one place.
// A single filter bar + a uniform (brand-orange) card grid:
// each rule card has a ghost watermark number, an icon and an
// "enforced" footer. No per-category colours.
// (house 181 · OTP to registered mobile · KYC two-docs ·
// password policy · ticket lifecycle · …)
// ============================================================
import { useMemo, useState } from "react";
import { rules } from "./account-data";
import { Icon, PageHead, Pill, Segmented } from "./ui";
// Each rule category maps to a glyph so cards read at a glance.
const TAG_ICON: Record<string, string> = {
Profile: "user", Security: "shield", KYC: "verify", Support: "chat",
Notifications: "bell", Privacy: "privacy",
};
export function Rules() {
const tags = useMemo(() => ["All", ...Array.from(new Set(rules.map((r) => r.tag)))], []);
const [tag, setTag] = useState("All");
const list = tag === "All" ? rules : rules.filter((r) => r.tag === tag);
return (
<div className="view">
<PageHead
eyebrow="Reference"
title="Rules Checklist"
subtitle="The 13 business rules that govern Profile & Support — one source of truth."
icon="check-circle"
actions={<Pill tone="blue">{rules.length} rules</Pill>}
/>
<Segmented value={tag} onChange={setTag} options={tags.map((t) => ({ value: t, label: t }))} />
<div className="rules-grid view-body">
{list.map((r) => (
<article className="card rule-card" key={r.n}>
<span className="rule-watermark" aria-hidden>{String(r.n).padStart(2, "0")}</span>
<div className="rule-top">
<span className="rule-ic"><Icon name={TAG_ICON[r.tag] ?? "check-circle"} size={18} /></span>
<Pill tone="muted">{r.tag}</Pill>
</div>
<div className="rule-title">{r.title}</div>
<p className="rule-detail">{r.detail}</p>
<div className="rule-foot"><Icon name="shield-check" size={13} /> Enforced · Rule {r.n}</div>
</article>
))}
</div>
</div>
);
}
+129 -46
View File
@@ -1,88 +1,171 @@
"use client"; "use client";
import { ChevronsUpDown } from "lucide-react"; import { useState } from "react";
import { FigIcon } from "./figicon"; import { useRouter } from "next/navigation";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui";
import { user } from "./account-data";
import { useMyAccess } from "@/lib/access";
type Item = { key: string; label: string }; function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
const GROUPS: { title: string; items: Item[] }[] = [ export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
export type NavGroup = { title: string; items: NavItem[] };
// Full workspace navigation. The Account group items (profile/support/rules)
// render real views; the rest are workspace modules shown via a placeholder.
export const NAV_GROUPS: NavGroup[] = [
{ {
title: "Workspace", title: "Workspace",
items: [ items: [
{ key: "dashboard", label: "Dashboard" }, { key: "dashboard", label: "Dashboard", icon: "dashboard", subtitle: "Welcome back — your territory at a glance" },
{ key: "owners", label: "Owners Box" }, { key: "owners", label: "Owners Box", icon: "owners" },
{ key: "projects", label: "Projects" }, { key: "projects", label: "Projects", icon: "projects" },
{ key: "leads", label: "Leads" }, { key: "leads", label: "Leads", icon: "leads" },
{ key: "verify", label: "Lead Verification" }, { key: "verify", label: "Lead Verification", icon: "verify" },
{ key: "pipeline", label: "Pipeline" }, { key: "pipeline", label: "Pipeline", icon: "pipeline" },
],
},
{
title: "Communication",
items: [
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, replies and updates for you" },
], ],
}, },
{ {
title: "Workspace", title: "Workspace",
items: [ items: [
{ key: "dispatch", label: "LynkDispatch" }, { key: "dispatch", label: "LynkDispatch", icon: "dispatch" },
{ key: "storm", label: "Storm Intel" }, { key: "storm", label: "Storm Intel", icon: "storm" },
{ key: "territory", label: "Territory Map" }, { key: "territory", label: "Territory Map", icon: "territory" },
{ key: "procanvas", label: "ProCanvas" }, { key: "procanvas", label: "ProCanvas", icon: "procanvas" },
{ key: "estimates", label: "Estimates" }, { key: "estimates", label: "Estimates", icon: "estimates" },
], ],
}, },
{ {
title: "Team", title: "Team",
items: [ items: [
{ key: "schedule", label: "Team Schedule" }, { key: "team", label: "Team Management", icon: "team", subtitle: "Your crew, roles and permissions" },
{ key: "leaderboard", label: "Leaderboard" }, { key: "schedule", label: "Team Schedule", icon: "schedule" },
{ key: "subtasks", label: "Subcontractor Tasks" }, { key: "leaderboard", label: "Leaderboard", icon: "leaderboard" },
{ key: "people", label: "People" }, { key: "subtasks", label: "Subcontractor Tasks", icon: "subtasks" },
{ key: "people", label: "People", icon: "people" },
],
},
{
title: "Workspace AI",
items: [
{ key: "settings", label: "Org Settings", icon: "settings" },
{ key: "ai", label: "AI Assistant", icon: "ai", subtitle: "Lynk AI — your roofing copilot" },
],
},
{
title: "Profile & Documents",
items: [
{ key: "profile", label: "Profile", icon: "user", subtitle: "Your account, identity and preferences" },
{ key: "support", label: "Support Center", icon: "chat", subtitle: "Get help, track requests and find answers" },
{ key: "rules", label: "Rules Checklist", icon: "check-circle", subtitle: "The 13 rules behind Profile & Support" },
], ],
}, },
]; ];
const EXTRA: Item[] = [ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
{ key: "settings", label: "Org Settings" },
{ key: "ai", label: "AI Assistant" }, // Nav visibility by CRM permission. Dashboard + Profile are always visible (even to a
{ key: "home", label: "Home" }, // brand-new user with no membership); every other item requires membership, and the
]; // items mapped here additionally require the given permission. Unmapped items are
// shown to any member. This is UX only — be-crm still enforces every action.
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox"]);
const NAV_PERMISSION: Record<string, string | undefined> = {
team: "team.manage",
people: "team.manage",
leads: "leads.manage",
verify: "leads.manage",
pipeline: "pipeline.manage",
estimates: "estimates.create",
procanvas: "estimates.create",
dispatch: "dispatch.manage",
schedule: "dispatch.manage",
storm: "dispatch.manage",
territory: "dispatch.manage",
leaderboard: "reports.view",
settings: "settings.manage",
};
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) { export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
const router = useRouter();
const { user: me, logout, context } = useAuth();
const access = useMyAccess();
const [menuOpen, setMenuOpen] = useState(false);
// A new user with no membership sees only Dashboard + Profile. Members see the areas
// their permissions allow. While access is still loading, keep it minimal to avoid
// flashing items the user can't actually use.
const canSee = (key: string): boolean => {
if (ALWAYS_VISIBLE.has(key)) return true;
if (access.loading || !access.isMember) return false;
const perm = NAV_PERMISSION[key];
return perm ? access.can(perm) : true;
};
const visibleGroups = NAV_GROUPS
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.key)) }))
.filter((g) => g.items.length > 0);
// Real signed-in identity from the App Context Envelope; fall back to the static
// demo user only when the Shell isn't wired.
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
router.replace("/portal/login");
}
return ( return (
<aside className="dash-sidebar"> <aside className="dash-sidebar">
<div className="dash-brand"> <div className="dash-brand">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt="LynkedUp Pro" /> <img src="/image/logo.png" alt="LynkedUp Pro" />
<span className="bk">LynkedUp Pro</span>
</div> </div>
<nav className="dash-nav"> <nav className="dash-nav">
{GROUPS.map((g, gi) => ( {visibleGroups.map((g, gi) => (
<div key={gi}> <div className="nav-section" key={gi}>
<div className="nav-group">{g.title}</div> <div className="nav-group">{g.title}</div>
{g.items.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)} {g.items.map((it) => (
<button key={it.key} className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
<Icon name={it.icon} size={18} />
<span className="nav-item-label">{it.label}</span>
</button>
))}
</div> </div>
))} ))}
<div style={{ height: 8 }} />
{EXTRA.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
</nav> </nav>
<div className="sb-foot"> <div className="sb-foot" style={{ position: "relative" }}>
<div className="sb-user"> {menuOpen && (
<span className="av" style={{ background: "linear-gradient(135deg,#285ef0,#09b9c6)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span> <>
<div style={{ flex: 1 }}> <div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="nm">Justin Johnson</div> <div className="sb-user-menu" role="menu">
<div className="rl">Owner</div> <button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
</div> </div>
<ChevronsUpDown size={15} /> <ChevronsUpDown size={15} />
</div> </button>
</div> </div>
</aside> </aside>
); );
} }
function NavBtn({ it, active, onSelect }: { it: Item; active: string; onSelect: (k: string) => void }) {
return (
<button className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
<FigIcon name={it.key} size={18} />
{it.label}
</button>
);
}
+609
View File
@@ -0,0 +1,609 @@
"use client";
// ============================================================
// Support Center
// Overview (5 channel cards + team) · Message Center ·
// New Ticket · My Tickets (+ timeline modal) · Help Center
// plus Live Chat handshake & Callback/Email modals.
// ============================================================
import { useEffect, useMemo, useRef, useState } from "react";
import {
supportChannels, supportTeam, ticketCategories, tickets as ticketSeed,
supportThreads as threadSeed, helpTopics, contactTimes, countryCodes,
type Ticket, type TicketStatus, type SupportThread, type ChatMsg,
} from "./account-data";
import {
Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, StatusDot, useToast,
} from "./ui";
const SECTIONS = [
{ value: "overview", label: "Overview", icon: "chat" },
{ value: "messages", label: "Messages", icon: "mail" },
{ value: "new", label: "New Ticket", icon: "ticket" },
{ value: "tickets", label: "My Tickets", icon: "book" },
{ value: "help", label: "Help Center", icon: "info" },
];
const STATUS_META: Record<TicketStatus | "created", { label: string; tone: string }> = {
created: { label: "Created", tone: "muted" },
open: { label: "Open", tone: "blue" },
"in-progress": { label: "In Progress", tone: "orange" },
"awaiting-customer": { label: "Awaiting You", tone: "purple" },
resolved: { label: "Resolved", tone: "green" },
closed: { label: "Closed", tone: "muted" },
};
export function Support() {
const [section, setSection] = useState("overview");
const [chatOpen, setChatOpen] = useState(false);
const [callbackOpen, setCallbackOpen] = useState(false);
const [emailOpen, setEmailOpen] = useState(false);
function onChannel(id: string) {
if (id === "chat") setChatOpen(true);
else if (id === "ticket") setSection("new");
else if (id === "callback") setCallbackOpen(true);
else if (id === "email") setEmailOpen(true);
else if (id === "help") setSection("help");
}
return (
<div className="view">
<PageHead
eyebrow="Help & Support"
title="Support Center"
subtitle="Reach a human, track a request, or find an answer fast."
icon="chat"
actions={<Btn icon="chat" onClick={() => setChatOpen(true)}>Start live chat</Btn>}
/>
<Segmented options={SECTIONS} value={section} onChange={setSection} />
<div className="view-body">
{section === "overview" && <Overview onChannel={onChannel} onSection={setSection} />}
{section === "messages" && <MessageCenter />}
{section === "new" && <NewTicket onDone={() => setSection("tickets")} />}
{section === "tickets" && <MyTickets />}
{section === "help" && <HelpCenter />}
</div>
<LiveChatModal open={chatOpen} onClose={() => setChatOpen(false)} />
<CallbackModal open={callbackOpen} onClose={() => setCallbackOpen(false)} />
<EmailModal open={emailOpen} onClose={() => setEmailOpen(false)} />
</div>
);
}
/* ============================================================ */
/* Overview — channel cards + support team */
/* ============================================================ */
const MINI_AREA: Record<string, string> = { ticket: "ticket", callback: "call", email: "email", help: "help" };
function Overview({ onChannel, onSection }: { onChannel: (id: string) => void; onSection: (s: string) => void }) {
const online = supportTeam.filter((a) => a.status === "online");
const mini = supportChannels.filter((c) => ["ticket", "callback", "email", "help"].includes(c.id));
const recent = ticketSeed.slice(0, 4);
// Derived "live" metrics so the command-center ribbon stays in sync with data.
const openCount = ticketSeed.filter((t) => !["resolved", "closed"].includes(t.status)).length;
const avgRating = (supportTeam.reduce((s, a) => s + a.rating, 0) / supportTeam.length).toFixed(1);
const pulse = [
{ icon: "people", value: String(online.length), label: "Agents online", live: true },
{ icon: "clock", value: "< 2m", label: "Avg wait time" },
{ icon: "ticket", value: String(openCount), label: "Open tickets" },
{ icon: "star", value: avgRating, label: "Avg CSAT rating" },
];
return (
<div className="support-ov">
{/* Live command-center ribbon */}
<div className="sup-status">
<span className="sup-status-l"><span className="sup-status-dot" /> All systems operational</span>
<span className="sup-status-r">Support · Live status</span>
</div>
<div className="sup-pulse">
{pulse.map((p) => (
<div className="sup-stat" key={p.label}>
<span className="sup-stat-ic"><Icon name={p.icon} size={17} /></span>
<div className="sup-stat-txt">
<b>{p.value}{p.icon === "star" && <Icon name="star" size={12} />}</b>
<span>{p.label}</span>
</div>
{p.live && <span className="sup-stat-live" />}
</div>
))}
</div>
<div className="support-bento">
{/* Hero — live chat */}
<button className="bento-tile bento-chat" onClick={() => onChannel("chat")}>
<span className="bento-chat-sig" aria-hidden><span /><span /><span /></span>
<div className="bento-chat-top">
<span className="bento-chat-badge"><StatusDot status="online" /> Agents online now</span>
<span className="bento-chat-wait"><Icon name="clock" size={13} /> Avg wait &lt; 2 min</span>
</div>
<div className="bento-chat-mid">
<h2>Need a hand?<br />Chat with us live.</h2>
<p>Instant help from a real support specialist no queues, no bots, no waiting on hold.</p>
</div>
<div className="bento-chat-foot">
<div className="bento-avstack">
{online.map((a) => <Avatar key={a.id} initials={a.initials} gradient={a.gradient} size={36} />)}
<span className="bento-av-more">+{online.length} online</span>
</div>
<span className="bento-chat-cta">Start chat <Icon name="arrow" size={16} /></span>
</div>
</button>
{/* Support team */}
<div className="bento-tile bento-team">
<div className="bento-tile-head"><h3>Your support team</h3><Pill tone="green">{online.length} online</Pill></div>
<div className="team-list">
{supportTeam.map((a) => (
<div className="team-row" key={a.id}>
<Avatar initials={a.initials} gradient={a.gradient} size={40} status={a.status} />
<div className="team-txt">
<div className="team-name">{a.name}</div>
<div className="team-role">{a.role}</div>
<div className="team-team"><Icon name="shield" size={11} /> {a.team}</div>
</div>
<div className="team-rating"><Icon name="star" size={13} /> {a.rating}</div>
</div>
))}
</div>
</div>
{/* Mini channel tiles */}
{mini.map((c) => (
<button key={c.id} className="bento-tile bento-mini" style={{ ["--accent" as string]: c.accent, gridArea: MINI_AREA[c.id] }} onClick={() => onChannel(c.id)}>
<span className="bento-mini-ic"><Icon name={c.icon} size={20} /></span>
<div className="bento-mini-title">{c.title}</div>
<div className="bento-mini-desc">{c.desc}</div>
<div className="bento-mini-meta"><span>{c.meta}</span> <Icon name="arrow" size={14} /></div>
</button>
))}
{/* Recent tickets */}
<div className="bento-tile bento-recent">
<div className="bento-tile-head"><h3>Recent tickets</h3><button className="link-inline" onClick={() => onSection("tickets")}>View all</button></div>
<div className="bento-ticket-row">
{recent.map((t) => (
<button key={t.id} className="bento-ticket" onClick={() => onSection("tickets")}>
<div className="bento-ticket-top"><span className="t-id">{t.id}</span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></div>
<div className="bento-ticket-subj">{t.subject}</div>
<div className="bento-ticket-meta"><Icon name="clock" size={12} /> {t.updatedAt} · {t.department}</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
/* ============================================================ */
/* Message Center — inbox + live thread + typing */
/* ============================================================ */
function MessageCenter() {
const [threads, setThreads] = useState<SupportThread[]>(threadSeed);
const [activeId, setActiveId] = useState(threadSeed[0].id);
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement | null>(null);
const active = threads.find((t) => t.id === activeId)!;
function send() {
const text = draft.trim();
if (!text) return;
const msg: ChatMsg = { id: `m${Date.now()}`, from: "me", text, at: "Now" };
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, msg], typing: true, preview: text } : t));
setDraft("");
// simulate agent typing → reply
setTimeout(() => {
const reply: ChatMsg = { id: `m${Date.now() + 1}`, from: "agent", text: "Thanks — noted. I'll update the ticket and get back to you shortly.", at: "Now" };
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, reply], typing: false } : t));
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, 1800);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
}
function openThread(id: string) {
setActiveId(id);
setThreads((ts) => ts.map((t) => t.id === id ? { ...t, unread: 0 } : t));
}
const agent = supportTeam.find((a) => a.id === active.agentId)!;
return (
<div className="card card-pad-0 msgctr">
<aside className="msg-inbox">
<div className="msg-inbox-head">Inbox <Pill tone="orange">{threads.reduce((n, t) => n + t.unread, 0)}</Pill></div>
<div className="msg-inbox-list">
{[...threads].sort((a, b) => Number(b.pinned) - Number(a.pinned)).map((t) => {
const ta = supportTeam.find((x) => x.id === t.agentId)!;
return (
<button key={t.id} className={`msg-thread ${t.id === activeId ? "active" : ""}`} onClick={() => openThread(t.id)}>
<Avatar initials={ta.initials} gradient={ta.gradient} size={38} status={ta.status} />
<div className="msg-thread-txt">
<div className="msg-thread-top"><span className="msg-thread-name">{ta.name}</span><span className="msg-thread-time">{t.updatedAt}</span></div>
<div className="msg-thread-sub">{t.subject}</div>
<div className="msg-thread-prev">{t.typing ? <em className="typing-now">typing</em> : t.preview}</div>
</div>
{t.unread > 0 && <span className="msg-unread">{t.unread}</span>}
{t.pinned && <Icon name="check" size={12} className="msg-pin" />}
</button>
);
})}
</div>
</aside>
<section className="msg-thread-pane">
<header className="msg-pane-head">
<Avatar initials={agent.initials} gradient={agent.gradient} size={40} status={agent.status} />
<div className="msg-pane-id">
<div className="msg-pane-name">{agent.name}</div>
<div className="msg-pane-sub">{active.typing ? <span className="typing-now">typing</span> : `${agent.role} · ${agent.team}`}</div>
</div>
<Pill tone="blue">{active.subject}</Pill>
</header>
<div className="msg-stream">
{active.messages.map((m) => (
<div key={m.id} className={`bubble-row ${m.from}`}>
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={28} />}
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
</div>
))}
{active.typing && (
<div className="bubble-row agent">
<Avatar initials={agent.initials} gradient={agent.gradient} size={28} />
<div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div>
</div>
)}
<div ref={endRef} />
</div>
<div className="msg-compose">
<button className="ds-iconbtn" aria-label="Attach"><Icon name="paperclip" size={18} /></button>
<input className="ds-input flush" placeholder="Write a reply…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
</div>
</section>
</div>
);
}
/* ============================================================ */
/* New Ticket — category → department routing + attachment rules */
/* ============================================================ */
function NewTicket({ onDone }: { onDone: () => void }) {
const { push } = useToast();
const [catId, setCatId] = useState("");
const [subject, setSubject] = useState("");
const [desc, setDesc] = useState("");
const [priority, setPriority] = useState("medium");
const [file, setFile] = useState<string | null>(null);
const cat = ticketCategories.find((c) => c.id === catId) ?? null;
const needAttachment = cat?.requiresAttachment && !file;
const canSubmit = catId && subject.trim() && desc.trim() && !needAttachment;
function submit() {
push({ tone: "success", title: "Ticket created", desc: `Routed to ${cat?.department}. We'll reply within ${cat?.sla}.` });
onDone();
}
return (
<div className="grid-side">
<div className="card">
<div className="card-head"><h3>Raise a new ticket</h3></div>
<Field label="What is this about?" required>
<select className="ds-select" value={catId} onChange={(e) => { setCatId(e.target.value); setFile(null); }}>
<option value="" disabled>Select a category</option>
{ticketCategories.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</Field>
{cat && (
<div className="route-banner">
<Icon name="arrow" size={15} />
<span>Routes to <strong>{cat.department}</strong></span>
<Pill tone="blue"><Icon name="clock" size={12} /> SLA {cat.sla}</Pill>
</div>
)}
<Field label="Subject" required><input className="ds-input" placeholder="Short summary" value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
<Field label="Describe the issue" required><textarea className="ds-input ds-textarea" rows={4} placeholder="Add as much detail as you can…" value={desc} onChange={(e) => setDesc(e.target.value)} /></Field>
<Field label="Priority">
<Segmented value={priority} onChange={setPriority} options={[
{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" },
]} />
</Field>
{cat && (
<Field label={`Attachment${cat.requiresAttachment ? "" : " (optional)"}`} required={cat.requiresAttachment} error={needAttachment ? "This category requires an attachment." : undefined} hint={cat.attachmentNote}>
{file ? (
<div className="kyc-file"><Icon name="paperclip" size={14} /><span className="kyc-file-name">{file}</span><button className="ds-iconbtn sm" aria-label="Remove" onClick={() => setFile(null)}><Icon name="trash" size={14} /></button></div>
) : (
<button className="kyc-drop" onClick={() => setFile("attachment.pdf")}><Icon name="upload" size={16} /> Attach a file</button>
)}
</Field>
)}
<div className="card-actions"><Btn icon="send" disabled={!canSubmit} onClick={submit}>Submit ticket</Btn></div>
</div>
<div className="card card-muted">
<div className="card-head"><h3>How routing works</h3></div>
<div className="route-list">
{ticketCategories.map((c) => (
<div className="route-item" key={c.id}>
<div className="route-cat">{c.label}</div>
<Icon name="arrow" size={13} />
<div className="route-dept">{c.department}</div>
{c.requiresAttachment && <Pill tone="orange"><Icon name="paperclip" size={11} /> Attach</Pill>}
</div>
))}
</div>
</div>
</div>
);
}
/* ============================================================ */
/* My Tickets — table + status timeline modal */
/* ============================================================ */
function MyTickets() {
const [filter, setFilter] = useState("all");
const [open, setOpen] = useState<Ticket | null>(null);
const list = useMemo(() => filter === "all" ? ticketSeed : ticketSeed.filter((t) => filter === "active" ? !["resolved", "closed"].includes(t.status) : ["resolved", "closed"].includes(t.status)), [filter]);
return (
<div className="card card-pad-0">
<div className="card-head pad">
<h3>My tickets</h3>
<Segmented value={filter} onChange={setFilter} options={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "closed", label: "Closed" }]} />
</div>
<div className="ticket-table">
<div className="ticket-row ticket-head">
<span>Ticket</span><span>Department</span><span>Priority</span><span>Status</span><span>Updated</span>
</div>
{list.map((t) => (
<button key={t.id} className="ticket-row" onClick={() => setOpen(t)}>
<span className="t-subject"><span className="t-id">{t.id}</span>{t.subject}</span>
<span className="t-dept">{t.department}</span>
<span><Pill tone={t.priority === "urgent" ? "red" : t.priority === "high" ? "orange" : t.priority === "medium" ? "blue" : "muted"}>{t.priority}</Pill></span>
<span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></span>
<span className="t-updated">{t.updatedAt} <Icon name="chevron-right" size={14} /></span>
</button>
))}
</div>
<Modal open={open != null} onClose={() => setOpen(null)} icon="ticket" size="md" title={open?.subject ?? ""} subtitle={open ? `${open.id} · ${open.department}` : ""}
footer={<><Btn variant="ghost" onClick={() => setOpen(null)}>Close</Btn><Btn icon="chat">Reply in chat</Btn></>}>
{open && (
<>
<div className="ticket-modal-meta">
<Pill tone={STATUS_META[open.status].tone}>{STATUS_META[open.status].label}</Pill>
<Pill tone="muted">Priority: {open.priority}</Pill>
<Pill tone="muted">Opened {open.createdAt}</Pill>
</div>
<div className="lifecycle">
{(["open", "in-progress", "awaiting-customer", "resolved", "closed"] as TicketStatus[]).map((s, i) => {
const reached = open.timeline.some((e) => e.status === s) || (s === "open");
return <span key={s} className={`lc-step ${reached ? "done" : ""} ${open.status === s ? "current" : ""}`}>{i > 0 && <span className="lc-line" />}<span className="lc-dot" />{STATUS_META[s].label}</span>;
})}
</div>
<div className="timeline">
{open.timeline.slice().reverse().map((e, i) => (
<div className="tl-item" key={i}>
<span className={`tl-dot ev-${e.status}`}><Icon name={e.status === "resolved" || e.status === "closed" ? "check" : e.status === "awaiting-customer" ? "alert" : "chat"} size={12} /></span>
<div className="tl-body"><div className="tl-title">{e.label}</div><div className="tl-meta">{e.by}</div><div className="tl-time">{e.at}</div></div>
</div>
))}
</div>
</>
)}
</Modal>
</div>
);
}
/* ============================================================ */
/* Help Center — search + browse accordion */
/* ============================================================ */
function HelpCenter() {
const [q, setQ] = useState("");
const [openId, setOpenId] = useState<string | null>(helpTopics[0].id);
const query = q.trim().toLowerCase();
const results = useMemo(() => {
if (!query) return null;
const hits: { topic: string; q: string; a: string }[] = [];
for (const t of helpTopics) for (const a of t.articles) if ((a.q + a.a).toLowerCase().includes(query)) hits.push({ topic: t.title, ...a });
return hits;
}, [query]);
return (
<div className="view">
<div className="help-search">
<Icon name="search" size={18} />
<input className="ds-input flush" placeholder="Search guides, FAQs and how-tos…" value={q} onChange={(e) => setQ(e.target.value)} />
{q && <button className="ds-iconbtn sm" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
</div>
{results ? (
<div className="card">
<div className="card-head"><h3>{results.length} result{results.length === 1 ? "" : "s"} for {q}</h3></div>
{results.length === 0 ? <p className="muted-note"><Icon name="info" size={14} /> Nothing matched. Try different keywords or raise a ticket.</p> : (
<div className="help-results">
{results.map((r, i) => (
<details key={i} className="help-acc">
<summary><span className="help-q">{r.q}</span><Pill tone="muted">{r.topic}</Pill><Icon name="chevron" size={16} className="acc-chev" /></summary>
<p>{r.a}</p>
</details>
))}
</div>
)}
</div>
) : (
<div className="help-topics">
{helpTopics.map((t) => (
<div className="card help-topic-card" key={t.id} style={{ ["--accent" as string]: t.accent }}>
<button className="help-topic-head" onClick={() => setOpenId(openId === t.id ? null : t.id)}>
<span className="help-topic-ic"><Icon name={t.icon} size={20} /></span>
<div className="help-topic-txt"><div className="help-topic-title">{t.title}</div><div className="help-topic-count">{t.count} articles</div></div>
<Icon name="chevron" size={18} className={`acc-chev ${openId === t.id ? "open" : ""}`} />
</button>
{openId === t.id && (
<div className="help-acc-list">
{t.articles.map((a, i) => (
<details key={i} className="help-acc">
<summary><span className="help-q">{a.q}</span><Icon name="chevron" size={15} className="acc-chev" /></summary>
<p>{a.a}</p>
</details>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
/* ============================================================ */
/* Live Chat — connecting → joined handshake */
/* ============================================================ */
function LiveChatModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [phase, setPhase] = useState<"connecting" | "joined">("connecting");
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
const [draft, setDraft] = useState("");
const [agentTyping, setAgentTyping] = useState(false);
const agent = supportTeam[0];
const endRef = useRef<HTMLDivElement | null>(null);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
// run the connecting → joined handshake each time the modal opens.
// Resetting + timed phase changes is an animation driven by `open`, so the
// synchronous resets here are intentional.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!open) return;
setPhase("connecting");
setMsgs([]);
setDraft("");
setAgentTyping(false);
const t1 = setTimeout(() => {
setPhase("joined");
setAgentTyping(true);
const t2 = setTimeout(() => {
setAgentTyping(false);
setMsgs([{ id: "g1", from: "agent", text: `Hi! I'm ${agent.name} from ${agent.team}. How can I help you today?`, at: "Now" }]);
}, 1400);
timers.current.push(t2);
}, 2000);
timers.current.push(t1);
const timersRef = timers.current;
return () => { timersRef.forEach(clearTimeout); timers.current = []; };
}, [open, agent.name, agent.team]);
/* eslint-enable react-hooks/set-state-in-effect */
function send() {
const text = draft.trim();
if (!text) return;
setMsgs((m) => [...m, { id: `u${Date.now()}`, from: "me", text, at: "Now" }]);
setDraft("");
setAgentTyping(true);
setTimeout(() => {
setAgentTyping(false);
setMsgs((m) => [...m, { id: `a${Date.now()}`, from: "agent", text: "Got it — let me pull up your account and check that for you.", at: "Now" }]);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
}, 1600);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
}
return (
<Modal open={open} onClose={onClose} size="md" icon="chat" title="Live Chat"
subtitle={phase === "connecting" ? "Connecting you to the next available agent…" : `Connected · ${agent.name}`}>
{phase === "connecting" ? (
<div className="chat-connecting">
<div className="chat-radar"><Avatar initials={agent.initials} gradient={agent.gradient} size={64} /><span className="radar-ring" /><span className="radar-ring d2" /></div>
<div className="chat-connecting-txt">Finding an agent<span className="dots"><span>.</span><span>.</span><span>.</span></span></div>
<div className="muted-note">Average wait under 2 minutes</div>
</div>
) : (
<div className="livechat">
<div className="livechat-joined"><StatusDot status="online" /> {agent.name} joined the chat</div>
<div className="msg-stream sm">
{msgs.map((m) => (
<div key={m.id} className={`bubble-row ${m.from}`}>
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={26} />}
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
</div>
))}
{agentTyping && <div className="bubble-row agent"><Avatar initials={agent.initials} gradient={agent.gradient} size={26} /><div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div>}
<div ref={endRef} />
</div>
<div className="msg-compose">
<input className="ds-input flush" placeholder="Type a message…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
</div>
</div>
)}
</Modal>
);
}
/* ============================================================ */
/* Callback & Email modals */
/* ============================================================ */
function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { push } = useToast();
const [cc, setCc] = useState("+91");
const [num, setNum] = useState("98765 43012");
const [slot, setSlot] = useState(contactTimes.find((t) => t.enabled)?.id ?? contactTimes[0].id);
return (
<Modal open={open} onClose={onClose} icon="phone" title="Request a callback" subtitle="We'll call within your chosen contact-time window."
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="phone" onClick={() => { push({ tone: "success", title: "Callback scheduled", desc: "An agent will call you in your selected window." }); onClose(); }}>Schedule callback</Btn></>}>
<Field label="Phone number">
<div className="phone-input">
<select className="ds-select cc" value={cc} onChange={(e) => setCc(e.target.value)}>{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}</select>
<input className="ds-input flush" value={num} onChange={(e) => setNum(e.target.value)} />
</div>
</Field>
<Field label="Preferred window" hint="Only windows enabled in your notification preferences are offered.">
<div className="cb-slots">
{contactTimes.filter((t) => t.enabled).map((t) => (
<button key={t.id} className={`cb-slot ${slot === t.id ? "active" : ""}`} onClick={() => setSlot(t.id)}>
<span className="cb-slot-l">{t.label}</span><span className="cb-slot-r">{t.range}</span>
</button>
))}
</div>
</Field>
</Modal>
);
}
function EmailModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { push } = useToast();
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
return (
<Modal open={open} onClose={onClose} icon="mail" title="Email support" subtitle="Sends to care@lynkeduppro.com · reply within 1 business day."
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="send" disabled={!subject.trim() || !body.trim()} onClick={() => { push({ tone: "success", title: "Email sent", desc: "We'll reply to your registered email." }); onClose(); }}>Send email</Btn></>}>
<Field label="Subject"><input className="ds-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="How can we help?" /></Field>
<Field label="Message"><textarea className="ds-input ds-textarea" rows={5} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" /></Field>
</Modal>
);
}
+282
View File
@@ -0,0 +1,282 @@
// ============================================================
// LynkedUp Pro — Team Management mock data.
// Members, roles and a permission matrix, modelled the way a
// CRM team module works: every member carries a role, and a
// role is a named bundle of permissions. All client-side so the
// screen is fully interactive without a backend.
// ============================================================
/* ---------------------------------------------------------- */
/* PERMISSIONS — grouped, the building blocks of a role */
/* ---------------------------------------------------------- */
export type Permission = { id: string; label: string; desc: string };
export type PermissionGroup = { id: string; title: string; icon: string; perms: Permission[] };
export const permissionGroups: PermissionGroup[] = [
{
id: "sales",
title: "Sales",
icon: "pipeline",
perms: [
{ id: "leads.manage", label: "Manage leads & contacts", desc: "Create, edit, assign and delete leads" },
{ id: "pipeline.manage", label: "Manage pipeline & deals", desc: "Move deals across stages and edit values" },
{ id: "estimates.create", label: "Create estimates & proposals", desc: "Build and send estimates to owners" },
],
},
{
id: "ops",
title: "Operations",
icon: "dispatch",
perms: [
{ id: "dispatch.manage", label: "Manage dispatch & schedule", desc: "Assign crews and schedule site visits" },
{ id: "billing.view", label: "View billing & invoices", desc: "See invoices, receipts and payment status" },
],
},
{
id: "insights",
title: "Insights",
icon: "leaderboard",
perms: [
{ id: "reports.view", label: "View reports & analytics", desc: "Access dashboards and export reports" },
],
},
{
id: "admin",
title: "Administration",
icon: "settings",
perms: [
{ id: "team.manage", label: "Manage team members", desc: "Invite, edit and deactivate members" },
{ id: "roles.manage", label: "Manage roles & permissions", desc: "Create roles and edit permission sets" },
{ id: "settings.manage", label: "Manage org settings", desc: "Billing plan, branding and integrations" },
],
},
];
export const allPermissionIds: string[] = permissionGroups.flatMap((g) => g.perms.map((p) => p.id));
/* ---------------------------------------------------------- */
/* ROLES — named permission bundles */
/* ---------------------------------------------------------- */
export type Role = {
id: string;
name: string;
color: string; // token, e.g. var(--orange)
description: string;
system: boolean; // system roles can't be deleted
perms: string[]; // granted permission ids
attire: string[]; // "attire & appearance" notes from the persona guide
overlap: string; // "common overlap" note — how the role blends with others
};
// Roles mirror the Construction Industry Personas & Attire reference guide:
// each role carries a plain-language description, the attire/appearance cues
// a person in that role typically shows, and the way the role commonly
// overlaps with others in a small contractor.
export const roles: Role[] = [
{
id: "owner",
name: "Owner",
color: "var(--orange)",
description: "Sets the company standard, drives the vision, builds relationships and stays close to sales, operations, production, client care and financial performance.",
system: true,
perms: [...allPermissionIds],
attire: [
"Tucked-in button-down or refined work shirt",
"Fitted jeans or chinos with a belt",
"Nice boots or leather shoes",
"Quality watch — Rolex-style or similar",
"Polished, confident, hands-on presence",
],
overlap: "In smaller companies, owners are often involved in a little bit of everything: sales, estimating, production, payroll, hiring and customer issues.",
},
{
id: "admin",
name: "Admin",
color: "var(--purple)",
description: "Keeps the office running — phones, calendars, customer updates, documents, onboarding paperwork, billing support and general organization.",
system: true,
perms: [...allPermissionIds],
attire: [
"Business casual blouse, sweater or cardigan",
"Clean, comfortable office attire",
"Simple jewelry and neat grooming",
"Organized desk presence",
"Approachable, calm and detail-oriented",
],
overlap: "In small teams, admins may also support HR, accounting, collections, dispatching, permit tracking and customer service.",
},
{
id: "sales-manager",
name: "Sales Manager",
color: "var(--blue)",
description: "Leads the sales team — manages pipeline, trains reps, reviews numbers, sets expectations, supports closes and holds the team accountable.",
system: false,
perms: ["leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage", "billing.view", "reports.view", "team.manage"],
attire: [
"Button-down shirt or polished business-casual top",
"Chinos or clean slacks",
"Leather shoes or clean boots",
"Folder, tablet or sales materials",
"More refined than a field rep, still jobsite-aware",
],
overlap: "Often supports recruiting, coaching, ride-alongs, lead routing, team meetings and performance reviews.",
},
{
id: "sales-rep",
name: "Sales Rep",
color: "var(--green)",
description: "Builds trust with homeowners, performs inspections, explains options, creates estimates, follows up and turns leads into signed projects.",
system: false,
perms: ["leads.manage", "pipeline.manage", "estimates.create"],
attire: [
"Branded polo or clean casual button-down",
"Well-fitted pants or jeans",
"Casual sneakers, boots or clean field shoes",
"Tablet, phone, folder or inspection tools",
"Polished, friendly and trustworthy look",
],
overlap: "In many smaller contractors, a sales rep may also act as project manager after the sale and stay responsible for the customer experience.",
},
{
id: "project-manager",
name: "Project Manager",
color: "var(--cyan)",
description: "Owns the job after approval — scope review, material coordination, scheduling, customer updates, trade coordination, documentation and closeout.",
system: false,
perms: ["leads.manage", "pipeline.manage", "dispatch.manage", "billing.view", "reports.view"],
attire: [
"Button-down, polo or clean field shirt",
"Jeans or durable pants",
"Work boots or field-ready shoes",
"Tablet, plans, folder or measuring tools",
"Professional but ready to walk a jobsite",
],
overlap: "Commonly overlaps with sales, estimating, scheduling, collections and quality control.",
},
{
id: "site-supervisor",
name: "Site Supervisor",
color: "var(--red)",
description: "Runs daily field execution — watches quality and safety, keeps crews moving, verifies details and helps projects stay on schedule.",
system: false,
perms: ["dispatch.manage", "reports.view"],
attire: [
"Company t-shirt or work shirt",
"Ball cap or jobsite hat",
"Durable jeans or work pants",
"Work boots and jobsite watch",
"Practical, experienced, hands-on look",
],
overlap: "May coordinate subs, verify deliveries, answer field questions, document progress and report back to the project manager or owner.",
},
{
id: "canvasser",
name: "Canvasser",
color: "var(--yellow)",
description: "Front-line lead generator — walks neighborhoods, knocks doors, qualifies homes, starts conversations and books inspections for sales reps.",
system: false,
perms: ["leads.manage"],
attire: [
"Young, clean, energetic appearance",
"Branded polo shirt",
"Lanyard ID badge",
"Comfortable pants or khakis",
"Clean sneakers, phone and shoulder bag",
],
overlap: "A strong canvasser may grow into a sales rep or team lead once they learn scripts, objections, territory discipline and appointment quality.",
},
{
id: "team-lead",
name: "Team Lead",
color: "#ec4899",
description: "Leads a small group of reps or canvassers — a sales team lead or a canvassing team lead depending on the company structure.",
system: false,
perms: ["leads.manage", "pipeline.manage", "reports.view"],
attire: [
"Branded polo or clean field-casual shirt",
"Well-fitted pants with belt",
"Boots or clean sneakers",
"Tablet, phone, clipboard or route list",
"Confident, coachable, team-first presence",
],
overlap: "May run morning huddles, assign territory, coach scripts, inspect appointment quality and help new people ramp faster.",
},
{
id: "subcontractor",
name: "Subcontractor",
color: "var(--orange-2)",
description: "Specialized trade partner performing the actual skilled work — roofing, framing, electrical, HVAC, masonry, gutters, painting, drywall or flooring.",
system: false,
perms: ["dispatch.manage", "billing.view"],
attire: [
"Trade-specific workwear",
"Hard hat, high-vis vest, gloves and PPE when needed",
"Durable pants and work boots",
"Tools, belt or equipment as required",
"Rugged, skilled and dependable presence",
],
overlap: "Subs may have their own crews, foremen, schedules, invoices, insurance documents and safety requirements.",
},
{
id: "other",
name: "Other",
color: "var(--muted)",
description: "Utility roles — estimator, dispatcher, bookkeeper, HR coordinator, accounting support, warehouse support, permit coordinator, marketing assistant or specialist.",
system: false,
perms: ["reports.view"],
attire: [
"Dress for the function of the day",
"Office casual for desk work",
"Polo, jeans, boots or safety gear for field visits",
"Clean, reliable, company-aligned appearance",
"Flexible enough to support multiple departments",
],
overlap: "In smaller companies, other roles often become utility players covering gaps across operations, accounting, HR, dispatch, estimating and customer service.",
},
];
/* ---------------------------------------------------------- */
/* MEMBERS */
/* ---------------------------------------------------------- */
export type MemberStatus = "active" | "away" | "offline";
export type Member = {
id: string;
name: string;
initials: string;
email: string;
title: string; // job title, free text
roleId: string;
gradient: string;
status: MemberStatus;
lastActive: string;
deals: number; // open deals owned
joined: string;
};
export const members: Member[] = [
{ id: "u1", name: "James Carter", initials: "JC", email: "james.carter@lynkeduppro.com", title: "Founder & CEO", roleId: "owner", gradient: "linear-gradient(135deg,#fda913,#fd6d13)", status: "active", lastActive: "Active now", deals: 12, joined: "Mar 2021" },
{ id: "u2", name: "Emma Wilson", initials: "EW", email: "emma.wilson@lynkeduppro.com", title: "Head of Sales", roleId: "sales-manager", gradient: "linear-gradient(135deg,#285ef0,#09b9c6)", status: "active", lastActive: "Active now", deals: 21, joined: "Aug 2021" },
{ id: "u3", name: "Liam Foster", initials: "LF", email: "liam.foster@lynkeduppro.com", title: "Senior Sales Rep", roleId: "sales-rep", gradient: "linear-gradient(135deg,#9036e9,#285ef0)", status: "active", lastActive: "5 min ago", deals: 17, joined: "Jan 2022" },
{ id: "u4", name: "Sophie Turner", initials: "ST", email: "sophie.turner@lynkeduppro.com", title: "Account Executive", roleId: "sales-rep", gradient: "linear-gradient(135deg,#14bc83,#09b9c6)", status: "away", lastActive: "32 min ago", deals: 9, joined: "May 2022" },
{ id: "u5", name: "Noah Mitchell", initials: "NM", email: "noah.mitchell@lynkeduppro.com", title: "Site Supervisor", roleId: "site-supervisor", gradient: "linear-gradient(135deg,#f0563f,#fda913)", status: "active", lastActive: "Active now", deals: 0, joined: "Sep 2022" },
{ id: "u6", name: "Olivia Bennett", initials: "OB", email: "olivia.bennett@lynkeduppro.com", title: "Office Admin", roleId: "admin", gradient: "linear-gradient(135deg,#09b9c6,#285ef0)", status: "away", lastActive: "1 hour ago", deals: 3, joined: "Nov 2021" },
{ id: "u7", name: "Ethan Brooks", initials: "EB", email: "ethan.brooks@lynkeduppro.com", title: "Project Manager", roleId: "project-manager", gradient: "linear-gradient(135deg,#ffd60a,#fda913)", status: "offline", lastActive: "Yesterday", deals: 6, joined: "Feb 2023" },
{ id: "u8", name: "Ava Reynolds", initials: "AR", email: "ava.reynolds@lynkeduppro.com", title: "Bookkeeper", roleId: "other", gradient: "linear-gradient(135deg,#9036e9,#f0563f)", status: "offline", lastActive: "2 days ago", deals: 0, joined: "Apr 2023" },
{ id: "u9", name: "Mason Reed", initials: "MR", email: "mason.reed@lynkeduppro.com", title: "Field Canvasser", roleId: "canvasser", gradient: "linear-gradient(135deg,#ffd60a,#14bc83)", status: "active", lastActive: "Active now", deals: 4, joined: "Jun 2023" },
{ id: "u10", name: "Chloe Adams", initials: "CA", email: "chloe.adams@lynkeduppro.com", title: "Sales Team Lead", roleId: "team-lead", gradient: "linear-gradient(135deg,#ec4899,#9036e9)", status: "away", lastActive: "18 min ago", deals: 11, joined: "Oct 2022" },
{ id: "u11", name: "Diego Ramirez", initials: "DR", email: "diego.ramirez@lynkeduppro.com", title: "Roofing Subcontractor", roleId: "subcontractor", gradient: "linear-gradient(135deg,#fd6d13,#f0563f)", status: "offline", lastActive: "3 days ago", deals: 0, joined: "Jan 2023" },
];
/* ---------------------------------------------------------- */
/* PENDING INVITES */
/* ---------------------------------------------------------- */
export type Invite = { id: string; email: string; roleId: string; invitedBy: string; sentAt: string };
export const pendingInvites: Invite[] = [
{ id: "inv1", email: "daniel.hughes@gmail.com", roleId: "sales-rep", invitedBy: "Emma Wilson", sentAt: "2 days ago" },
{ id: "inv2", email: "grace.murphy@outlook.com", roleId: "canvasser", invitedBy: "James Carter", sentAt: "5 days ago" },
];
@@ -0,0 +1,525 @@
"use client";
// ============================================================
// Team Management — a CRM people + roles module, redesigned as
// a creative "crew" experience.
// · Crew hero : avatar stack, live presence, headline stats
// · Members : people gallery (cards) ⇄ compact list, with
// role filter chips, search, MULTI-role chips,
// deal-load meters and row actions
// · Roles : permission-coverage rings + live toggle matrix
// · Invites : envelope timeline with resend / revoke
// Data comes from useTeamData(): the local mock when the Shell
// isn't configured, or the live be-crm data door (crm.team.*)
// when it is. A member can hold MANY roles.
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast,
} from "./ui";
import { permissionGroups, allPermissionIds } from "./team-data";
import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api";
const STATUS_LABEL: Record<UiStatus, string> = { active: "Active", away: "Away", offline: "Offline" };
const toDot = (s: UiStatus) => (s === "offline" ? "offline" : s === "away" ? "away" : "online");
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
export function TeamManagement() {
const toast = useToast();
const team = useTeamData();
const { members, roles, invites, live } = team;
const [tab, setTab] = useState("members");
const [view, setView] = useState<"grid" | "list">("grid");
const [query, setQuery] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [inviteOpen, setInviteOpen] = useState(false);
const [editing, setEditing] = useState<UiMember | null>(null);
const [confirmRemove, setConfirmRemove] = useState<UiMember | null>(null);
const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]);
const assignableRoles = useMemo(() => roles.filter((r) => !r.isOwner), [roles]);
const countByRole = useMemo(() => {
const m: Record<string, number> = {};
for (const mem of members) for (const rid of mem.roleIds) m[rid] = (m[rid] ?? 0) + 1;
return m;
}, [members]);
const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return members.filter((m) => {
const matchRole = roleFilter === "all" || m.roleIds.includes(roleFilter);
const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q);
return matchRole && matchQ;
});
}, [members, query, roleFilter]);
const activeCount = members.filter((m) => m.status === "active").length;
/* ---- mutations (async; live → data door, mock → local) ---- */
async function saveRoles(m: UiMember, roleIds: string[]) {
try {
await team.setMemberRoles(m.id, roleIds);
toast.push({ tone: "success", title: "Roles updated", desc: `${m.name} now holds ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update roles", desc: (e as Error).message }); }
}
async function removeMember(m: UiMember) {
setConfirmRemove(null);
try { await team.removeMember(m.id); toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't remove member", desc: (e as Error).message }); }
}
async function togglePerm(roleId: string, permId: string, granted: boolean) {
try { await team.setPermission(roleId, permId, granted); }
catch (e) { toast.push({ tone: "error", title: "Couldn't update permission", desc: (e as Error).message }); }
}
// Crew stack — show online folks first, capped to 6 with a +N bubble.
const stack = [...members].sort((a, b) => Number(b.status === "active") - Number(a.status === "active"));
const stackShown = stack.slice(0, 6);
const stackRest = members.length - stackShown.length;
return (
<div className="view tm">
{/* ---- Crew hero ---------------------------------------- */}
<header className="tm-hero">
<div className="tm-hero-orbs" aria-hidden />
<div className="tm-hero-grid" aria-hidden />
<div className="tm-hero-main">
<div className="tm-stack" aria-hidden>
{stackShown.map((m, i) => (
<span key={m.id} className="tm-stack-av" style={{ zIndex: stackShown.length - i }}>
<Avatar initials={m.initials} gradient={m.gradient} size={46} status={toDot(m.status)} />
</span>
))}
{stackRest > 0 && <span className="tm-stack-more">+{stackRest}</span>}
</div>
<div className="tm-hero-copy">
<div className="tm-hero-eyebrow">
<Icon name="team" size={13} /> Your crew · LynkedUp Pro
{live && <Pill tone="green" style={{ marginLeft: 8 }}>Live</Pill>}
</div>
<h1>Team Management</h1>
<p>Manage your crew, assign roles and control exactly what each person can do.</p>
<div className="tm-hero-live">
<span className="tm-pulse" /><b>{activeCount}</b> online now
<span className="tm-hero-dot" /> {members.length} members
<span className="tm-hero-dot" /> {invites.length} invites pending
</div>
</div>
</div>
<div className="tm-hero-actions">
<Btn icon="plus" onClick={() => setInviteOpen(true)}>Invite member</Btn>
</div>
</header>
{/* ---- Tabs --------------------------------------------- */}
<div className="tm-tabs" role="tablist">
{[
{ value: "members", label: "Members", icon: "people", badge: members.length },
{ value: "roles", label: "Roles & Permissions", icon: "shield", badge: roles.length },
{ value: "invites", label: "Invites", icon: "mail", badge: invites.length },
].map((t) => (
<button key={t.value} role="tab" aria-selected={tab === t.value} className={`tm-tab ${tab === t.value ? "active" : ""}`} onClick={() => setTab(t.value)}>
<Icon name={t.icon} size={16} /> <span>{t.label}</span>
<span className="tm-tab-badge">{t.badge}</span>
</button>
))}
</div>
{team.error && <div className="card tm-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn&apos;t load team data: {team.error}</p></div>}
{/* ---- MEMBERS ------------------------------------------ */}
{tab === "members" && (
<div className="view-body">
<div className="tm-toolbar">
<div className="tm-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search by name, email or title…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="tm-viewtoggle" role="group" aria-label="Layout">
<button className={view === "grid" ? "on" : ""} aria-label="Gallery view" onClick={() => setView("grid")}><Icon name="subtasks" size={15} /></button>
<button className={view === "list" ? "on" : ""} aria-label="List view" onClick={() => setView("list")}><Icon name="leaderboard" size={15} /></button>
</div>
</div>
{/* Role filter chips */}
<div className="tm-chips">
<button className={`tm-chip ${roleFilter === "all" ? "on" : ""}`} onClick={() => setRoleFilter("all")}>
<span className="tm-chip-dot" style={{ background: "var(--text-2)" }} /> All <i>{members.length}</i>
</button>
{roles.filter((r) => (countByRole[r.id] ?? 0) > 0).map((r) => (
<button key={r.id} className={`tm-chip ${roleFilter === r.id ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => setRoleFilter(r.id)}>
<span className="tm-chip-dot" style={{ background: r.color }} /> {r.name} <i>{countByRole[r.id] ?? 0}</i>
</button>
))}
</div>
{team.live && team.loading && members.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="people" size={24} /></span><p>Loading your crew</p></div>
) : filtered.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="search" size={24} /></span><p>No members match your search.</p><Btn variant="soft" size="sm" onClick={() => { setQuery(""); setRoleFilter("all"); }}>Clear filters</Btn></div>
) : view === "grid" ? (
<div className="tm-gallery">
{filtered.map((m) => (
<article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: roleById[m.roleIds[0]]?.color }}>
<span className="tm-pcard-bg" aria-hidden />
<div className="tm-pcard-top">
<span className="tm-pcard-av"><Avatar initials={m.initials} gradient={m.gradient} size={58} status={toDot(m.status)} /></span>
<RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} />
</div>
<div className="tm-pcard-name">{m.name}{m.isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
<div className="tm-pcard-title">{m.title || <span className="tm-dash">No title</span>}</div>
<div className="tm-pcard-email"><Icon name="mail" size={12} /> {m.email}</div>
<div className="tm-pcard-role">
<RoleChips roleIds={m.roleIds} roleById={roleById} />
</div>
<div className="tm-meter" title={`${m.deals} open deals`}>
<div className="tm-meter-h"><span>Deal load</span><b>{m.deals}</b></div>
<span className="tm-meter-track"><span className="tm-meter-fill" style={{ width: `${Math.round((m.deals / maxDeals) * 100)}%` }} /></span>
</div>
<div className="tm-pcard-foot">
<span className="tm-statuscell"><StatusDot status={toDot(m.status)} /> {STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
</article>
))}
</div>
) : (
<div className="card card-pad-0 tm-table">
<div className="tm-row tm-head">
<span>Member</span><span>Roles</span><span>Status</span><span className="tm-c-num">Open deals</span><span className="tm-c-act" />
</div>
{filtered.map((m) => (
<div className="tm-row" key={m.id}>
<div className="tm-member">
<Avatar initials={m.initials} gradient={m.gradient} size={40} status={toDot(m.status)} />
<div className="tm-member-meta">
<div className="tm-name">{m.name}{m.isYou && <Pill tone="orange" style={{ marginLeft: 8 }}>You{m.isOwner ? " · Owner" : ""}</Pill>}</div>
<div className="tm-sub">{m.title ? `${m.title} · ` : ""}{m.email}</div>
</div>
</div>
<div className="tm-rolecell"><RoleChips roleIds={m.roleIds} roleById={roleById} /></div>
<div className="tm-statuscell">
<StatusDot status={toDot(m.status)} /><span>{STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
<div className="tm-c-num">{m.deals > 0 ? <b>{m.deals}</b> : <span className="tm-dash"></span>}</div>
<div className="tm-c-act"><RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
</div>
))}
</div>
)}
</div>
)}
{/* ---- ROLES -------------------------------------------- */}
{tab === "roles" && (
<div className="view-body tm-roles">
{roles.map((r) => {
const pct = Math.round((r.perms.length / allPermissionIds.length) * 100);
return (
<div className="card tm-rolecard" key={r.id} style={{ ["--rc" as string]: r.color }}>
<div className="tm-rolecard-head">
<span className="tm-ring" style={{ ["--pct" as string]: pct }}>
<span className="tm-ring-in"><Icon name={r.isOwner ? "star" : r.system ? "shield-check" : "shield"} size={17} /></span>
</span>
<div className="tm-rolecard-title">
<div className="tm-rolecard-name">{r.name}{r.system && <Pill tone="muted" style={{ marginLeft: 8 }}>System</Pill>}</div>
<div className="tm-rolecard-sub">{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions</div>
</div>
</div>
{r.description && <p className="tm-rolecard-desc">{r.description}</p>}
{r.attire.length > 0 && (
<div className="tm-attire">
<div className="tm-attire-h"><Icon name="user" size={13} /> Attire &amp; appearance</div>
<ul className="tm-attire-list">
{r.attire.map((a) => (<li key={a}><span className="tm-attire-dot" />{a}</li>))}
</ul>
</div>
)}
{r.overlap && (
<div className="tm-overlap">
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
<div><b>Common overlap</b><p>{r.overlap}</p></div>
</div>
)}
<div className="tm-permlist">
{permissionGroups.map((g) => (
<div className="tm-permgroup" key={g.id}>
<div className="tm-permgroup-h"><Icon name={g.icon} size={13} /> {g.title}</div>
{g.perms.map((p) => {
const granted = r.perms.includes(p.id);
const locked = r.isOwner;
return (
<div className={`tm-perm ${granted ? "on" : ""}`} key={p.id}>
<div className="tm-perm-meta">
<span className="tm-perm-label">{p.label}</span>
<span className="tm-perm-desc">{p.desc}</span>
</div>
<Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id, !granted)} label={p.label} />
</div>
);
})}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* ---- INVITES ------------------------------------------ */}
{tab === "invites" && (
<div className="view-body card card-pad-0">
<div className="tm-invite-head">
<div>
<h3>Pending invitations</h3>
<p>People you&apos;ve invited who haven&apos;t joined yet.</p>
</div>
<Btn icon="plus" size="sm" onClick={() => setInviteOpen(true)}>New invite</Btn>
</div>
{invites.length === 0 ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="mail" size={24} /></span><p>No pending invites. Everyone&apos;s on board.</p></div>
) : (
<div className="tm-invites">
{invites.map((inv) => (
<div className="tm-inviterow" key={inv.id}>
<span className="tm-invite-ic"><Icon name="mail" size={16} /></span>
<div className="tm-invite-meta">
<div className="tm-name">{inv.email}</div>
<div className="tm-sub">Invited by {inv.invitedBy} · {inv.sentAt}</div>
</div>
<RoleChips roleIds={inv.roleIds} roleById={roleById} />
<div className="tm-invite-actions">
{inv.token && (
<Btn variant="soft" size="sm" icon="copy" onClick={async () => {
const link = `${window.location.origin}/portal/invite?token=${inv.token}`;
try { await navigator.clipboard.writeText(link); toast.push({ tone: "success", title: "Invite link copied", desc: `Send it to ${inv.email} to join.` }); }
catch { toast.push({ tone: "info", title: "Invite link", desc: link }); }
}}>Copy link</Btn>
)}
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${inv.email}.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
}}>Resend</Btn>
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't revoke", desc: (e as Error).message }); }
}}>Revoke</Btn>
</div>
</div>
))}
</div>
)}
</div>
)}
<InviteModal
open={inviteOpen}
roles={assignableRoles}
onClose={() => setInviteOpen(false)}
onInvite={async (email, roleIds) => {
setInviteOpen(false);
try {
await team.invite(email, roleIds);
setTab("invites");
toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
}}
/>
<EditMemberModal
member={editing}
roles={assignableRoles}
onClose={() => setEditing(null)}
onSave={async (id, title, roleIds) => {
const m = editing;
setEditing(null);
if (!m) return;
try {
await team.updateMember(id, { title, roleIds });
toast.push({ tone: "success", title: "Member updated" });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update member", desc: (e as Error).message }); }
}}
/>
<Modal
open={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
title="Remove member"
subtitle={confirmRemove ? `${confirmRemove.name} will lose access immediately.` : ""}
icon="alert"
size="sm"
footer={
<>
<Btn variant="ghost" onClick={() => setConfirmRemove(null)}>Cancel</Btn>
<Btn variant="danger" icon="trash" onClick={() => confirmRemove && removeMember(confirmRemove)}>Remove member</Btn>
</>
}
>
<p className="tm-confirm-text">Their open deals and assignments stay in the workspace and can be reassigned afterwards. This action can be undone by re-inviting them.</p>
</Modal>
</div>
);
}
/* ---------------------------------------------------------- */
/* Role chips — show every role a member/invite holds */
/* ---------------------------------------------------------- */
function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record<string, UiRole> }) {
if (roleIds.length === 0) return <span className="tm-dash">No role</span>;
return (
<span className="tm-rolechips">
{roleIds.map((rid) => {
const role = roleById[rid];
return (
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }} key={rid}>
<Icon name={role?.isOwner ? "star" : "shield"} size={12} /> {role?.name ?? rid}
</span>
);
})}
</span>
);
}
/* ---------------------------------------------------------- */
/* Row actions menu */
/* ---------------------------------------------------------- */
function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove: () => void; disabled?: boolean }) {
const [open, setOpen] = useState(false);
if (disabled) return <span className="tm-menu-lock" title="The owner can't be edited"><Icon name="lock" size={15} /></span>;
return (
<div className="tm-menu">
<button className="ds-iconbtn" aria-label="Member actions" onClick={() => setOpen((o) => !o)}><Icon name="dots" size={18} /></button>
{open && (
<>
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="edit" size={15} /> Edit member</button>
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change roles</button>
<div className="tm-menu-sep" />
<button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button>
</div>
</>
)}
</div>
);
}
/* ---------------------------------------------------------- */
/* Multi-role picker — checkbox chips, ≥1 required */
/* ---------------------------------------------------------- */
function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) {
return (
<div className="tm-rolepicker">
{roles.map((r) => {
const on = selected.includes(r.id);
return (
<button type="button" key={r.id} className={`tm-rolepick ${on ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => onToggle(r.id)} aria-pressed={on}>
<span className="tm-rolepick-check">{on && <Icon name="check" size={12} />}</span>
<span className="tm-roledot" /> {r.name}
</button>
);
})}
</div>
);
}
/* ---------------------------------------------------------- */
/* Invite modal */
/* ---------------------------------------------------------- */
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) {
const [email, setEmail] = useState("");
const [roleIds, setRoleIds] = useState<string[]>([]);
const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0;
function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
function submit() { if (!valid) return; onInvite(email.trim(), roleIds); setEmail(""); setRoleIds([]); }
return (
<Modal
open={open}
onClose={onClose}
title="Invite a team member"
subtitle="They'll get an email with a secure link to set up their account."
icon="people"
footer={
<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" disabled={!valid} onClick={submit}>Send invitation</Btn>
</>
}
>
<div className="tm-form">
<Field label="Work email" required hint={email && !EMAIL_RE.test(email.trim()) ? undefined : "We'll send the invite here."} error={email && !EMAIL_RE.test(email.trim()) ? "Enter a valid email address." : undefined}>
<input className="ds-input" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field label="Assign roles" required hint="Pick one or more. Roles decide what this person can see and do.">
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
</Field>
</div>
</Modal>
);
}
/* ---------------------------------------------------------- */
/* Edit member modal — job title + multi-role */
/* ---------------------------------------------------------- */
function EditMemberModal({ member, roles, onClose, onSave }: { member: UiMember | null; roles: UiRole[]; onClose: () => void; onSave: (id: string, title: string, roleIds: string[]) => void }) {
const [title, setTitle] = useState("");
const [roleIds, setRoleIds] = useState<string[]>([]);
const key = member?.id ?? "";
useEffect(() => { if (member) { setTitle(member.title); setRoleIds(member.roleIds); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
if (!member) return null;
const valid = roleIds.length > 0;
function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
return (
<Modal
open={!!member}
onClose={onClose}
title={`Edit ${member.name}`}
subtitle={member.email}
icon="edit"
footer={
<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="check" disabled={!valid} onClick={() => onSave(member.id, title, roleIds)}>Save changes</Btn>
</>
}
>
<div className="tm-form">
<div className="tm-edit-hero">
<Avatar initials={member.initials} gradient={member.gradient} size={52} />
<div>
<div className="tm-name">{member.name}</div>
<div className="tm-sub">Joined {member.joined} · {member.deals} open deals</div>
</div>
</div>
<Field label="Job title">
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" />
</Field>
<Field label="Roles" required hint="A member can hold several roles — permissions are the union of all of them.">
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
</Field>
</div>
</Modal>
);
}
+50 -16
View File
@@ -1,32 +1,66 @@
"use client"; "use client";
import { Sun, ChevronDown } from "lucide-react"; import { useState } from "react";
import { FigIcon } from "./figicon"; import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui";
import { user } from "./account-data";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
// When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user.
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore */ }
router.replace("/portal/login");
}
export function Topbar({ theme, onToggle }: { theme: "dark" | "light"; onToggle: () => void }) {
return ( return (
<header className="dash-topbar"> <header className="dash-topbar">
<div className="dash-title"> <div className="dash-title">
<h1>Dashboard Overview</h1> <h1>{title}</h1>
<p>Welcome back, here&apos;s what&apos;s happening with your territory</p> <p>{subtitle}</p>
</div> </div>
<div className="top-actions"> <div className="top-actions">
<button className="ic-btn" aria-label="Search"><FigIcon name="i_search" size={18} /></button> <button className="ic-btn" aria-label="Search"><Icon name="search" size={18} /></button>
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}> <button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
{theme === "dark" ? <FigIcon name="i_theme" size={18} /> : <Sun size={18} />} {theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button> </button>
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}> <button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
<FigIcon name="i_bell" size={18} /> <Icon name="bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} /> <span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button> </button>
<button className="top-user"> <div className="top-user-wrap" style={{ position: "relative" }}>
<span className="av" style={{ background: "linear-gradient(135deg,#fda913,#fd6d13)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span> <button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<div style={{ textAlign: "left" }}> <span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div className="nm">Justin Johnson</div> <div style={{ textAlign: "left", minWidth: 0 }}>
<div className="rl">Owner</div> <div className="nm">{name}</div>
</div> <div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
<ChevronDown size={16} /> </div>
</button> <ChevronDown size={16} />
</button>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
</div>
</div> </div>
</header> </header>
); );
+309
View File
@@ -0,0 +1,309 @@
"use client";
// ============================================================
// LynkedUp Pro — shared design-system primitives.
// Tokens live in dashboard.css (.dash-root). These components
// only consume CSS variables so they theme automatically.
//
// Exports: Icon, Avatar, PageHead, Pill, Toggle, OtpField,
// Modal, ToastProvider/useToast, Field, SegTabs,
// StatusDot, Segmented.
// ============================================================
import {
createContext, useCallback, useContext, useEffect, useId,
useRef, useState, type ReactNode,
} from "react";
import {
MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck,
Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send,
Paperclip, Clock, MapPin, Monitor, Smartphone, Tablet, LogOut, ArrowRight,
Info, Check, X, Search, ChevronDown, ChevronRight, Trash2, Globe,
CheckCircle2, KeyRound, Pencil, Copy, RefreshCw, AlertTriangle,
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, type LucideIcon,
} from "lucide-react";
/* ---------------------------------------------------------- */
/* Icon — single named entry point used across the module */
/* ---------------------------------------------------------- */
const ICONS: Record<string, LucideIcon> = {
chat: MessageCircle, ticket: Ticket, phone: Phone, mail: Mail, book: BookOpen,
rocket: Rocket, shield: Shield, "shield-check": ShieldCheck, lock: Lock,
card: CreditCard, user: User, bell: Bell, eye: Eye, "eye-off": EyeOff,
camera: Camera, upload: Upload, plus: Plus, star: Star, send: Send,
paperclip: Paperclip, clock: Clock, pin: MapPin, monitor: Monitor,
mobile: Smartphone, tablet: Tablet, logout: LogOut, arrow: ArrowRight,
info: Info, check: Check, x: X, search: Search, chevron: ChevronDown,
"chevron-right": ChevronRight, trash: Trash2, globe: Globe,
"check-circle": CheckCircle2, key: KeyRound, edit: Pencil, copy: Copy,
refresh: RefreshCw, alert: AlertTriangle, privacy: ShieldCheck, devices: Monitor,
desktop: Monitor,
// sidebar / workspace nav
dashboard: LayoutDashboard, owners: Building2, projects: FolderKanban,
leads: UserPlus, verify: BadgeCheck, pipeline: Filter, dispatch: Truck,
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
team: UsersRound, dots: MoreHorizontal,
};
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
const C = ICONS[name] ?? Info;
return <C size={size} className={className} strokeWidth={strokeWidth} />;
}
/* ---------------------------------------------------------- */
/* Avatar */
/* ---------------------------------------------------------- */
export function Avatar({
initials, gradient = "linear-gradient(135deg,#fda913,#fd6d13)", size = 40, status, square = false,
}: { initials: string; gradient?: string; size?: number; status?: "online" | "away" | "offline" | "busy"; square?: boolean }) {
return (
<span className="ds-avatar" style={{ width: size, height: size, borderRadius: square ? size * 0.28 : "50%", fontSize: size * 0.36 }}>
<span className="ds-avatar-bg" style={{ background: gradient, borderRadius: "inherit" }}>{initials}</span>
{status && <span className={`ds-avatar-dot status-${status}`} style={{ width: size * 0.28, height: size * 0.28 }} />}
</span>
);
}
/* ---------------------------------------------------------- */
/* PageHead — section title block used at the top of each view */
/* ---------------------------------------------------------- */
export function PageHead({ eyebrow, title, subtitle, icon, actions }: { eyebrow?: string; title: string; subtitle?: string; icon?: string; actions?: ReactNode }) {
return (
<div className="ds-pagehead">
<div className="ds-pagehead-l">
{icon && <span className="ds-pagehead-ic"><Icon name={icon} size={22} /></span>}
<div>
{eyebrow && <div className="ds-eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
{actions && <div className="ds-pagehead-actions">{actions}</div>}
</div>
);
}
/* ---------------------------------------------------------- */
/* Pill / StatusDot */
/* ---------------------------------------------------------- */
export function Pill({ children, tone = "muted", style }: { children: ReactNode; tone?: string; style?: React.CSSProperties }) {
return <span className={`ds-pill tone-${tone}`} style={style}>{children}</span>;
}
export function StatusDot({ status }: { status: "online" | "away" | "offline" | "busy" }) {
return <span className={`ds-statusdot status-${status}`} />;
}
/* ---------------------------------------------------------- */
/* Toggle */
/* ---------------------------------------------------------- */
export function Toggle({ checked, onChange, disabled, label }: { checked: boolean; onChange?: (v: boolean) => void; disabled?: boolean; label?: string }) {
return (
<button
type="button" role="switch" aria-checked={checked} aria-label={label}
className={`ds-toggle ${checked ? "on" : ""} ${disabled ? "disabled" : ""}`}
onClick={() => !disabled && onChange?.(!checked)}
disabled={disabled}
>
<span className="knob" />
</button>
);
}
/* ---------------------------------------------------------- */
/* Field — labelled input wrapper */
/* ---------------------------------------------------------- */
export function Field({ label, hint, error, children, required }: { label: string; hint?: string; error?: string; children: ReactNode; required?: boolean }) {
return (
<label className="ds-field">
<span className="ds-field-lbl">{label}{required && <i className="req">*</i>}</span>
{children}
{error ? <span className="ds-field-err"><Icon name="alert" size={12} /> {error}</span> : hint ? <span className="ds-field-hint">{hint}</span> : null}
</label>
);
}
/* ---------------------------------------------------------- */
/* Segmented / SegTabs */
/* ---------------------------------------------------------- */
export function Segmented({ options, value, onChange }: { options: { value: string; label: string; icon?: string }[]; value: string; onChange: (v: string) => void }) {
return (
<div className="ds-segmented" role="tablist">
{options.map((o) => (
<button key={o.value} role="tab" aria-selected={value === o.value} className={`seg ${value === o.value ? "active" : ""}`} onClick={() => onChange(o.value)}>
{o.icon && <Icon name={o.icon} size={15} />} {o.label}
</button>
))}
</div>
);
}
export function SegTabs({ tabs, value, onChange }: { tabs: { value: string; label: string; icon?: string; badge?: number }[]; value: string; onChange: (v: string) => void }) {
return (
<div className="ds-tabs" role="tablist">
{tabs.map((t) => (
<button key={t.value} role="tab" aria-selected={value === t.value} className={`ds-tab ${value === t.value ? "active" : ""}`} onClick={() => onChange(t.value)}>
{t.icon && <Icon name={t.icon} size={16} />}
<span>{t.label}</span>
{t.badge != null && t.badge > 0 && <span className="ds-tab-badge">{t.badge}</span>}
</button>
))}
</div>
);
}
/* ---------------------------------------------------------- */
/* OtpField — N-digit one-time-code input */
/* ---------------------------------------------------------- */
export function OtpField({ length = 6, value, onChange, autoFocus = true }: { length?: number; value: string; onChange: (v: string) => void; autoFocus?: boolean }) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => { if (autoFocus) refs.current[0]?.focus(); }, [autoFocus]);
function setAt(i: number, char: string) {
const next = value.split("");
next[i] = char;
const joined = next.join("").slice(0, length);
onChange(joined);
}
return (
<div className="ds-otp">
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => { refs.current[i] = el; }}
inputMode="numeric"
maxLength={1}
className="ds-otp-box"
value={value[i] ?? ""}
onChange={(e) => {
const d = e.target.value.replace(/\D/g, "");
if (!d) { setAt(i, ""); return; }
// support paste of full code
if (d.length > 1) {
onChange((value.slice(0, i) + d).slice(0, length));
refs.current[Math.min(i + d.length, length - 1)]?.focus();
return;
}
setAt(i, d);
refs.current[Math.min(i + 1, length - 1)]?.focus();
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !value[i] && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
}}
/>
))}
</div>
);
}
/* ---------------------------------------------------------- */
/* Modal */
/* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
}) {
const titleId = useId();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="ds-modal-overlay" onMouseDown={onClose}>
<div className={`ds-modal size-${size}`} role="dialog" aria-modal="true" aria-labelledby={titleId} onMouseDown={(e) => e.stopPropagation()}>
<div className="ds-modal-head">
<div className="ds-modal-head-l">
{icon && <span className="ds-modal-ic"><Icon name={icon} size={18} /></span>}
<div>
<h3 id={titleId}>{title}</h3>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
<div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>}
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* Toast */
/* ---------------------------------------------------------- */
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string };
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
const ToastContext = createContext<ToastCtx | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) return { push: () => {} };
return ctx;
}
let toastSeq = 1;
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Toast[]>([]);
const push = useCallback((t: Omit<Toast, "id">) => {
const id = toastSeq++;
setItems((s) => [...s, { ...t, id }]);
setTimeout(() => setItems((s) => s.filter((x) => x.id !== id)), 3800);
}, []);
return (
<ToastContext.Provider value={{ push }}>
{children}
<div className="ds-toasts">
{items.map((t) => (
<div key={t.id} className={`ds-toast tone-${t.tone}`}>
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
<div className="ds-toast-body">
<div className="ds-toast-title">{t.title}</div>
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
</div>
<button className="ds-toast-x" aria-label="Dismiss" onClick={() => setItems((s) => s.filter((x) => x.id !== t.id))}><Icon name="x" size={14} /></button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ---------------------------------------------------------- */
/* Button — light helper so call sites stay terse */
/* ---------------------------------------------------------- */
export function Btn({ children, variant = "primary", icon, iconRight, onClick, disabled, type = "button", full, size = "md" }: {
children: ReactNode; variant?: "primary" | "ghost" | "soft" | "danger" | "outline";
icon?: string; iconRight?: string; onClick?: () => void; disabled?: boolean;
type?: "button" | "submit"; full?: boolean; size?: "sm" | "md";
}) {
return (
<button type={type} className={`ds-btn v-${variant} s-${size} ${full ? "full" : ""}`} onClick={onClick} disabled={disabled}>
{icon && <Icon name={icon} size={size === "sm" ? 14 : 16} />}
{children}
{iconRight && <Icon name={iconRight} size={size === "sm" ? 14 : 16} />}
</button>
);
}
+2 -7
View File
@@ -145,18 +145,13 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
if (url) { window.location.href = url; return true; } if (url) { window.location.href = url; return true; }
return false; return false;
} }
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) { export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
// Only Google is offered (Microsoft/Apple intentionally hidden).
return ( return (
<div className="col gap-3"> <div className="col gap-3">
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}> <button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
<GoogleMark /> {verb} with Google <GoogleMark /> {verb} with Google
</button> </button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
<MicrosoftMark /> {verb} with Microsoft / Outlook
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
<AppleMark /> {verb} with Apple
</button>
</div> </div>
); );
} }
+10 -9
View File
@@ -27,10 +27,10 @@ export const MASKED_WA = "+91 ••••• ••012";
/* ---- demo account "on file" ---- */ /* ---- demo account "on file" ---- */
export const DEMO_USER = { export const DEMO_USER = {
name: "Rajesh Kumar Sharma", name: "James Carter",
firstName: "Rajesh", firstName: "James",
lastName: "Sharma", lastName: "Carter",
initials: "RS", initials: "JC",
allotmentNo: "YEA-654321", allotmentNo: "YEA-654321",
sector: "Sector 18", sector: "Sector 18",
pocket: "Pocket B", pocket: "Pocket B",
@@ -38,7 +38,7 @@ export const DEMO_USER = {
category: "Residential", category: "Residential",
size: "300 sq.m", size: "300 sq.m",
maskedMobile: MASKED_PHONE, maskedMobile: MASKED_PHONE,
email: "rajesh.sharma@example.com", email: "james.carter@example.com",
relationship: "Self", relationship: "Self",
isAllottee: true, isAllottee: true,
}; };
@@ -75,11 +75,12 @@ export const countryCodes: CountryCode[] = [
]; ];
export const relationshipOptions = [ export const relationshipOptions = [
"Homeowner", "Customer",
"Employee",
"Owner",
"Contractor", "Contractor",
"Property Manager", "Sub-Con",
"Tenant", "Vendor",
"Authorized Representative",
]; ];
export const sectors = [ export const sectors = [
+214 -43
View File
@@ -8,7 +8,13 @@ import {
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial, RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
PasswordStrength, PasswordStrength,
} from "./bits"; } from "./bits";
import { lookupAccount, type Account } from "./data"; 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" };
type Step = type Step =
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password" | "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
@@ -19,12 +25,45 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() { export function LoginFlow() {
const router = useRouter(); const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [step, setStep] = useState<Step>("identify"); const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null); const [account, setAccount] = useState<Account | null>(null);
const [provider, setProvider] = useState<string>(""); const [provider, setProvider] = useState<string>("");
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>(""); const [flash, setFlash] = useState<string>("");
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email");
const [invited, setInvited] = useState(false);
// Arriving from a team invite for an already-registered email → prefill it.
useEffect(() => {
try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvited(true); } } catch { /* ignore */ }
}, []);
// After a successful sign-in, redeem a pending team invite (if any), then go to the dashboard.
async function acceptPendingInviteThenDashboard() {
try {
const t = sessionStorage.getItem("invite_token");
if (t) {
await sdk.command("crm.team.invitation.accept", { token: t });
sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
}
} catch { /* invite expired/used — proceed to the dashboard anyway */ }
router.replace("/dashboard");
}
// Minimal account stand-in for passwordless entry points (Shell mode).
function blankAccount(): Account {
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
}
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
push("otp"); // push (not replace) so Back returns to the identify screen
}
/* ---- history hash sync ---- */ /* ---- history hash sync ---- */
function push(s: Step) { function push(s: Step) {
@@ -47,6 +86,37 @@ export function LoginFlow() {
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
}, []); }, []);
/* ---- OAuth return: finish the redirect started by loginWithOAuth ---- */
useEffect(() => {
if (!isShellConfigured()) return;
const code = new URLSearchParams(window.location.search).get("code");
if (!code) return;
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
if (!ready) { setStep("connecting"); return; }
setStep("connecting");
completeOAuthLogin(code)
.then(async (ace) => {
if (!ace) { replace("identify"); return; }
// First-time Google user (no CRM profile yet) → complete onboarding; an
// existing profile → straight to the dashboard.
let registered = false;
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
if (registered) { await acceptPendingInviteThenDashboard(); 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, getUserEmail]);
/* ---- auth resolution ---- */ /* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
if (factor === "password") { setFlash(""); push("otp"); return; } if (factor === "password") { setFlash(""); push("otp"); return; }
@@ -56,6 +126,14 @@ export function LoginFlow() {
} }
function onSocial(p: "google" | "microsoft" | "apple") { function onSocial(p: "google" | "microsoft" | "apple") {
if (isShellConfigured()) {
// Real OAuth: appshell redirects to the provider; we finish on return (effect above).
setProvider(p);
push("connecting");
loginWithOAuth(OAUTH_PROVIDER[p] ?? p, `${window.location.origin}/portal/login`)
.catch(() => { setFlash("Couldn't start sign-in with that provider."); replace("identify"); });
return;
}
const real = startSocial(p); const real = startSocial(p);
if (real) return; // redirected away if (real) return; // redirected away
setProvider(p); setProvider(p);
@@ -65,6 +143,20 @@ export function LoginFlow() {
function identifyEmail() { function identifyEmail() {
if (!EMAIL_RE.test(email)) return; if (!EMAIL_RE.test(email)) return;
if (isShellConfigured()) {
// No mock account directory when the Shell is live: any valid email goes to the
// password screen; the BFF/Supabase decides if the account exists.
const name = email.split("@")[0];
setAccount({
firstName: name.charAt(0).toUpperCase() + name.slice(1),
name, initials: name.slice(0, 2).toUpperCase(),
hasPasskey: false, hasTotp: false, hasPush: false,
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
setOtpChannel("email");
push("password"); // push (not replace) so Back returns to the email screen, not off-page
return;
}
push("connecting"); push("connecting");
setProvider(""); setProvider("");
setTimeout(() => { setTimeout(() => {
@@ -78,14 +170,18 @@ export function LoginFlow() {
/* =================================================================== */ /* =================================================================== */
return ( return (
<div className="card anim-fade-up" key={step}> <div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />} {step === "identify" && (
<>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} invited={invited} />
</>
)}
{step === "connecting" && ( {step === "connecting" && (
<div className="interstitial"> <div className="interstitial">
<Spinner lg /> <Spinner lg />
<div> <div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1> <h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div> </div>
</div> </div>
)} )}
@@ -124,11 +220,11 @@ export function LoginFlow() {
)} )}
{step === "password" && account && ( {step === "password" && account && (
<Password account={account} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> <Password account={account} email={email} login={login} onAuthenticated={acceptPendingInviteThenDashboard} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)} )}
{step === "otp" && account && ( {step === "otp" && account && (
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} /> <OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} />
)} )}
{step === "another" && account && ( {step === "another" && account && (
@@ -192,17 +288,17 @@ export function LoginFlow() {
/* ============================ screens ============================ */ /* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: { function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: {
email: string; setEmail: (v: string) => void; email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void; onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; invited?: boolean;
}) { }) {
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(!!invited);
const valid = EMAIL_RE.test(email); const valid = EMAIL_RE.test(email);
return ( return (
<div> <div>
<div className="kicker">Welcome back</div> <div className="kicker">{invited ? "You're invited" : "Welcome back"}</div>
<h1>Sign in to LynkedUp</h1> <h1>{invited ? "Sign in to join the team" : "Sign in to LynkedUp"}</h1>
<p className="sub">Drone inspections, AI estimates and insurance-ready reports all in one place.</p> <p className="sub">{invited ? "You already have an account — sign in to accept your invitation." : "Drone inspections, AI estimates and insurance-ready reports — all in one place."}</p>
<div style={{ marginTop: 22 }}> <div style={{ marginTop: 22 }}>
<SocialButtons onPick={onSocial} /> <SocialButtons onPick={onSocial} />
@@ -225,6 +321,12 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
</button> </button>
</form> </form>
)} )}
{/* 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> </div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p> <p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
@@ -233,7 +335,6 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span> <span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span> <span><Icon name="check" size={13} /> Drone-Powered</span>
</div> </div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div> </div>
); );
} }
@@ -268,22 +369,32 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
<button className="link" onClick={onPassword}>Use your password instead</button> <button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button> <button className="link" onClick={onAnother}>Try another way</button>
</div> </div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} /> <button hidden onClick={onFail} />
</div> </div>
); );
} }
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: { function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void; account: Account; email: string; login: ReturnType<typeof useAuth>["login"]; onAuthenticated: () => void;
flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
}) { }) {
const [pw, setPw] = useState(""); const [pw, setPw] = useState("");
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
function submit(e: React.FormEvent) { const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!pw || busy) return;
if (isShellConfigured()) {
// Real sign-in through appshell → BFF. A resolved ACE means fully authenticated
// (Supabase handles any MFA), so go straight to the dashboard.
setErr(""); setBusy(true);
try { await login({ email, password: pw }); onAuthenticated(); }
catch { setErr("locked"); onLocked(); }
finally { setBusy(false); }
return;
}
if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; } if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; }
if (!pw) return;
onOk(); onOk();
} }
return ( return (
@@ -304,46 +415,109 @@ function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
</button> </button>
</div> </div>
</div> </div>
<button className="btn btn-primary" style={{ marginTop: 14 }}>Sign in <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 14 }} disabled={busy}>{busy ? "Signing in…" : <>Sign in <Icon name="arrowR" size={16} /></>}</button>
</form> </form>
<div className="row between" style={{ marginTop: 16 }}> <div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button> <button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button> <button className="link" onClick={onOtp}>Sign in with a one-time code</button>
</div> </div>
<p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>
</div> </div>
); );
} }
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: { function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
}) { }) {
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email"); const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
const [error, setError] = useState(false); const shell = isShellConfigured();
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa; // In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
function complete(code: string) { const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
if (code === "000000") { setError(true); return; } const [phone, setPhone] = useState("");
setError(false); onVerified(); const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>("");
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa;
const PHONE_RE = /^\+[1-9]\d{6,14}$/;
// Real Supabase OTP: auto-send the email code when this screen opens.
useEffect(() => {
if (shell && channel === "email" && !sent) void send();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shell, channel]);
async function send() {
setError("");
try {
if (channel === "email") { await sendEmailOtp(email); setSent(true); }
else if (channel === "sms") {
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
await sendPhoneOtp(phone); setSent(true);
}
} catch (e) {
// gotrue can throw an error whose message is an unhelpful "{}"/JSON blob (e.g. a
// 500 "Error sending magic link email" when SMTP isn't set up). Show something
// readable and actionable instead of the raw payload.
const raw = (e as { message?: unknown })?.message;
const readable = typeof raw === "string" && raw.trim() && !raw.trim().startsWith("{") ? raw.trim() : "";
setError(readable || "We couldn't send your sign-in code right now. Please try again shortly, or sign in with your password.");
}
} }
async function complete(code: string) {
if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; }
setBusy(true); setError("");
try {
if (channel === "email") await verifyEmailOtp(email, code);
else await verifyPhoneOtp(phone, code);
onAuthenticated();
} catch { setError("Incorrect or expired code. Please try again."); }
finally { setBusy(false); }
}
const showBoxes = !shell || sent;
return ( return (
<div> <div>
<StepBack onClick={onBack} /> <StepBack onClick={onBack} />
<h1>2-step verification</h1> <h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p> <p className="sub">
<div className="seg" style={{ margin: "16px 0 14px" }}> {!shell
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(false); }}><Icon name="mail" size={15} /> Email</button> ? "Enter the 6-digit code we sent you to finish signing in."
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button> : channel === "sms"
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button> ? "We'll text a 6-digit code to sign in."
</div> : "We'll email you a 6-digit code to sign in."}
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p> </p>
<OtpBoxes onComplete={complete} error={error} /> {/* In Shell mode the channel is fixed by how you signed in (email vs phone)
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></div>} 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(); }}>
<div className="field">
<label className="label" htmlFor="otpphone">Phone number</label>
<input id="otpphone" className="input" type="tel" autoFocus placeholder="+1 415 555 0100" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={busy}>Send code <Icon name="arrowR" size={16} /></button>
</form>
) : (
<>
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
{showBoxes && <OtpBoxes onComplete={(c) => void complete(c)} error={!!error} />}
</>
)}
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">{error === "bad" ? "Incorrect code. Please try again." : error}</FlashNote></div>}
<div className="row between" style={{ margin: "14px 0" }}> <div className="row between" style={{ margin: "14px 0" }}>
<ResendLink seconds={30} /> {shell ? <button className="link" onClick={() => void send()} disabled={busy}>Resend code</button> : <ResendLink seconds={30} />}
<span /> <span />
</div> </div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." /> <RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
<p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>
</div> </div>
); );
} }
@@ -398,7 +572,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"} {setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button> </button>
<RememberDevice checked={remember} onChange={setRemember} /> <RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div> </div>
); );
} }
@@ -417,7 +590,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p> <p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div> <div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>} {!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div> </div>
); );
} }
@@ -501,7 +673,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div> <div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>} {error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div> <div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div> </div>
); );
} }
+5 -1
View File
@@ -508,8 +508,12 @@ select.input option { background: var(--ink); color: var(--text); }
/* ---- country code select ---- */ /* ---- country code select ---- */
.phone-row { display: flex; gap: 8px; } .phone-row { display: flex; gap: 8px; }
.cc-field { position: relative; flex: 0 0 118px; width: 118px; }
.cc-field .cc-select { width: 100%; flex: none; padding-left: 40px; }
.cc-flag-img { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 22px; height: 16px; border-radius: 3px; object-fit: cover; pointer-events: none; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
.cc-flag-img.static { position: static; transform: none; }
.cc-select { width: 110px; flex: 0 0 110px; } .cc-select { width: 110px; flex: 0 0 110px; }
@media (max-width: 420px) { .cc-select { width: 96px; flex-basis: 96px; } } @media (max-width: 420px) { .cc-field { flex-basis: 104px; width: 104px; } .cc-select { width: 96px; flex-basis: 96px; } }
/* ---- misc login/register bits ---- */ /* ---- misc login/register bits ---- */
.tips { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 8px; } .tips { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 8px; }
+246 -231
View File
@@ -5,19 +5,40 @@ import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons"; import { Icon, Avatar } from "./icons";
import { import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice, StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal, PasswordStrength, LegalModal,
} from "./bits"; } from "./bits";
import { import {
countryCodes, relationshipOptions, addressCountries, countryCodes, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry, TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data"; } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP, DEMO_OTP } from "@/lib/otp";
const STEPS = ["Account", "Property", "Verify", "Address"]; type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
const STEPS = ["Account", "Verify", "Address"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function RegisterFlow() { export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
// profile: email is skipped (Google-verified), the auth account already exists so
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter(); const router = useRouter();
const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth();
const { sdk } = useAppShell();
const [step, setStep] = useState(0); 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>({});
const [mailAddr, setMailAddr] = useState<Addr>({});
const [mailingSame, setMailingSame] = useState(true);
// shared form state // shared form state
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null); const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
@@ -27,84 +48,145 @@ export function RegisterFlow() {
const [cc, setCc] = useState("+1"); const [cc, setCc] = useState("+1");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [pw, setPw] = useState(""); const [pw, setPw] = useState("");
const [relationship, setRelationship] = useState("Homeowner");
const [alloeNo, setAlloeNo] = useState("");
const [alloeFirst, setAlloeFirst] = useState("");
const [alloeLast, setAlloeLast] = useState("");
const [termsOk, setTermsOk] = useState(false); const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false); const [privacyOk, setPrivacyOk] = useState(false);
// step 1 // verify step (email is trusted without an OTP — see verifyValid below)
const [allotment, setAllotment] = useState("");
const [plotVerified, setPlotVerified] = useState(false);
// step 2
const [emailVerified, setEmailVerified] = useState(false);
const [phoneVerified, setPhoneVerified] = useState(false); const [phoneVerified, setPhoneVerified] = useState(false);
// When arriving from a team invite, the email is fixed to the invited address.
const [invitedEmail, setInvitedEmail] = useState("");
useEffect(() => {
if (onboard) return;
try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvitedEmail(e); } } catch { /* ignore */ }
}, [onboard]);
// 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", "Verify", "Address"] : STEPS;
const isSelf = relationship === "Homeowner";
const country = countryCodes.find((c) => c.code === cc)!; const country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits; const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3; const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(alloeNo);
const step0Valid = const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk && first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && alloeFirst.trim() && alloeLast.trim())); termsOk && privacyOk;
const step1Valid = /^LUP-\d{6}$/.test(allotment) || plotVerified; // Email is already trusted in both flows (registration: Supabase auto-confirms on
const step2Valid = emailVerified && phoneVerified; // signup; onboarding: Google-verified), so the Verify step only gates on the phone.
const verifyValid = phoneVerified;
function finish() { // 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;
const profile = { const profile = {
name: `${first} ${last}`.trim(), name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(), initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: sso?.email || email, email: finalEmail,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
}; };
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ } try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
if (isShellConfigured()) {
setSubmitErr("");
// 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 CRM registration payload to be-crm (name, phone, addresses,
// consent). No role/persona — a new user has no permissions until invited.
// Non-fatal: the auth account exists either way.
try {
const mailing = mailingSame ? regAddr : mailAddr;
await sdk.command("crm.account.register", {
email: finalEmail,
firstName: first, lastName: last,
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
registeredAddress: regAddr,
mailingAddress: mailing,
mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk,
});
} catch {
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
}
// 3) If the user arrived from a team invitation link, redeem it now — this
// creates their membership with the invited role(s). Non-fatal.
try {
const inviteToken = sessionStorage.getItem("invite_token");
if (inviteToken) {
await sdk.command("crm.team.invitation.accept", { token: inviteToken });
sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
}
} catch { /* invitation expired/used — they can still be invited again */ }
}
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
router.push("/dashboard"); router.push("/dashboard");
} }
return ( return (
<div className="card anim-fade-up"> <div className="card anim-fade-up">
<Stepper current={step} /> <Stepper current={step} labels={STEP_LABELS} />
{step === 0 && ( {step === 0 && (
<StepAccount <>
sso={sso} setSso={setSso} email={email} setEmail={setEmail} {submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
first={first} setFirst={setFirst} last={last} setLast={setLast} <StepAccount
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country} onboard={onboard} creating={creating} invited={!!invitedEmail}
pw={pw} setPw={setPw} sso={sso} setSso={setSso} email={email} setEmail={setEmail}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf} first={first} setFirst={setFirst} last={last} setLast={setLast}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk} cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast} pw={pw} setPw={setPw}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk} termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid} valid={!!step0Valid}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")} onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/> />
</>
)} )}
{step === 1 && ( {step === 1 && (
<StepAllotment <StepVerify
allotment={allotment} setAllotment={setAllotment} cc={cc} phone={phone} country={country}
plotVerified={plotVerified} setPlotVerified={setPlotVerified} phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step1Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)} valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/> />
)} )}
{step === 2 && ( {step === 2 && (
<StepVerify <>
emailValue={sso?.email || email} cc={cc} phone={phone} country={country} {submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
emailVerified={emailVerified} setEmailVerified={setEmailVerified} <StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified} </>
valid={step2Valid} onBack={() => setStep(1)} onContinue={() => setStep(3)}
/>
)}
{step === 3 && (
<StepAddress onBack={() => setStep(2)} onFinish={finish} />
)} )}
</div> </div>
); );
@@ -117,59 +199,53 @@ function StepAccount(p: {
first: string; setFirst: (v: string) => void; last: string; setLast: (v: string) => void; first: string; setFirst: (v: string) => void; last: string; setLast: (v: string) => void;
cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number]; cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
pw: string; setPw: (v: string) => void; pw: string; setPw: (v: string) => void;
relationship: string; setRelationship: (v: string) => void; isSelf: boolean;
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; termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
valid: boolean; onContinue: () => void; toLogin: () => void; valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; invited?: boolean;
}) { }) {
const [phase, setPhase] = useState<"sso" | "profile">("sso"); // Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
const [modal, setModal] = useState<null | "terms" | "privacy">(null); const [modal, setModal] = useState<null | "terms" | "privacy">(null);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") { if (phase === "sso") {
const valid = EMAIL_RE.test(p.email); const valid = EMAIL_RE.test(p.email);
return ( return (
<div> <div>
<h1>Create your account</h1> <StepBack onClick={p.toLogin} />
<p className="sub">Sign up with a provider or your email to get started.</p> <h1>{p.invited ? "Accept your invitation" : "Create your account"}</h1>
<p className="sub">{p.invited ? "You've been invited to the team. Set up your account to join." : "Enter your email to get started."}</p>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}> <form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field"> <div className="field">
<label className="label">Email address</label> <label className="label">Email address</label>
<div className="input-wrap"> <div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span> <span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus /> <input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!p.invited} disabled={p.invited} readOnly={p.invited} />
</div> </div>
{p.invited && <span className="faint" style={{ fontSize: 12 }}>This is the address you were invited with.</span>}
</div> </div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button>
</form> </form>
</div> </div>
<p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p> {!p.invited && <p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p>}
</div> </div>
); );
} }
return ( return (
<div> <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> <h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p> <p className="sub">Tell us a bit about you to set up your account.</p>
<div style={{ marginTop: 16 }}> <div className="field" style={{ marginTop: 16 }}>
{p.sso ? ( <label className="label">Email address</label>
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div> <div className="input-wrap">
) : ( <span className="input-ico"><Icon name="mail" size={17} /></span>
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div> <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>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}> <div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
@@ -191,9 +267,13 @@ function StepAccount(p: {
<div className="field" style={{ marginTop: 14 }}> <div className="field" style={{ marginTop: 14 }}>
<label className="label">Mobile number</label> <label className="label">Mobile number</label>
<div className="phone-row"> <div className="phone-row">
<select className="input cc-select" value={p.cc} onChange={(e) => p.setCc(e.target.value)}> <div className="cc-field">
{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)} {/* eslint-disable-next-line @next/next/no-img-element */}
</select> <img className="cc-flag-img" src={flagUrl(p.country.flag)} alt={p.country.name} width={22} height={16} />
<select className="input cc-select" value={p.cc} onChange={(e) => p.setCc(e.target.value)}>
{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.code}</option>)}
</select>
</div>
<input className="input grow" inputMode="numeric" value={p.phone} onChange={(e) => p.setPhone(e.target.value.replace(/\D/g, ""))} placeholder={p.country.example} /> <input className="input grow" inputMode="numeric" value={p.phone} onChange={(e) => p.setPhone(e.target.value.replace(/\D/g, ""))} placeholder={p.country.example} />
</div> </div>
{p.phone && !p.phoneOk && <span style={{ fontSize: 12, color: "#fca5a5" }}>Enter a valid {p.country.digits}-digit number.</span>} {p.phone && !p.phoneOk && <span style={{ fontSize: 12, color: "#fca5a5" }}>Enter a valid {p.country.digits}-digit number.</span>}
@@ -209,35 +289,6 @@ function StepAccount(p: {
</div> </div>
)} )}
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Your role</label>
<select className="input" value={p.relationship} onChange={(e) => p.setRelationship(e.target.value)}>
{relationshipOptions.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
{p.isSelf ? (
<div style={{ marginTop: 12 }}><FlashNote tone="success">Homeowner account you own this property.</FlashNote></div>
) : (
<div style={{ marginTop: 12 }}>
<FlashNote tone="info">Registering on behalf of the property owner.</FlashNote>
<div className="dashed-block" style={{ marginTop: 12 }}>
<div className="row between" style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 13.5 }}>Property Owner Details</strong>
{p.alloeNo && (p.alloeIdOk ? <Badge tone="green"><Icon name="check" size={12} /> Valid</Badge> : <Badge tone="gray">Checking</Badge>)}
</div>
<div className="field">
<label className="label">Owner / Property ID</label>
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder="Alphanumeric ID" />
</div>
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">Owner first name</label><input className="input" value={p.alloeFirst} onChange={(e) => p.setAlloeFirst(e.target.value)} /></div>
<div className="field grow"><label className="label">Owner last name</label><input className="input" value={p.alloeLast} onChange={(e) => p.setAlloeLast(e.target.value)} /></div>
</div>
</div>
</div>
)}
<div className="col gap-2" style={{ marginTop: 16 }}> <div className="col gap-2" style={{ marginTop: 16 }}>
<label className="check-row"> <label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} /> <input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
@@ -251,7 +302,9 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</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}>Create Account &amp; 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 === "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); }} />} {modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -261,74 +314,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read // module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false }; const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — ALLOTMENT ====================== */ /* ====================== STEP 1 VERIFY PHONE ======================
function StepAllotment(p: { Email is already trusted (registration: Supabase auto-confirms on signup;
allotment: string; setAllotment: (v: string) => void; onboarding: Google-verified), so this step only verifies the phone via a real
plotVerified: boolean; setPlotVerified: (v: boolean) => void; SMS OTP: addPhone() Twilio texts a code verifyPhone() confirms it. Both
valid: boolean; onBack: () => void; onContinue: () => void; run against the live Supabase session established in the previous step. */
}) {
const [mode, setMode] = useState<"have" | "forgot">("have");
const [houseNo, setHouseNo] = useState("");
const [street, setStreet] = useState("");
const [city, setCity] = useState("");
const [busy, setBusy] = useState(false);
const fmtOk = /^LUP-\d{6}$/.test(p.allotment);
function findPlot() {
if (!houseNo || !street || !city) return;
setBusy(true);
setTimeout(() => { setBusy(false); p.setPlotVerified(true); }, 800);
}
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Identify the property</h1>
<p className="sub">Find the property by its job ID, or look it up by address.</p>
<div className="seg" style={{ margin: "16px 0" }}>
<button className={mode === "have" ? "on" : ""} onClick={() => setMode("have")}>I have a job ID</button>
<button className={mode === "forgot" ? "on" : ""} onClick={() => setMode("forgot")}>Find by address</button>
</div>
{mode === "have" ? (
<div className="field">
<label className="label">Property / Job ID</label>
<input className="input" value={p.allotment} onChange={(e) => p.setAllotment(e.target.value.toUpperCase())} placeholder="LUP-123456" />
<span className="faint" style={{ fontSize: 12 }}>Format: LUP- followed by 6 digits.</span>
</div>
) : (
<div>
<div className="row gap-3">
<div className="field" style={{ width: 120 }}><label className="label">House no.</label><input className="input" value={houseNo} onChange={(e) => setHouseNo(e.target.value)} placeholder="123" /></div>
<div className="field grow"><label className="label">Street</label><input className="input" value={street} onChange={(e) => setStreet(e.target.value)} placeholder="Street name" /></div>
</div>
<div className="field" style={{ marginTop: 12 }}><label className="label">City / ZIP</label><input className="input" value={city} onChange={(e) => setCity(e.target.value)} placeholder="City or ZIP code" /></div>
{!p.plotVerified ? (
<button className="btn" style={{ marginTop: 12 }} disabled={busy || !(houseNo && street && city)} onClick={findPlot}>
{busy ? "Searching…" : "Find my property"}
</button>
) : (
<div className="conn-banner conn-green" style={{ marginTop: 12 }}>
<Icon name="check" size={16} />
<span style={{ letterSpacing: 1 }}>LUP-****** · Verified · {houseNo} {street}, {city}</span>
</div>
)}
<p className="demo-hint">Sensitive details stay masked until verified (verify-to-reveal).</p>
</div>
)}
{mode === "have" && p.allotment && !fmtOk && <p className="hint-line">Enter a valid ID like LUP-123456.</p>}
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!p.valid} onClick={p.onContinue}>Continue <Icon name="arrowR" size={16} /></button>
</div>
);
}
/* ====================== STEP 2 — VERIFY ====================== */
function StepVerify(p: { function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number]; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void; phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void; valid: boolean; onBack: () => void; onContinue: () => void;
}) { }) {
@@ -336,12 +328,11 @@ function StepVerify(p: {
return ( return (
<div> <div>
<StepBack onClick={p.onBack} /> <StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1> <h1>Verify your phone</h1>
<p className="sub">Confirm both so we can secure your account.</p> <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 }}> <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)} /> <PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div> </div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div> <div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
@@ -350,40 +341,53 @@ function StepVerify(p: {
); );
} }
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile"; type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: { function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string; cc: string; country: typeof countryCodes[number]; initialPhone: string;
verified: boolean; onVerified: () => void; verified: boolean; onVerified: () => void;
}) { }) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone); const { addPhone, verifyPhone } = useAuth();
const [via, setVia] = useState<"primary" | "wa">("primary"); const [value, setValue] = useState(initialPhone);
const [state, setState] = useState<SendState>("idle"); const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false); const [otpError, setOtpError] = useState("");
const primaryLabel = kind === "email" ? "Email" : "SMS"; 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"); setState("sending");
setTimeout(() => { setOtpError("");
if (kind === "email") { if (MOCK_OTP) { setState("sent"); return; } // demo: skip Twilio entirely
if (!EMAIL_RE.test(value)) return setState("invalid_email"); try {
if (value.includes("full")) return setState("mailbox_full"); await addPhone(e164); // Supabase → Twilio sends the SMS OTP
if (value.includes("bo")) return setState("bounce_risk"); setState("sent");
return setState("ok"); } catch {
} else { setState("send_error");
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile"); }
return setState("ok"); }
}
}, 700); 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) { if (verified) {
return ( return (
<div className="vchannel verified"> <div className="vchannel verified">
<div className="row between"> <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> <Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div> </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> </div>
); );
} }
@@ -391,49 +395,42 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
return ( return (
<div className="vchannel"> <div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}> <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> </div>
{kind === "email" ? ( <div className="phone-row">
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" /> <span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
) : ( {/* eslint-disable-next-line @next/next/no-img-element */}
<div className="phone-row"> <img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>{country.flag} {cc}</span> {cc}
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} /> </span>
</div> <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="row between" style={{ marginTop: 12 }}>
<div className="seg"> <span className="faint" style={{ fontSize: 12 }}>{MOCK_OTP ? "Demo verification (no SMS sent)." : "Standard SMS rates may apply."}</span>
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button> <button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button> {state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
</button> </button>
</div> </div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>} {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_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 === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>} {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 }}> <div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} /> <OtpBoxes onComplete={submit} error={!!otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>} {otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div> <div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
</div> </div>
)} )}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div> </div>
); );
} }
/* ====================== STEP 3 — ADDRESS ====================== */ /* ====================== STEP 3 — ADDRESS ====================== */
function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) { function StepAddress({ sameAs, setSameAs, onRegAddr, onMailAddr, onBack, onFinish }: { sameAs: boolean; setSameAs: (v: boolean) => void; onRegAddr: (a: Addr) => void; onMailAddr: (a: Addr) => void; onBack: () => void; onFinish: () => void }) {
const [sameAs, setSameAs] = useState(true);
return ( return (
<div> <div>
<StepBack onClick={onBack} /> <StepBack onClick={onBack} />
@@ -441,7 +438,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
<p className="sub">Add your registered address and where we should send mail.</p> <p className="sub">Add your registered address and where we should send mail.</p>
<AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" /> <AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" />
<AddressBlock idPrefix="reg" /> <AddressBlock idPrefix="reg" onChange={onRegAddr} />
<label className="check-row" style={{ marginTop: 16 }}> <label className="check-row" style={{ marginTop: 16 }}>
<input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} /> <input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} />
@@ -451,7 +448,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
{!sameAs && ( {!sameAs && (
<> <>
<AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" /> <AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" />
<AddressBlock idPrefix="mail" /> <AddressBlock idPrefix="mail" onChange={onMailAddr} />
</> </>
)} )}
@@ -476,7 +473,7 @@ function AddrSectionHead({ icon, title, sub, tone }: { icon: string; title: stri
); );
} }
function AddressBlock({ idPrefix }: { idPrefix: string }) { function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a: Addr) => void }) {
const [country, setCountry] = useState<AddrCountry>(addressCountries[0]); const [country, setCountry] = useState<AddrCountry>(addressCountries[0]);
const [pin, setPin] = useState(""); const [pin, setPin] = useState("");
const [city, setCity] = useState(""); const [city, setCity] = useState("");
@@ -489,6 +486,13 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]); const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null); const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Report the composed address up so the parent can persist it (postalCode is the
// PIN for India, the postal field elsewhere).
useEffect(() => {
onChange?.({ line1, line2, city, state: stateName, postalCode: postal || pin, country: country.code, locality });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [line1, line2, city, stateName, postal, pin, country, locality]);
// India: PIN auto-fill // India: PIN auto-fill
useEffect(() => { useEffect(() => {
if (country.lookup !== "pincode" || pin.length !== 6) return; if (country.lookup !== "pincode" || pin.length !== 6) return;
@@ -598,10 +602,10 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
} }
/* ====================== stepper ====================== */ /* ====================== stepper ====================== */
function Stepper({ current }: { current: number }) { function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
return ( return (
<div className="steps"> <div className="steps">
{STEPS.map((label, i) => { {labels.map((label, i) => {
const done = i < current, on = i === current; const done = i < current, on = i === current;
return ( return (
<div key={label} style={{ display: "contents" }}> <div key={label} style={{ display: "contents" }}>
@@ -618,3 +622,14 @@ function Stepper({ current }: { current: number }) {
} }
function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); } function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }
// Flag emojis don't render on Windows, so map the emoji's regional-indicator
// code points to an ISO-2 code and use a flag image instead.
function flagUrl(flag: string): string {
const iso = Array.from(flag)
.map((ch) => ch.codePointAt(0))
.filter((cp): cp is number => cp != null && cp >= 0x1f1e6 && cp <= 0x1f1ff)
.map((cp) => String.fromCharCode(cp - 0x1f1e6 + 97))
.join("");
return `https://flagcdn.com/w40/${iso}.png`;
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
/**
* The signed-in user's effective CRM access, from crm.account.me. Drives navigation
* gating: a user with no membership/permissions sees only Dashboard + Profile; members
* see the areas their permissions allow.
*
* Every CRM permission id, granted to superadmins / owners. Used as the mock default
* when the Shell isn't configured (local demo shows everything).
*/
export const ALL_CRM_PERMISSIONS = [
"leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage",
"reports.view", "billing.view", "team.manage", "roles.manage", "settings.manage",
] as const;
export interface MyAccess {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
/** True while the access query is still resolving (nav stays minimal until then). */
loading: boolean;
can: (permission: string) => boolean;
}
interface MeDTO {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
}
function useLiveAccess(): MyAccess {
const { data, loading } = useQuery<MeDTO>("crm.account.me");
const permissions = data?.permissions ?? [];
return {
registered: data?.registered ?? false,
isMember: data?.isMember ?? false,
roleSlugs: data?.roleSlugs ?? [],
permissions,
isSuperadmin: data?.isSuperadmin ?? false,
loading,
can: (p: string) => permissions.includes(p),
};
}
function useMockAccess(): MyAccess {
// No Shell (local demo): show everything so the mock UI is fully browsable.
const permissions = [...ALL_CRM_PERMISSIONS];
return {
registered: true,
isMember: true,
roleSlugs: ["superadmin"],
permissions,
isSuperadmin: true,
loading: false,
can: () => true,
};
}
export const useMyAccess: () => MyAccess = isShellConfigured() ? useLiveAccess : useMockAccess;
+11
View File
@@ -0,0 +1,11 @@
// Integration helpers for appshell-sdk. NEXT_PUBLIC_* vars are inlined at build
// time, so this reflects deploy-time configuration.
/**
* True when the Shell (Supabase BFF) is configured. When false, the app falls
* back to the existing mock portal / static user so the demo keeps working before
* the backend is deployed. Gate real-vs-mock auth on this.
*/
export function isShellConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
// Inbox data layer. The inbox is a personalized work/awareness feed IIOS projects from events
// (NEEDS_REPLY, MENTION, …). The CRM lists it and transitions item state; items are never created
// here. Mock when the Shell isn't configured; live via the be-crm data door (crm.inbox.*) otherwise.
//
// Live contract (be-crm):
// query crm.inbox.list { state? } -> InboxItem[]
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
export interface UiInboxItem {
id: string; kind: string; state: InboxState; title: string; summary?: string;
priority: string; threadId?: string; createdAt: string;
}
const SHELL = isShellConfigured();
export interface InboxData {
live: boolean; loading: boolean; error: string | null;
items: UiInboxItem[];
transition: (id: string, state: InboxState) => Promise<void>;
refetch: () => void;
}
export function useInboxData(state?: InboxState): InboxData {
return SHELL ? useLiveInbox(state) : useMockInbox(state);
}
function useLiveInbox(state?: InboxState): InboxData {
const { sdk } = useAppShell();
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
const transition = useCallback(async (id: string, next: InboxState) => {
await sdk.command("crm.inbox.transition", { id, state: next });
q.refetch();
}, [sdk, q]);
return { live: true, loading: q.loading, error: q.error?.message ?? null, items: q.data ?? [], transition, refetch: q.refetch };
}
const MOCK_ITEMS: UiInboxItem[] = [
{ id: "in_1", kind: "MENTION", state: "OPEN", title: "Sofia mentioned you", summary: "@you — can you confirm the Henderson scope?", priority: "HIGH", threadId: "th_mock_1", createdAt: new Date().toISOString() },
{ id: "in_2", kind: "NEEDS_REPLY", state: "OPEN", title: "Reply needed — Storm response", summary: "Dan: Crew is rolling out at 7.", priority: "MEDIUM", threadId: "th_mock_2", createdAt: new Date().toISOString() },
{ id: "in_3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TK-204 updated", summary: "Customer replied on the roof-leak case.", priority: "LOW", createdAt: new Date().toISOString() },
];
function useMockInbox(state?: InboxState): InboxData {
const [items, setItems] = useState<UiInboxItem[]>(MOCK_ITEMS);
const filtered = useMemo(() => (state ? items.filter((i) => i.state === state) : items), [items, state]);
const transition = useCallback(async (id: string, next: InboxState) => {
setItems((l) => l.map((i) => (i.id === id ? { ...i, state: next } : i)));
}, []);
return { live: false, loading: false, error: null, items: filtered, transition, refetch: () => {} };
}
+371
View File
@@ -0,0 +1,371 @@
"use client";
// Messenger data layer. Serves EITHER a local mock (when the Shell isn't configured — the demo
// keeps working) OR the live be-crm data door (crm.messenger.*), behind one interface so the UI is
// mode-agnostic. DM-vs-group + who-can-chat are enforced server-side by IIOS/OPA; this is just glue.
//
// Live contract (be-crm):
// query crm.messenger.directory { kind, query?, limit } -> DirectoryEntry[]
// query crm.messenger.conversation.list {} -> ConversationSummary[]
// cmd crm.messenger.conversation.open { participantIds[], membership?, subject? } -> { threadId, ... }
// query crm.messenger.history { threadId } -> MessengerMessage[]
// cmd crm.messenger.send { threadId, content } -> MessengerMessage
// cmd crm.messenger.participant.add { threadId, userId }
//
// v1 uses REST + polling for the live stream; v2 layers the IIOS MessageSocket (messenger-socket.tsx)
// on top for live messages, typing, read receipts, and reactions.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import type { AnnotationEvent, AnnotationGroup } from "@insignia/iios-kernel-client";
import { isShellConfigured } from "./appshell";
import { useMessengerSocket } from "./messenger-socket";
export type Membership = "dm" | "group";
export interface UiPerson { id: string; name: string; kind: "staff" | "customer" }
export interface UiConversation {
threadId: string; title: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
export interface UiReaction { emoji: string; count: number; mine: boolean }
export interface UiMessage {
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
parentInteractionId?: string | null;
reactions?: UiReaction[];
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
interface ConversationDTO {
threadId: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
const SHELL = isShellConfigured();
const POLL_MS = 4000;
const TYPING_TTL_MS = 3500;
const shortId = (id: string) => id.replace(/^(pp_|cust_)/, "").slice(0, 6);
/** Turn the kernel's generic annotation aggregates into reaction chips. `users` may hold user or
* actor ids depending on the source, so `mine` is best-effort; a fresh annotation event corrects it. */
export function toReactions(annotations: AnnotationGroup[] | undefined, myId?: string): UiReaction[] {
if (!annotations) return [];
return annotations
.filter((a) => a.type === "reaction" && a.users.length > 0)
.map((a) => ({ emoji: a.value, count: a.users.length, mine: !!myId && a.users.includes(myId) }));
}
function applyAnnotation(prev: UiReaction[] | undefined, e: AnnotationEvent, myId?: string): UiReaction[] {
const base = (prev ?? []).filter((r) => r.emoji !== e.value);
if (e.type !== "reaction" || e.users.length === 0) return base;
return [...base, { emoji: e.value, count: e.users.length, mine: !!myId && e.users.includes(myId) }];
}
/* ======================================================================== */
/* Public hooks */
/* ======================================================================== */
export interface MessengerData {
live: boolean; loading: boolean; error: string | null;
directory: UiPerson[];
conversations: UiConversation[];
nameOf: (id: string) => string;
openConversation: (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => Promise<string>;
refetch: () => void;
}
export interface ThreadData {
loading: boolean; error: string | null;
messages: UiMessage[];
send: (content: string, opts?: { parentInteractionId?: string }) => Promise<void>;
react: (interactionId: string, emoji: string) => void;
typingUserIds: string[];
seenIds: Set<string>;
refetch: () => void;
}
export function useMessengerData(): MessengerData {
return SHELL ? useLiveMessenger() : useMockMessenger();
}
export function useThread(threadId: string): ThreadData {
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
}
/* ======================================================================== */
/* Live implementation (be-crm data door + IIOS socket) */
/* ======================================================================== */
function useLiveMessenger(): MessengerData {
const { sdk } = useAppShell();
const { user } = useAuth();
const socket = useMessengerSocket();
const myId = user?.id;
const dirQ = useQuery<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const convQ = useQuery<ConversationDTO[]>("crm.messenger.conversation.list", {});
const directory: UiPerson[] = useMemo(
() => (dirQ.data ?? []).map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })),
[dirQ.data],
);
const nameById = useMemo(() => Object.fromEntries(directory.map((p) => [p.id, p.name])), [directory]);
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
// Live sidebar previews: patch lastMessage/lastAt the instant a message arrives on any thread,
// then reconcile authoritative unread/order with a debounced refetch.
const [previews, setPreviews] = useState<Record<string, { lastMessage: string; lastAt: string }>>({});
const refetchRef = useRef(convQ.refetch);
refetchRef.current = convQ.refetch;
useEffect(() => {
if (!socket) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const off = socket.onAnyMessage((threadId, m) => {
setPreviews((p) => ({ ...p, [threadId]: { lastMessage: m.text, lastAt: m.at } }));
if (timer) clearTimeout(timer);
timer = setTimeout(() => refetchRef.current(), 600);
});
return () => { off(); if (timer) clearTimeout(timer); };
}, [socket]);
const conversations: UiConversation[] = useMemo(
() => (convQ.data ?? []).map((c) => shape(c, nameOf, myId, previews[c.threadId])),
[convQ.data, nameOf, myId, previews],
);
const refetch = useCallback(() => { dirQ.refetch(); convQ.refetch(); }, [dirQ, convQ]);
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
const res = (await sdk.command("crm.messenger.conversation.open", {
participantIds, ...(opts?.membership ? { membership: opts.membership } : {}), ...(opts?.subject ? { subject: opts.subject } : {}),
})) as { threadId: string };
convQ.refetch();
return res.threadId;
}, [sdk, convQ]);
return {
live: true,
loading: dirQ.loading || convQ.loading,
error: (dirQ.error ?? convQ.error)?.message ?? null,
directory, conversations, nameOf, openConversation, refetch,
};
}
function useLiveThread(threadId: string): ThreadData {
const { sdk } = useAppShell();
const socket = useMessengerSocket();
const socketReady = socket?.ready ?? false;
const q = useQuery<MessageDTO[]>("crm.messenger.history", { threadId });
const [socketMsgs, setSocketMsgs] = useState<UiMessage[]>([]);
const [myActorId, setMyActorId] = useState<string | null>(null);
const myActorIdRef = useRef<string | null>(null);
myActorIdRef.current = myActorId;
const [typing, setTyping] = useState<Record<string, number>>({}); // userId -> expiry ts
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const myId = socket?.myUserId;
// REST poll — the fallback whenever the live socket isn't connected.
const refetchRef = useRef(q.refetch);
refetchRef.current = q.refetch;
useEffect(() => {
if (socketReady) return;
const t = setInterval(() => refetchRef.current(), POLL_MS);
return () => clearInterval(t);
}, [socketReady, threadId]);
// Socket (primary): load history + subscribe to live messages, typing, receipts, reactions.
useEffect(() => {
if (!socket || !socketReady) return;
let alive = true;
setSocketMsgs([]); setSeenIds(new Set()); setTyping({});
void socket.openThread(threadId).then((hist) => { if (alive) setSocketMsgs(hist); }).catch(() => {});
const offMsg = socket.subscribe(threadId, (m) =>
setSocketMsgs((l) => (l.some((x) => x.id === m.id) ? l : [...l, m])),
);
const offTyping = socket.onTyping(threadId, (userId) =>
setTyping((t) => ({ ...t, [userId]: Date.now() + TYPING_TTL_MS })),
);
// Receipts are a global stream (no threadId). Count only reads by the OTHER side; seenMine then
// narrows to my messages in this thread.
const offReceipt = socket.onReceipt((e) => {
if (e.actorId === myActorIdRef.current) return;
setSeenIds((s) => (s.has(e.interactionId) ? s : new Set(s).add(e.interactionId)));
});
const offAnn = socket.onAnnotation(threadId, (e) =>
setSocketMsgs((l) => l.map((m) => (m.id === e.interactionId ? { ...m, reactions: applyAnnotation(m.reactions, e, myId) } : m))),
);
return () => { alive = false; offMsg(); offTyping(); offReceipt(); offAnn(); };
}, [socket, socketReady, threadId, myId]);
// Learn my own actor id from a message I sent, so receipts from OTHER actors read as "seen".
useEffect(() => {
const mine = socketMsgs.find((m) => m.mine && m.actorId);
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
}, [socketMsgs, myActorId]);
// Tell the server I've read the latest message (drives the other side's "seen" tick).
useEffect(() => {
if (!socket || !socketReady || socketMsgs.length === 0) return;
socket.markRead(threadId, socketMsgs[socketMsgs.length - 1].id);
}, [socket, socketReady, threadId, socketMsgs]);
// Expire stale typing entries.
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing).filter(([, exp]) => exp > now).map(([u]) => u);
}, [typing]);
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
const restMsgs: UiMessage[] = useMemo(
() => (q.data ?? []).map((m) => ({
id: m.interactionId, actorId: m.actorId, senderId: null, text: m.text ?? "", at: m.occurredAt,
mine: !!myActorId && m.actorId === myActorId, reactions: [],
})),
[q.data, myActorId],
);
const messages = socketReady ? socketMsgs : restMsgs;
// My messages the other side has read (receipts carry the other actor's id).
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => {
if (socket && socketReady) {
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
} else {
const m = (await sdk.command("crm.messenger.send", { threadId, content })) as MessageDTO;
if (m.actorId) setMyActorId(m.actorId);
q.refetch();
}
}, [socket, socketReady, threadId, sdk, q]);
const react = useCallback((interactionId: string, emoji: string) => {
if (socket && socketReady) socket.react(threadId, interactionId, emoji);
}, [socket, socketReady, threadId]);
return {
loading: q.loading && !socketReady, error: q.error?.message ?? null,
messages, send, react, typingUserIds, seenIds: seenMine, refetch: q.refetch,
};
}
function shape(
c: ConversationDTO,
nameOf: (id: string) => string,
myId: string | undefined,
overlay?: { lastMessage: string; lastAt: string },
): UiConversation {
// A DM's title is the OTHER person — never yourself, and never the raw unknown-id fallback for both.
const others = myId ? c.participants.filter((p) => p !== myId) : c.participants;
const title = c.subject?.trim()
|| (c.membership === "group"
? `Group · ${c.participants.length}`
: (others.map(nameOf).join(", ") || nameOf(c.participants[0] ?? "") || "Conversation"));
const lastMessage = overlay?.lastMessage ?? c.lastMessage;
const lastAt = overlay?.lastAt ?? c.lastAt;
return {
threadId: c.threadId, title, subject: c.subject, membership: c.membership,
participants: c.participants, unread: c.unread,
...(lastMessage ? { lastMessage } : {}), ...(lastAt ? { lastAt } : {}),
};
}
/* ======================================================================== */
/* Mock implementation (no Shell configured — the demo keeps working) */
/* ======================================================================== */
const MOCK_PEOPLE: UiPerson[] = [
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
{ id: "pp_dan", name: "Dan Whitaker", kind: "staff" },
{ id: "pp_priya", name: "Priya Nair", kind: "staff" },
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
{ id: "cust_globex", name: "Globex Homes (Client)", kind: "customer" },
];
interface MockThread { threadId: string; membership: Membership; subject: string | null; participants: string[]; messages: UiMessage[] }
const now = () => new Date().toISOString();
let MOCK_SEQ = 100;
// A tiny module-level store both mock hooks share, with a subscribe-on-change so the
// conversation list and the open thread stay in sync (no globalThis, no render writes).
const MOCK_STORE = new Map<string, MockThread>([
["th_mock_1", { threadId: "th_mock_1", membership: "dm", subject: null, participants: ["me", "pp_sofia"],
messages: [{ id: "m1", actorId: "pp_sofia", text: "Can you review the Henderson estimate?", at: now(), mine: false, reactions: [] }] }],
["th_mock_2", { threadId: "th_mock_2", membership: "group", subject: "Storm response — East side", participants: ["me", "pp_dan", "pp_priya"],
messages: [{ id: "m2", actorId: "pp_dan", text: "Crew is rolling out at 7.", at: now(), mine: false, reactions: [] }] }],
]);
const mockListeners = new Set<() => void>();
const notifyMock = () => mockListeners.forEach((l) => l());
function useMockSubscription(): void {
const [, setV] = useState(0);
useEffect(() => {
const l = () => setV((n) => n + 1);
mockListeners.add(l);
return () => { mockListeners.delete(l); };
}, []);
}
function useMockMessenger(): MessengerData {
useMockSubscription();
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
const conversations: UiConversation[] = [...MOCK_STORE.values()].map((t) => {
const last = t.messages[t.messages.length - 1];
return {
threadId: t.threadId,
title: t.subject || t.participants.filter((p) => p !== "me").map(nameOf).join(", ") || "Conversation",
subject: t.subject, membership: t.membership, participants: t.participants, unread: 0,
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
};
});
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
const membership = opts?.membership ?? (participantIds.length === 1 ? "dm" : "group");
const threadId = `th_mock_${MOCK_SEQ++}`;
MOCK_STORE.set(threadId, { threadId, membership, subject: opts?.subject ?? null, participants: ["me", ...participantIds], messages: [] });
notifyMock();
return threadId;
}, []);
return { live: false, loading: false, error: null, directory: MOCK_PEOPLE, conversations, nameOf, openConversation, refetch: () => {} };
}
function useMockThread(threadId: string): ThreadData {
useMockSubscription();
const thread = MOCK_STORE.get(threadId);
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => {
const t = MOCK_STORE.get(threadId);
if (t) {
t.messages = [...t.messages, {
id: `m_${MOCK_SEQ++}`, actorId: "me", text: content, at: now(), mine: true, reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
}];
notifyMock();
}
}, [threadId]);
const react = useCallback((interactionId: string, emoji: string) => {
const t = MOCK_STORE.get(threadId);
if (!t) return;
t.messages = t.messages.map((m) => {
if (m.id !== interactionId) return m;
const has = (m.reactions ?? []).find((r) => r.emoji === emoji);
const reactions = has
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
return { ...m, reactions };
});
notifyMock();
}, [threadId]);
return {
loading: false, error: null, messages: thread?.messages ?? [], send, react,
typingUserIds: [], seenIds: new Set(), refetch: notifyMock,
};
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
// v2 live stream: one IIOS MessageSocket for the whole Messenger panel, using the SDK
// (@insignia/iios-kernel-client) — not raw socket.io. The delegated realtime token comes from
// the be-crm data door (crm.messenger.realtime). Threads subscribe through a context; the socket
// re-opens every joined thread on reconnect (handled inside the SDK). In mock mode this is a no-op
// passthrough and the thread hook falls back to the REST poll.
//
// Beyond plain messages, the kernel exposes typing, read receipts, and reactions (generic
// annotations). This provider fans each server event out to per-thread listeners so the UI can
// render typing indicators, "seen" ticks, and emoji reactions live.
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { MessageSocket, type Message, type AnnotationEvent } from "@insignia/iios-kernel-client";
import { useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import { toReactions, type UiMessage } from "./messenger-api";
interface RealtimeDTO { url: string; audience: string; token?: string }
export interface ReceiptHit { interactionId: string; actorId: string }
export interface MessengerSocket {
ready: boolean;
myUserId?: string;
openThread: (threadId: string) => Promise<UiMessage[]>;
send: (threadId: string, content: string, opts?: { parentInteractionId?: string }) => Promise<void>;
subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void;
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void;
sendTyping: (threadId: string) => void;
onTyping: (threadId: string, cb: (userId: string) => void) => () => void;
markRead: (threadId: string, interactionId: string) => void;
/** The kernel's receipt event carries no threadId, so this is a global stream; the thread hook
* filters to receipts for its own (mine) messages. */
onReceipt: (cb: (e: ReceiptHit) => void) => () => void;
react: (threadId: string, interactionId: string, emoji: string) => void;
onAnnotation: (threadId: string, cb: (e: AnnotationEvent) => void) => () => void;
}
const Ctx = createContext<MessengerSocket | null>(null);
export function useMessengerSocket(): MessengerSocket | null { return useContext(Ctx); }
const SHELL = isShellConfigured();
const toUi = (m: Message, myUserId?: string): UiMessage => ({
id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt,
mine: !!myUserId && m.senderId === myUserId,
...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}),
reactions: toReactions(m.annotations, myUserId),
});
export function MessengerSocketProvider({ children }: { children: ReactNode }) {
// SHELL is a build-time constant, so the branch is stable across renders (Rules-of-Hooks safe).
if (!SHELL) return <>{children}</>;
return <LiveSocketProvider>{children}</LiveSocketProvider>;
}
// A tiny per-thread listener registry, reused for messages / typing / receipts / annotations.
function makeRegistry<T>() {
const map = new Map<string, Set<(v: T) => void>>();
const add = (key: string, cb: (v: T) => void) => {
if (!map.has(key)) map.set(key, new Set());
map.get(key)!.add(cb);
return () => { map.get(key)?.delete(cb); };
};
const emit = (key: string, v: T) => map.get(key)?.forEach((cb) => cb(v));
return { add, emit };
}
function LiveSocketProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [ready, setReady] = useState(false);
const socketRef = useRef<MessageSocket | null>(null);
const myRef = useRef<string | undefined>(user?.id);
myRef.current = user?.id;
// One registry per event kind, keyed by threadId (plus a global message fan-out).
const msgReg = useRef(makeRegistry<UiMessage>()).current;
const anyMsg = useRef(new Set<(threadId: string, m: UiMessage) => void>()).current;
const typingReg = useRef(makeRegistry<string>()).current;
const receiptSet = useRef(new Set<(e: ReceiptHit) => void>()).current;
const annReg = useRef(makeRegistry<AnnotationEvent>()).current;
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const socket = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
socketRef.current = socket;
const offConnected = socket.onConnected(() => setReady(true));
const offMessage = socket.on("message", (m) => {
const ui = toUi(m, myRef.current);
msgReg.emit(m.threadId, ui);
anyMsg.forEach((cb) => cb(m.threadId, ui));
});
const offTyping = socket.on("typing", (e) => { if (e.userId !== myRef.current) typingReg.emit(e.threadId, e.userId); });
const offReceipt = socket.on("receipt", (e) => receiptSet.forEach((cb) => cb({ interactionId: e.interactionId, actorId: e.actorId })));
const offAnn = socket.on("annotation", (e) => annReg.emit(e.threadId, e));
socket.connect();
return () => {
offConnected(); offMessage(); offTyping(); offReceipt(); offAnn();
socket.disconnect(); socketRef.current = null; setReady(false);
};
}, [url, token, msgReg, anyMsg, typingReg, receiptSet, annReg]);
const openThread = useCallback(async (threadId: string): Promise<UiMessage[]> => {
const s = socketRef.current;
if (!s) return [];
const res = await s.openThread(threadId);
return res.history.map((m) => toUi(m, myRef.current));
}, []);
const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string }) => {
const s = socketRef.current;
if (!s) throw new Error("Not connected");
await s.sendMessage(threadId, content, opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined);
}, []);
const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]);
const onAnyMessage = useCallback((cb: (threadId: string, m: UiMessage) => void) => {
anyMsg.add(cb); return () => { anyMsg.delete(cb); };
}, [anyMsg]);
const onTyping = useCallback((threadId: string, cb: (userId: string) => void) => typingReg.add(threadId, cb), [typingReg]);
const onReceipt = useCallback((cb: (e: ReceiptHit) => void) => {
receiptSet.add(cb); return () => { receiptSet.delete(cb); };
}, [receiptSet]);
const onAnnotation = useCallback((threadId: string, cb: (e: AnnotationEvent) => void) => annReg.add(threadId, cb), [annReg]);
const sendTyping = useCallback((threadId: string) => socketRef.current?.typing(threadId), []);
const markRead = useCallback((threadId: string, interactionId: string) => { void socketRef.current?.markRead(threadId, interactionId); }, []);
const react = useCallback((threadId: string, interactionId: string, emoji: string) => { void socketRef.current?.react(threadId, interactionId, emoji); }, []);
return (
<Ctx.Provider value={{
ready, myUserId: user?.id, openThread, send, subscribe, onAnyMessage,
sendTyping, onTyping, markRead, onReceipt, react, onAnnotation,
}}>
{children}
</Ctx.Provider>
);
}
+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";
+210
View File
@@ -0,0 +1,210 @@
"use client";
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
// configured — the polished demo keeps working) OR the live be-crm data door
// (crm.team.*), behind one interface so the component is mode-agnostic.
//
// Live contract (be-crm, multi-role):
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
// cmd crm.team.member.setRoles { id, roleIds }
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
// cmd crm.team.member.remove { id }
// cmd crm.team.invitation.create/resend/revoke
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
} from "@/components/dashboard/team-data";
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
export type UiStatus = "active" | "away" | "offline";
export interface UiRole {
id: string; slug: string; name: string; color: string;
description: string; system: boolean; isOwner: boolean;
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
}
export interface UiMember {
id: string; principalId?: string; name: string; initials: string; email: string;
title: string; roleIds: string[]; gradient: string; status: UiStatus;
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
/** Accept token for the invite link (pending invites only; no email delivery yet). */
token?: string;
}
export interface TeamData {
live: boolean; loading: boolean; error: string | null;
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
removeMember: (id: string) => Promise<void>;
/** Create an invite. be-crm emails the invitee automatically. */
invite: (email: string, roleIds: string[]) => Promise<void>;
/** Resend an invite (regenerates the token + re-emails it). */
resendInvite: (id: string) => Promise<void>;
revokeInvite: (id: string) => Promise<void>;
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
refetch: () => void;
}
/* ---- helpers ------------------------------------------------------------ */
const GRADIENTS = [
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
/* ---- be-crm DTO types (subset used here) -------------------------------- */
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number; email?: string | null; firstName?: string | null; lastName?: string | null; displayName?: string | null }
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
if (Number.isNaN(then)) return iso;
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
/* ---- mock → UI ---------------------------------------------------------- */
const mockRole = (r: SeedRole): UiRole => ({
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
});
const mockMember = (m: SeedMember): UiMember => ({
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
});
const mockInvite = (i: SeedInvite): UiInvite => ({
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
});
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
function useMockTeam(): TeamData {
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
}, []);
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
setMembers((l) => l.map((m) => (m.id === id
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
: m)));
}, []);
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
const invite = useCallback(async (email: string, roleIds: string[]) => {
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
}, []);
const resendInvite = useCallback(async () => {}, []);
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
})));
}, []);
return { live: false, loading: false, error: null, members, roles, invites,
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
function useLiveTeam(): TeamData {
const { sdk } = useAppShell();
const { user } = useAuth();
const meId = user?.id;
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
})), [rolesQ.data]);
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
// Prefer the member's real name/email from their CRM registration; fall back to the
// ACE (for the current user), then job title, then a short principal id.
const name = m.displayName?.trim()
|| (isYou && user?.displayName)
|| m.jobTitle?.trim()
|| `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`;
const email = m.email || (isYou ? user?.email : undefined) || "";
return {
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
email,
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
status: (m.status === "active" ? "active" : "offline") as UiStatus,
lastActive: m.status === "active" ? "Active" : "—",
deals: m.openDeals, joined: relTime(m.joinedAt),
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
};
}), [membersQ.data, meId, user]);
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
})), [invitesQ.data]);
return {
live: true,
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
members, roles, invites,
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
updateMember: (id, patch) => cmd("crm.team.member.update", {
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
}),
removeMember: (id) => cmd("crm.team.member.remove", { id }),
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
setPermission: (roleId, permId, granted) =>
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useTeamData(): TeamData {
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
// so the same hook path runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveTeam() : useMockTeam();
}