39 Commits

Author SHA1 Message Date
maaz519 7bcd1a2a2d fix(inbox): reload mail content when switching items (+ filter refetch)
The appshell SDK useQuery only re-runs when the action changes, not the
variables — so switching mail items (same crm.mail.history action, new
threadId) never refetched and the reader kept the first thread's content
(only the subject, a direct prop, updated). Key MailReader by threadId to
remount it on switch (same pattern Messenger already uses for ThreadView).
Also force a refetch of crm.inbox.list when the filter changes, which hit
the same stale-variables bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:08:47 +05:30
maaz519 efd31d9293 fix(inbox): show sent mail in the unified inbox + fix compose attach button
- inbox merges crm.mail.list threads (kind MAIL) into crm.inbox.list items so
  sent/received mail actually appears in the one surface; mail shows in the
  Open view (it has no inbox work-item state) and opens in MailReader on click.
  Mail rows are read/reply only — no Done/Snooze/Archive (crm.inbox.transition
  doesn't apply to a mail thread).
- compose Attachments block was wrapped in <Field> (a <label>), so the label
  hijacked the Attach button's click via label→file-input association and the
  picker misfired. Use a plain div with the same field styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:53:33 +05:30
maaz519 a2a7e71f59 Merge pull request 'Feat/crm mail ui' (#30) from feat/crm-mail-ui into goutamnextflow
Reviewed-on: #30
2026-07-18 12:31:18 +00:00
maaz519 28907acf0e fix(dashboard): portal modals to .dash-root so they anchor to the viewport
A transformed/overflow panel ancestor was trapping the modal overlay's
position:fixed, so the modal rendered offset inside the messenger/inbox panel
and clipped (Group settings sat inside the thread pane; the compose modal's
top was cut at the panel edge). Portal the overlay up to .dash-root — above
those panels but still inside the scoped design-system CSS — so it centers on
the viewport and the 90vh cap works. Falls back to inline render if no
.dash-root is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:52:32 +05:30
maaz519 54add81187 fix(dashboard): keep tall modal footers on-screen
The modal body is a flex child with overflow-y:auto but had no min-height:0,
so it refused to shrink below content height and pushed the footer past the
90vh cap (visible on the tall Group settings modal — the Done button was
clipped). Add flex:1 1 auto + min-height:0 so the body scrolls and the
footer stays pinned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:46:55 +05:30
maaz519 4ad0decad0 feat(messenger): group settings UI — rename, member list, add/remove
- messenger-api: useGroupSettings(threadId) — members query + rename/add/
  remove commands + isAdmin (from the member roles); live + mock
- messenger: a settings (gear) button on group thread headers opens a
  GroupSettingsModal — editable name (admin), member list with role pills,
  admin-gated remove, and add-from-directory search
- controls are admin-gated in the UI; IIOS/OPA re-enforces server-side

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:43:52 +05:30
maaz519 0b433790a5 feat(inbox): attachments in mail reader + composer
- mail-api: MailMessage carries attachment; reply + sendInternal +
  sendExternal accept uploaded attachments; mock updated
- mail.tsx: MailAttachmentView (inline image or file chip via signed URL),
  StagedChip; attach button in the reply footer and the New Message
  composer (multi-file, up to 10); text optional when a file is attached
- messenger: file-chip icon uses paperclip (was an unknown name)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:31:22 +05:30
maaz519 7982c243c0 feat: attachments in Messenger — upload/preview images + files
- media-api: useUploadAttachment (presign → direct PUT to IIOS storage),
  useDownloadUrl (short-lived signed URL), isImage, 25MB cap
- socket + REST send now carry attachment {contentRef, mimeType, sizeBytes}
- composer: 📎 attach button, staged chip, send with attachment (text optional)
- MessageBubble renders AttachmentView (inline image or file chip), aligned
  to the message side; empty bubble suppressed for attachment-only messages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:17:45 +05:30
maaz519 1490fa3460 fix(messenger): reply focuses composer + quoted message jumps to original
- Clicking Reply now focuses the composer input (was requiring a manual click).
- A quoted message is clickable → scrolls to the original and flashes it briefly
  (was inert). Message elements register refs by id for the jump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:03:44 +05:30
maaz519 36f43d7d2a refactor(inbox): fold mail INTO the Inbox (one unified surface, no Mail tab)
The Inbox is the single communication surface — mentions, needs-reply, system
alerts, support updates AND the mail behind them, all in one list. Removed the
separate Mail tab.

- Inbox is now two-pane: the item list (crm.inbox.*) on the left; clicking an item
  tied to a thread opens its conversation (MailReader) on the right to read + reply.
  Non-threaded items (e.g. system alerts) show their detail. Item actions
  (Done/Snooze/Archive) work for every item.
- Compose new mail (in-app or email) from the Inbox header.
- mail.tsx trimmed to reusable MailReader + NewMailModal (no standalone tab);
  sidebar + dashboard reverted to no Mail entry.
- The existing work-item Inbox stays the notifier; this makes it the reader too.
  HTML bodies still render in a sandboxed iframe. tsc + next build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:43:48 +05:30
maaz519 46aa7a767c feat(mail): dedicated Mail reader in the CRM
A Mail surface in the Communication group, distinct from Messenger (chat) and the
work-item Inbox: read app-to-app + email messages (subject + rendered body), reply,
and compose (in-app to a person, or external to an email).

- mail-api.ts: crm.mail.list/history/reply + compose (crm.mail.internal/send), reusing
  the messenger directory for the people picker. Live via the AppShell SDK; mock in demo mode.
- mail.tsx: thread list + reader + reply + New-mail composer. HTML bodies render inside a
  SANDBOXED iframe (no scripts) — safe against untrusted email HTML.
- Wired into the sidebar (Communication) + dashboard switch.

The existing work-item Inbox stays as the notifier (IIOS's projector already flags new
mail there); this is where you read it. tsc + next build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:38:16 +05:30
maaz519 4b50d682e6 Merge pull request 'feat(messenger): reactions, replies, typing, seen ticks + UX fixes' (#29) from feat/messenger-ux into goutamnextflow
Reviewed-on: #29
2026-07-16 11:24:03 +00:00
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
29 changed files with 2890 additions and 198 deletions
+4
View File
@@ -1,2 +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}
+104 -5
View File
@@ -8,7 +8,8 @@
"name": "lynkeduppro-crm",
"version": "0.1.0",
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.5",
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
@@ -28,9 +29,9 @@
}
},
"node_modules/@abe-kap/appshell-sdk": {
"version": "0.2.5",
"resolved": "https://npm.pkg.github.com/download/@abe-kap/appshell-sdk/0.2.5/76bddffe21164d833c4a63adbb6409930dbc1356",
"integrity": "sha512-iHkdtkUs+sgiEY+2xbQDlm9WOtEbrbB0Z/YoiBZrXM64PT4+gcgFf3RPpKNSqqkVT6LzoL2mZjulFpSMCLw+fA==",
"version": "0.2.6",
"resolved": "https://npm.pkg.github.com/download/@abe-kap/appshell-sdk/0.2.6/d47293556f910a31ed189bbabab4c81022549744",
"integrity": "sha512-OAMY3Lhahdl8lSvsNT5uBZFZVeV2Nr0E/QDIqWErZj8Q4jbysHLNnF0/ra/yrmNfVSZg3wPtV5OADVG67r6jqA==",
"license": "MIT",
"dependencies": {
"@supabase/supabase-js": "^2.108.2",
@@ -1011,6 +1012,20 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@insignia/iios-contracts": {
"version": "0.1.0",
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-contracts/-/0.1.0/iios-contracts-0.1.0.tgz",
"integrity": "sha512-+ycbP8ORdwllMtn7dmxIL7lnDpDDkV+gHqT2OKJtMplMivjKU03gsuuEveQxnrE1CMvZ3fcSljmfMs10Cw131g=="
},
"node_modules/@insignia/iios-kernel-client": {
"version": "0.1.4",
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-kernel-client/-/0.1.4/iios-kernel-client-0.1.4.tgz",
"integrity": "sha512-pYBHH21TvgOOm0kp3ogvZx/iEzXLwmU23jHpDzIolh5FrqxCrcpUjRwLDkeYRTTkRVdoJAPTmPES/pU66iNoiA==",
"dependencies": {
"@insignia/iios-contracts": "0.1.0",
"socket.io-client": "^4.8.1"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1279,6 +1294,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.110.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.2.tgz",
@@ -3051,7 +3072,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3205,6 +3225,28 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io-client": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.21.0",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
@@ -6688,6 +6730,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7448,6 +7518,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+2 -1
View File
@@ -9,7 +9,8 @@
"lint": "eslint"
},
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.5",
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import "@/components/portal/portal.css";
import "./legal.css";
import { COMPANY } from "./legal-config";
export const metadata: Metadata = {
title: `${COMPANY} — Legal & SMS`,
description: `${COMPANY} SMS program opt-in, Privacy Policy and Terms of Service.`,
};
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<div className="legal-root">
<header className="legal-bar">
<Link href="/portal/login" aria-label={`${COMPANY} home`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt={`${COMPANY} logo`} />
</Link>
<nav>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
</nav>
</header>
{children}
<footer className="legal-foot">
<div>© {new Date().getFullYear()} {COMPANY}. All rights reserved.</div>
<div style={{ marginTop: 8 }}>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
</div>
</footer>
</div>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Shared business details for the public compliance pages (SMS opt-in, Privacy,
* Terms). Edit these in one place to keep every page and disclosure consistent.
* Replace SMS_SENDER with your live Twilio number once the campaign is approved.
*/
export const COMPANY = "LynkedUp Pro";
export const SUPPORT_EMAIL = "support@lynkeduppro.com";
export const PRIVACY_EMAIL = "privacy@lynkeduppro.com";
export const SMS_SENDER = "(555) 010-0100"; // TODO: replace with your Twilio A2P number
export const SITE_URL = "https://lynkeduppro-crmnew.vercel.app";
export const LAST_UPDATED = "July 13, 2026";
+125
View File
@@ -0,0 +1,125 @@
/* Public compliance pages (SMS opt-in, Privacy, Terms). Reuses the portal design
tokens (--bg, --text, --primary…) from portal.css for a consistent look, with
prose styling tuned for long-form legal copy. */
.legal-root {
min-height: 100vh;
background: var(--bg, #0b0c11);
color: var(--text, #f3f4f8);
font-family: var(--font-ui, "Inter", system-ui, sans-serif);
display: flex;
flex-direction: column;
}
.legal-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.09));
position: sticky;
top: 0;
background: color-mix(in srgb, var(--bg, #0b0c11) 88%, transparent);
backdrop-filter: blur(10px);
z-index: 5;
}
.legal-bar img { height: 30px; width: auto; }
.legal-bar nav { display: flex; gap: 20px; flex-wrap: wrap; }
.legal-bar nav a {
color: var(--muted, #a3a8b5);
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.legal-bar nav a:hover { color: var(--text, #f3f4f8); }
.legal-bar nav a.on { color: var(--primary-2, #fb923c); }
.legal-wrap { flex: 1; width: 100%; max-width: 820px; margin: 0 auto; padding: 44px 24px 80px; }
.legal-wrap h1 { font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 6px; }
.legal-wrap .updated { color: var(--faint, #6c7280); font-size: 13px; margin: 0 0 30px; }
.legal-wrap h2 {
font-size: 19px; font-weight: 700; margin: 34px 0 10px;
padding-top: 20px; border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
}
.legal-wrap h2:first-of-type { border-top: none; padding-top: 0; }
.legal-wrap p, .legal-wrap li { color: var(--muted, #a3a8b5); font-size: 15px; line-height: 1.7; }
.legal-wrap p { margin: 0 0 12px; }
.legal-wrap ul { margin: 0 0 12px; padding-left: 20px; }
.legal-wrap li { margin: 0 0 6px; }
.legal-wrap strong { color: var(--text, #f3f4f8); font-weight: 600; }
.legal-wrap a { color: var(--primary-2, #fb923c); }
/* Callout box for the mandatory SMS disclosures — makes them easy for a reviewer to find. */
.legal-callout {
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 16px 18px;
margin: 8px 0 18px;
}
.legal-callout p:last-child { margin-bottom: 0; }
.legal-foot {
border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
padding: 22px 24px;
text-align: center;
color: var(--faint, #6c7280);
font-size: 13px;
}
.legal-foot a { color: var(--muted, #a3a8b5); text-decoration: none; margin: 0 10px; }
.legal-foot a:hover { color: var(--text, #f3f4f8); }
/* SMS opt-in form */
.optin-card {
border: 1px solid var(--line, rgba(255, 255, 255, 0.09));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 22px;
margin-top: 22px;
}
.optin-field { margin-bottom: 16px; }
.optin-field label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 7px; color: var(--text, #f3f4f8); }
.optin-input {
width: 100%;
background: var(--ink, #121319);
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
border-radius: 10px;
padding: 13px 14px;
color: var(--text, #f3f4f8);
font-size: 15px;
font-family: inherit;
}
.optin-input:focus { outline: 2px solid var(--primary, #f97316); outline-offset: 1px; border-color: transparent; }
.optin-consent { display: flex; gap: 11px; align-items: flex-start; margin: 6px 0 4px; }
.optin-consent input { margin-top: 3px; width: 18px; height: 18px; flex: none; accent-color: var(--primary, #f97316); }
.optin-consent label { font-size: 13.5px; line-height: 1.6; color: var(--muted, #a3a8b5); }
.optin-submit {
width: 100%;
margin-top: 18px;
background: var(--primary, #f97316);
color: #fff;
border: none;
border-radius: 10px;
padding: 14px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.optin-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.optin-fine { font-size: 12.5px; color: var(--faint, #6c7280); line-height: 1.6; margin-top: 14px; }
.optin-done {
border: 1px solid color-mix(in srgb, var(--green, #34d399) 45%, transparent);
background: color-mix(in srgb, var(--green, #34d399) 12%, transparent);
color: #a7f3d0;
border-radius: var(--radius-sm, 13px);
padding: 18px 20px;
margin-top: 22px;
font-size: 14.5px;
line-height: 1.6;
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, PRIVACY_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Privacy Policy — ${COMPANY}`,
description: `How ${COMPANY} collects, uses, and protects your information, including SMS/text messaging.`,
};
export default function PrivacyPage() {
return (
<main className="legal-wrap">
<h1>Privacy Policy</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
This Privacy Policy explains how {COMPANY} (&quot;{COMPANY},&quot; &quot;we,&quot;
&quot;us&quot;) collects, uses, and protects your information when you use our
website, portal, and services, including our text message (SMS) program.
</p>
<h2>Information we collect</h2>
<ul>
<li><strong>Account information</strong> name, email address, and mobile phone number you provide when registering or signing in.</li>
<li><strong>Property and service information</strong> details you share to request roof inspections, estimates, and reports.</li>
<li><strong>Usage and device information</strong> log data, device and browser type, and similar technical information collected automatically.</li>
</ul>
<h2>How we use your information</h2>
<ul>
<li>To create and secure your account, including sending one-time verification codes (OTP).</li>
<li>To provide, maintain, and improve our services.</li>
<li>To communicate with you about your account, appointments, estimates, and support requests.</li>
<li>To comply with legal obligations and protect against fraud and abuse.</li>
</ul>
<h2>SMS / text messaging</h2>
<div className="legal-callout">
<p>
<strong>
We do not sell, rent, or share your mobile phone number, or your SMS opt-in
consent, with any third parties or affiliates for their marketing or
promotional purposes.
</strong>
</p>
<p>
Mobile information collected for the purpose of sending text messages is used
only to deliver the messages you opted into and is not shared with third parties
for marketing. We may share your number only with service providers (such as our
messaging platform) strictly to deliver the messages, and as required by law.
</p>
<p>
<strong>Message frequency varies</strong> verification codes are sent when you
request them (for example, each time you sign in or register), and account or
service notifications are occasional. <strong>Message and data rates may
apply</strong>, depending on your mobile carrier and plan.
</p>
<p>
You can opt out at any time by replying <strong>STOP</strong> to any message, and
get help by replying <strong>HELP</strong>. See our{" "}
<Link href="/terms">Terms of Service</Link> and the{" "}
<Link href="/sms-opt-in">SMS opt-in page</Link> for full program details.
</p>
</div>
<h2>How we share information</h2>
<p>
We do not sell your personal information. We share information only with service
providers who help us operate the service (for example, cloud hosting,
authentication, and messaging providers), and when required by law or to protect
our rights. As stated above, mobile phone numbers and SMS consent are never shared
with third parties or affiliates for marketing purposes.
</p>
<h2>Data retention and security</h2>
<p>
We retain personal information for as long as your account is active or as needed to
provide the service and meet legal obligations. We use administrative, technical,
and physical safeguards designed to protect your information; no method of
transmission or storage is completely secure.
</p>
<h2>Your choices and rights</h2>
<p>
You may access or update your account information at any time, opt out of SMS by
replying STOP, and request deletion of your account by contacting us. Depending on
your location, you may have additional rights under applicable privacy laws.
</p>
<h2>Contact us</h2>
<p>
Questions about this Privacy Policy? Email us at{" "}
<a href={`mailto:${PRIVACY_EMAIL}`}>{PRIVACY_EMAIL}</a> or{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { COMPANY, SMS_SENDER, SUPPORT_EMAIL } from "../legal-config";
/**
* Public SMS opt-in web form for A2P 10DLC campaign verification. Contains every
* element Twilio/CTIA require: phone field, an un-pre-checked consent checkbox, a
* description of the messages, frequency, rates disclaimer, HELP/STOP instructions,
* and links to the Terms and Privacy Policy.
*/
export default function SmsOptInPage() {
const [phone, setPhone] = useState("");
const [consent, setConsent] = useState(false);
const [done, setDone] = useState(false);
const digits = phone.replace(/\D/g, "");
const valid = digits.length >= 10 && consent;
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid) return;
// Records the opt-in. Consent + timestamp should be persisted server-side for your
// records; this confirmation is the user-facing acknowledgement.
setDone(true);
}
return (
<main className="legal-wrap">
<h1>{COMPANY} SMS Alerts</h1>
<p className="updated">Sign up to receive text messages from {COMPANY}.</p>
<p>
At {COMPANY}, we send text messages to help you use and secure your account. By
opting in below you&apos;ll receive <strong>account and service messages</strong>,
including:
</p>
<ul>
<li>One-time verification codes (OTP) when you sign in or register</li>
<li>Sign-in and security alerts</li>
<li>Roof inspection scheduling and status updates</li>
<li>Estimate and report notifications</li>
</ul>
{done ? (
<div className="optin-done" role="status">
<strong>You&apos;re signed up.</strong>{" "}We&apos;ll text account and service
messages to {phone}. Reply <strong>STOP</strong>{" "}at any time to unsubscribe,
or <strong>HELP</strong>{" "}for help.
</div>
) : (
<form className="optin-card" onSubmit={onSubmit}>
<div className="optin-field">
<label htmlFor="sms-phone">Mobile phone number</label>
<input
id="sms-phone"
className="optin-input"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="+1 (555) 010-0100"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
</div>
{/* Consent checkbox — MUST NOT be pre-checked (starts false). */}
<div className="optin-consent">
<input
id="sms-consent"
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<label htmlFor="sms-consent">
I agree to receive account and service text messages (including one-time
verification codes) from {COMPANY} at the number provided. Consent is not a
condition of purchase. Message frequency varies. Message and data rates may
apply. Reply HELP for help and STOP to unsubscribe. See our{" "}
<Link href="/terms">Terms of Service</Link> and{" "}
<Link href="/privacy">Privacy Policy</Link>.
</label>
</div>
<button className="optin-submit" type="submit" disabled={!valid}>
Yes, sign me up!
</button>
<p className="optin-fine">
Message frequency varies. Message and data rates may apply. Reply{" "}
<strong>HELP</strong>{" "}for help or <strong>STOP</strong>{" "}to cancel at any
time. Carriers are not liable for delayed or undelivered messages.
</p>
</form>
)}
<h2>Program details</h2>
<ul>
<li><strong>Program name:</strong> {COMPANY} Account &amp; Service Alerts</li>
<li><strong>Message types:</strong> verification codes (OTP), security alerts, appointment and inspection updates, estimate notifications</li>
<li><strong>Message frequency:</strong> varies codes are sent when you request them (e.g. each sign-in or registration); notifications are occasional</li>
<li><strong>Cost:</strong> Message and data rates may apply, per your mobile plan</li>
</ul>
<h2>How to opt out or get help</h2>
<p>
Reply <strong>STOP</strong>{" "}to any message to unsubscribe you&apos;ll get one
confirmation and no further texts. Reply <strong>HELP</strong>{" "}for help, or
contact us at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
{SMS_SENDER ? <> or {SMS_SENDER}</> : null}. Messages are sent from {COMPANY}.
</p>
<h2>Your privacy</h2>
<p>
<strong>
We do not sell, rent, or share your mobile phone number or your SMS opt-in
consent with any third parties or affiliates for their marketing purposes.
</strong>{" "}
See our <Link href="/privacy">Privacy Policy</Link>{" "}for full details.
</p>
</main>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Terms of Service — ${COMPANY}`,
description: `The terms governing your use of ${COMPANY}, including the SMS/text messaging program.`,
};
export default function TermsPage() {
return (
<main className="legal-wrap">
<h1>Terms of Service</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the {COMPANY}{" "}
website, portal, and services (the &quot;Service&quot;). By using the
Service you agree to these Terms.
</p>
<h2>Use of the Service</h2>
<p>
You must provide accurate information, keep your credentials secure, and use the
Service only for lawful purposes and in accordance with these Terms. You are
responsible for activity that occurs under your account.
</p>
<h2>Accounts and verification</h2>
<p>
To protect your account we may verify your identity, including by sending one-time
codes to your email or mobile number. You agree to receive these verification
messages as part of using the Service.
</p>
<h2>SMS / text messaging program</h2>
<div className="legal-callout">
<p>
By opting in on our <Link href="/sms-opt-in">SMS opt-in page</Link> or by
providing your mobile number and agreeing to receive texts, you consent to
receive <strong>account and service text messages</strong> from {COMPANY},
including one-time verification codes (OTP), security alerts, appointment and
inspection updates, and estimate notifications.
</p>
<p>
<strong>Message frequency varies.</strong> <strong>Message and data rates may
apply.</strong> Reply <strong>HELP</strong> for help or <strong>STOP</strong> to
unsubscribe at any time; after you send STOP we will send one confirmation and no
further messages. Carriers are not liable for delayed or undelivered messages.
</p>
<p>
We do not sell, rent, or share your mobile number or SMS consent with third
parties or affiliates for marketing. See our{" "}
<Link href="/privacy">Privacy Policy</Link> for details.
</p>
</div>
<h2>Acceptable use</h2>
<p>
You may not misuse the Service, attempt to access accounts or data you are not
authorized to access, interfere with the Service&apos;s operation, or use it to
send unlawful, harmful, or infringing content.
</p>
<h2>Disclaimers and limitation of liability</h2>
<p>
The Service is provided &quot;as is&quot; without warranties of any kind. To the
fullest extent permitted by law, {COMPANY} is not liable for any indirect,
incidental, or consequential damages arising from your use of the Service.
</p>
<h2>Changes to these Terms</h2>
<p>
We may update these Terms from time to time. Continued use of the Service after
changes take effect constitutes acceptance of the updated Terms.
</p>
<h2>Contact us</h2>
<p>
Questions about these Terms? Email us at{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+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 });
}
}
+1 -1
View File
@@ -437,7 +437,7 @@
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
.dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; }
.dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.dash-root .ds-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); }
/* ---- Toasts ---- */
+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>
);
}
+4
View File
@@ -15,6 +15,8 @@ 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";
export function Dashboard() {
@@ -45,6 +47,8 @@ export function Dashboard() {
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "inbox" ? <Inbox />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
+152
View File
@@ -0,0 +1,152 @@
"use client";
// ============================================================
// Inbox — the ONE unified communication surface. It lists everything
// IIOS surfaces for you (mentions, needs-reply, system alerts, support
// updates, …) AND the mail behind them: click an item tied to a thread
// and its conversation opens on the right to read + reply. Compose new
// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*.
// ============================================================
import { useEffect, useState } from "react";
import { Btn, Icon, PageHead, Pill, useToast } from "./ui";
import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api";
import { MailReader, NewMailModal } from "./mail";
const KIND_LABEL: Record<string, string> = {
MAIL: "Mail",
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);
const toast = useToast();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
useEffect(() => {
if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id);
}, [inbox.items, selectedId]);
const selected = inbox.items.find((i) => i.id === selectedId) ?? null;
return (
<div className="view">
<PageHead
eyebrow="Communication" title="Inbox" subtitle="Mentions, messages, system alerts and mail — all in one place" icon="bell"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New mail</Btn>}
/>
{!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={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
{/* Left — the unified item list */}
<aside style={{ width: 360, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
{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) => (
<ItemRow key={it.id} it={it} active={it.id === selectedId} onClick={() => setSelectedId(it.id)} />
))}
</aside>
{/* Right — read the mail behind the item, or the item detail */}
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "var(--bg)" }}>
{selected ? (
<Detail
it={selected}
onError={(m) => toast.push({ tone: "error", title: "Failed", desc: m })}
onDone={() => inbox.transition(selected.id, "DONE")}
onSnooze={() => inbox.transition(selected.id, "SNOOZED")}
onArchive={() => inbox.transition(selected.id, "ARCHIVED")}
/>
) : (
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
<Icon name="bell" size={38} /><p>Select an item to read</p>
</div>
)}
</section>
</div>
<NewMailModal
open={newOpen} onClose={() => setNewOpen(false)}
onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }}
onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })}
/>
</div>
);
}
function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) {
const isMention = it.kind === "MENTION";
return (
<button
onClick={onClick}
style={{
display: "flex", gap: 10, alignItems: "flex-start", width: "100%", textAlign: "left",
padding: "13px 16px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
}}
>
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
<Pill tone={isMention ? "warn" : "muted"}>{KIND_LABEL[it.kind] ?? it.kind}</Pill>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
</div>
{it.summary && <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 3, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.summary}</div>}
</div>
{it.state !== "OPEN" && <Pill tone="muted">{it.state.toLowerCase()}</Pill>}
</button>
);
}
function Detail({ it, onError, onDone, onSnooze, onArchive }: {
it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void;
}) {
return (
<>
{/* Item actions bar only for real inbox work-items. Mail isn't an inbox item
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
{it.state === "OPEN" && it.kind !== "MAIL" && (
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
<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>
)}
{it.threadId ? (
// A message/mail item → open the conversation to read + reply.
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
// variables — so switching items must remount MailReader to load the new thread's history.
<div style={{ flex: 1, minHeight: 0 }}>
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
</div>
) : (
// A non-threaded item (e.g. a system alert) → show its detail.
<div style={{ flex: 1, overflowY: "auto", padding: 22 }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>{it.title}</div>
{it.summary && <div style={{ color: "var(--muted)", fontSize: 14, lineHeight: 1.55 }}>{it.summary}</div>}
</div>
)}
</>
);
}
+231
View File
@@ -0,0 +1,231 @@
"use client";
// ============================================================
// Mail components used INSIDE the Inbox (not a separate tab).
// The Inbox is the one unified surface — mentions, system messages
// and mail all live there. These render the mail body + reply, and
// compose a new message. HTML bodies render in a sandboxed iframe.
// ============================================================
import { type CSSProperties, useEffect, useRef, useState } from "react";
import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui";
import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api";
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
const timeOf = (iso?: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
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",
};
function fmtBytes(n: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */
function MailAttachmentView({ att }: { att: MailAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
const label = att.filename || "Attachment";
if (isImage(att.mimeType)) {
return url
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target="_blank" rel="noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
</a>
);
}
/** A small staged-file chip shown in a composer before send, with a remove button. */
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
<Icon name="paperclip" size={14} />
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}></button>
</div>
);
}
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */
export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
const t = useMailThread(threadId);
const upload = useUploadAttachment();
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try { setStaged(await upload(file)); }
catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
async function reply() {
const text = draft.trim();
if ((!text && !staged) || sending) return;
const att = staged ?? undefined;
setDraft(""); setStaged(null); setSending(true);
try { await t.reply(text, att); }
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
finally { setSending(false); }
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<header style={{ padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{subject || "(no subject)"}</div>
</header>
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading</div>}
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages.</div>}
{t.messages.map((m) => (
<div key={m.interactionId} style={{ border: "1px solid var(--border)", borderRadius: 12, background: "var(--panel)", overflow: "hidden" }}>
<div style={{ padding: "7px 12px", borderBottom: "1px solid var(--border)", display: "flex", justifyContent: "space-between", fontSize: 12, color: "var(--muted)" }}>
<span>{m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""}</span>
<span>{timeOf(m.occurredAt)}</span>
</div>
{m.html
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
: m.text
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
: null}
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
</div>
))}
</div>
<footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
<div style={{ display: "flex", gap: 8 }}>
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
<input
value={draft} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
placeholder="Reply…" style={inputStyle}
/>
<Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
</div>
</footer>
</div>
);
}
/** Compose a new message — in-app (to a person) or external (to an email). */
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
const compose = useMailCompose(onSent);
const upload = useUploadAttachment();
const [mode, setMode] = useState<"internal" | "external">("internal");
const [recipient, setRecipient] = useState("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(files.map((f) => upload(f)));
setStaged((s) => [...s, ...uploaded].slice(0, 10));
} catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
async function send() {
if (!canSend) return;
setBusy(true);
try {
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
} catch (e) { onError((e as Error).message); }
finally { setBusy(false); }
}
return (
<Modal
open={open} onClose={onClose} title="New message" subtitle={mode === "internal" ? "To a team member or client (in-app)" : "To an email address"} icon="chat"
footer={<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" onClick={() => void send()} disabled={!canSend}>{busy ? "Sending…" : "Send"}</Btn>
</>}
>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
<Btn variant={mode === "internal" ? "primary" : "outline"} onClick={() => { setMode("internal"); setRecipient(""); }}>In-app</Btn>
<Btn variant={mode === "external" ? "primary" : "outline"} onClick={() => { setMode("external"); setRecipient(""); }}>Email</Btn>
</div>
{mode === "internal" ? (
<Field label="To (person)">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
<div style={{ maxHeight: 180, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}>
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No people found.</div>}
{filtered.map((p: MailPerson) => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: recipient === p.id ? "var(--panel-2)" : "transparent" }}>
<input type="radio" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<Avatar initials={(p.name.split(/\s+/).map((s) => s[0]).join("").slice(0, 2) || "?").toUpperCase()} size={26} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Pill tone="muted">{p.kind}</Pill>
</label>
))}
</div>
</Field>
) : (
<Field label="To (email)">
<input value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" style={inputStyle} />
</Field>
)}
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
Attach button's click via labelinput association and open the picker erratically. */}
<div className="ds-field">
<span className="ds-field-lbl">Attachments</span>
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
</div>
</div>
</Modal>
);
}
+550
View File
@@ -0,0 +1,550 @@
"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, useGroupSettings, type Membership, type UiAttachment, type UiConversation, type UiMember, type UiMessage, type UiPerson } from "@/lib/messenger-api";
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */
function AttachmentView({ att }: { att: UiAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
if (isImage(att.mimeType)) {
return url ? (
// eslint-disable-next-line @next/next/no-img-element
<a href={url} target="_blank" rel="noreferrer"><img src={url} alt="attachment" style={{ maxWidth: 240, maxHeight: 240, borderRadius: 10, display: "block", marginTop: 6, border: "1px solid var(--border)" }} /></a>
) : <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 12 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
</a>
);
}
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} directory={m.directory} 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, directory, onError }: { conv: UiConversation; nameOf: (id: string) => string; directory: UiPerson[]; 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 [flashId, setFlashId] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
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]);
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
function startReply(msg: UiMessage) {
setReplyTo(msg);
requestAnimationFrame(() => inputRef.current?.focus());
}
// Click a quoted message → scroll to the original and flash it.
function jumpTo(id: string) {
const el = msgRefs.current.get(id);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
setFlashId(id);
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
}
const uploadAttachment = useUploadAttachment();
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
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 onPickFile(file: File | undefined) {
if (!file) return;
setUploading(true);
try { setStaged(await uploadAttachment(file)); }
catch (e) { onError((e as Error).message); }
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; }
}
async function submit() {
const text = draft.trim();
if ((!text && !staged) || sending) return; // allow an attachment with no text
const parent = replyTo?.id;
const att = staged;
setDraft(""); setReplyTo(null); setStaged(null); setSending(true);
try {
await t.send(text, {
...(parent ? { parentInteractionId: parent } : {}),
...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}),
});
} catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); }
finally { setSending(false); }
}
const [settingsOpen, setSettingsOpen] = useState(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 style={{ flex: 1, minWidth: 0 }}>
<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>
{conv.membership === "group" && (
<button onClick={() => setSettingsOpen(true)} title="Group settings" style={{ ...actionBtnStyle, width: 34, height: 34 }}>
<Icon name="settings" size={18} />
</button>
)}
</header>
{conv.membership === "group" && settingsOpen && (
<GroupSettingsModal conv={conv} directory={directory} onClose={() => setSettingsOpen(false)} onError={onError} />
)}
<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}
flash={flashId === msg.id}
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
onReact={(emoji) => t.react(msg.id, emoji)}
onReply={() => startReply(msg)}
onQuoteClick={jumpTo}
/>
))}
<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>
)}
{staged && (
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)" }}>
<Icon name={isImage(staged.mimeType) ? "image" : "file"} size={16} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>{staged.filename}</span>
<span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(staged.sizeBytes)}</span>
<button onClick={() => setStaged(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Remove attachment">×</button>
</div>
)}
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)", alignItems: "center" }}>
<input ref={fileRef} type="file" hidden onChange={(e) => void onPickFile(e.target.files?.[0])} />
<button onClick={() => fileRef.current?.click()} disabled={uploading} title="Attach a file" style={{ ...actionBtnStyle, width: 38, height: 38, flexShrink: 0, opacity: uploading ? 0.5 : 1 }}>
{uploading ? "…" : "📎"}
</button>
<input
ref={inputRef}
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() && !staged)}>Send</Btn>
</footer>
</>
);
}
function MessageBubble({
msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
}: {
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
registerRef?: (el: HTMLElement | null) => void;
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
}) {
const [hover, setHover] = useState(false);
const [picker, setPicker] = useState(false);
return (
<div
ref={registerRef}
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",
borderRadius: 14, padding: 2, transition: "background 0.4s",
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
}}
>
{parent && (
<button
type="button"
onClick={() => parent.id && onQuoteClick?.(parent.id)}
title="Go to message"
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
>
<span style={{ opacity: 0.8 }}> {parent.text}</span>
</button>
)}
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
{(msg.text || !msg.attachment) && (
<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.attachment && (
<div style={{ marginTop: 4, display: "flex", justifyContent: msg.mine ? "flex-end" : "flex-start" }}>
<AttachmentView att={msg.attachment} />
</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>
);
}
/** Group settings: rename, member list with roles, add/remove — admin-gated (IIOS/OPA re-enforces). */
function GroupSettingsModal({ conv, directory, onClose, onError }: {
conv: UiConversation; directory: UiPerson[]; onClose: () => void; onError: (m: string) => void;
}) {
const g = useGroupSettings(conv.threadId);
const [name, setName] = useState(conv.subject ?? "");
const [savingName, setSavingName] = useState(false);
const [q, setQ] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => { setName(conv.subject ?? ""); }, [conv.subject]);
const memberIds = useMemo(() => new Set(g.members.map((m) => m.userId)), [g.members]);
const nameChanged = name.trim() && name.trim() !== (conv.subject ?? "").trim();
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
async function saveName() {
if (!nameChanged || savingName) return;
setSavingName(true);
try { await g.rename(name.trim()); }
catch (e) { onError((e as Error).message); }
finally { setSavingName(false); }
}
async function add(userId: string) {
setPendingId(userId);
try { await g.addMember(userId); }
catch (e) { onError((e as Error).message); }
finally { setPendingId(null); }
}
async function remove(userId: string) {
setPendingId(userId);
try { await g.removeMember(userId); }
catch (e) { onError((e as Error).message); }
finally { setPendingId(null); }
}
return (
<Modal
open onClose={onClose} title="Group settings" subtitle={conv.title} icon="settings"
footer={<Btn variant="ghost" onClick={onClose}>Done</Btn>}
>
<Field label="Group name">
<div style={{ display: "flex", gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!g.isAdmin}
placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} />
{g.isAdmin && <Btn onClick={() => void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}</Btn>}
</div>
{!g.isAdmin && <div style={{ color: "var(--muted)", fontSize: 12, marginTop: 4 }}>Only a group admin can rename the group.</div>}
</Field>
<Field label={`Members (${g.members.length})`}>
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 200, overflowY: "auto" }}>
{g.loading && g.members.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>Loading</div>}
{g.members.map((mem: UiMember) => (
<div key={mem.userId} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10 }}>
<Avatar initials={initialsOf(mem.displayName)} size={28} gradient={GROUP_GRAD} />
<span style={{ flex: 1 }}>{mem.displayName}</span>
{mem.role === "ADMIN" && <Pill tone="purple">admin</Pill>}
{g.isAdmin && mem.role !== "ADMIN" && (
<button onClick={() => void remove(mem.userId)} disabled={pendingId === mem.userId} title="Remove"
style={{ ...actionBtnStyle, width: 28, height: 28 }}><Icon name="trash" size={15} /></button>
)}
</div>
))}
</div>
</Field>
{g.isAdmin && (
<Field label="Add member">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 180, overflowY: "auto" }}>
{addable.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No one to add.</div>}
{addable.map((p) => (
<button key={p.id} onClick={() => void add(p.id)} disabled={pendingId === p.id}
style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: "transparent", border: "none", color: "var(--text)", textAlign: "left" }}>
<Avatar initials={initialsOf(p.name)} size={28} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Icon name="plus" size={16} />
</button>
))}
</div>
</Field>
)}
</Modal>
);
}
+44 -1
View File
@@ -6,6 +6,7 @@ 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";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
@@ -28,6 +29,13 @@ export const NAV_GROUPS: NavGroup[] = [
{ 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, messages, alerts and mail — all in one" },
],
},
{
title: "Workspace",
items: [
@@ -67,10 +75,45 @@ export const NAV_GROUPS: NavGroup[] = [
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
// Nav visibility by CRM permission. Dashboard + Profile are always visible (even to a
// 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 }) {
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) : "";
@@ -92,7 +135,7 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
</div>
<nav className="dash-nav">
{NAV_GROUPS.map((g, gi) => (
{visibleGroups.map((g, gi) => (
<div className="nav-section" key={gi}>
<div className="nav-group">{g.title}</div>
{g.items.map((it) => (
+9 -2
View File
@@ -301,8 +301,15 @@ export function TeamManagement() {
</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 sent to ${inv.email}.` }); }
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 () => {
@@ -326,7 +333,7 @@ export function TeamManagement() {
try {
await team.invite(email, roleIds);
setTab("invites");
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
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 }); }
}}
/>
+9 -2
View File
@@ -14,6 +14,7 @@ import {
createContext, useCallback, useContext, useEffect, useId,
useRef, useState, type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck,
Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send,
@@ -221,6 +222,11 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
}) {
const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
// not to a transformed/overflow panel ancestor (which would clip or offset the modal).
const [host, setHost] = useState<Element | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => { setHost(document.querySelector(".dash-root")); setMounted(true); }, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
@@ -228,8 +234,8 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
if (!open || !mounted) return null;
const overlay = (
<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">
@@ -247,6 +253,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
</div>
</div>
);
return host ? createPortal(overlay, host) : overlay;
}
/* ---------------------------------------------------------- */
+60 -20
View File
@@ -11,6 +11,7 @@ import {
import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP } from "@/lib/otp";
// Map the portal's social button ids to Supabase OAuth provider ids.
const OAUTH_PROVIDER: Record<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
@@ -33,6 +34,25 @@ export function LoginFlow() {
const [remember, setRemember] = useState(false);
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 {
@@ -42,7 +62,7 @@ export function LoginFlow() {
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
replace("otp");
push("otp"); // push (not replace) so Back returns to the identify screen
}
/* ---- history hash sync ---- */
@@ -86,7 +106,7 @@ export function LoginFlow() {
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
if (registered) { router.replace("/dashboard"); return; }
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");
@@ -134,7 +154,7 @@ export function LoginFlow() {
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
setOtpChannel("email");
replace("password");
push("password"); // push (not replace) so Back returns to the email screen, not off-page
return;
}
push("connecting");
@@ -153,7 +173,7 @@ export function LoginFlow() {
{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")} />
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} invited={invited} />
</>
)}
@@ -200,11 +220,11 @@ export function LoginFlow() {
)}
{step === "password" && account && (
<Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); 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 && (
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} />
)}
{step === "another" && account && (
@@ -268,17 +288,17 @@ export function LoginFlow() {
/* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: {
email: string; setEmail: (v: string) => void;
onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => 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);
return (
<div>
<div className="kicker">Welcome back</div>
<h1>Sign in to LynkedUp</h1>
<p className="sub">Drone inspections, AI estimates and insurance-ready reports all in one place.</p>
<div className="kicker">{invited ? "You're invited" : "Welcome back"}</div>
<h1>{invited ? "Sign in to join the team" : "Sign in to LynkedUp"}</h1>
<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 }}>
<SocialButtons onPick={onSocial} />
@@ -301,7 +321,10 @@ function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
</button>
</form>
)}
{isShellConfigured() && (
{/* Phone (SMS) sign-in needs a deliverable one-time code hidden while SMS is
mocked, since a faked code can't mint a real session. Email + password + Google
all work without SMS. */}
{isShellConfigured() && !MOCK_OTP && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div>
@@ -431,7 +454,14 @@ function OtpVerify({ account, email, initialChannel = "email", remember, setReme
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
await sendPhoneOtp(phone); setSent(true);
}
} catch (e) { setError((e as Error).message); }
} 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) {
@@ -450,12 +480,22 @@ function OtpVerify({ account, email, initialChannel = "email", remember, setReme
<div>
<StepBack onClick={onBack} />
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<p className="sub">{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}</p>
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
{!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
</div>
<p className="sub">
{!shell
? "Enter the 6-digit code we sent you to finish signing in."
: channel === "sms"
? "We'll text a 6-digit code to sign in."
: "We'll email you a 6-digit code to sign in."}
</p>
{/* In Shell mode the channel is fixed by how you signed in (email vs phone)
no toggle, so an email sign-in never shows a stray SMS option. */}
{!shell && (
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
</div>
)}
{shell && channel === "sms" && !sent ? (
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
+156 -159
View File
@@ -8,11 +8,12 @@ import {
PasswordStrength, LegalModal,
} from "./bits";
import {
countryCodes, relationshipOptions, addressCountries,
countryCodes, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP, DEMO_OTP } from "@/lib/otp";
type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
@@ -25,10 +26,14 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter();
const { register, setPassword, getUserEmail } = useAuth();
const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth();
const { sdk } = useAppShell();
const [step, setStep] = useState(0);
const [submitErr, setSubmitErr] = useState("");
// Verifying a phone via Supabase needs a live session. Onboarding already has one
// (Google OAuth); registration creates the account when leaving the Account step,
// then attaches + verifies the phone on it. `creating` guards the Account button.
const [creating, setCreating] = useState(false);
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
const [regAddr, setRegAddr] = useState<Addr>({});
@@ -43,16 +48,18 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
const [cc, setCc] = useState("+1");
const [phone, setPhone] = useState("");
const [pw, setPw] = useState("");
const [relationship, setRelationship] = useState("Customer");
const [alloeNo, setAlloeNo] = useState("");
const [alloeFirst, setAlloeFirst] = useState("");
const [alloeLast, setAlloeLast] = useState("");
const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false);
// verify step
const [emailVerified, setEmailVerified] = useState(false);
// verify step (email is trusted without an OTP — see verifyValid below)
const [phoneVerified, setPhoneVerified] = useState(false);
// 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.
@@ -63,20 +70,36 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS;
const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS;
const isSelf = relationship === "Customer" || relationship === "Owner";
const isEmployee = relationship === "Employee";
const country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{4,}$/.test(alloeNo);
const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
termsOk && privacyOk;
const step2Valid = emailVerified && phoneVerified;
// Email is already trusted in both flows (registration: Supabase auto-confirms on
// signup; onboarding: Google-verified), so the Verify step only gates on the phone.
const verifyValid = phoneVerified;
// Registration: create the auth account when leaving the Account step, so the phone
// can be attached + verified against a live session at the Verify step. Onboarding
// is already authenticated, so it just advances.
async function leaveAccountStep() {
if (onboard || !isShellConfigured() || sso) { setStep(1); return; }
setSubmitErr("");
setCreating(true);
try {
await register(email, pw);
setStep(1);
} catch {
setSubmitErr("We couldn't create that account. The email may already be registered.");
} finally {
setCreating(false);
}
}
async function finish() {
const finalEmail = sso?.email || email;
@@ -84,32 +107,27 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: finalEmail,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
};
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
if (isShellConfigured()) {
setSubmitErr("");
// 1) Auth account. Onboarding users are already authenticated via Google — set
// a password so email+password login works too. Everyone else registers now.
if (onboard) {
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
} else if (!sso) {
try { await register(finalEmail, pw); }
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
// 1) Auth account already exists at this point — registration created it when
// leaving the Account step; onboarding users signed in via Google. Onboarding
// additionally sets a password so email+password login works too (the phone was
// just verified against the same live session).
if (onboard && pw) {
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
}
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
// 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, ""),
persona: relationship,
isAllottee: isSelf,
...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }),
registeredAddress: regAddr,
mailingAddress: mailing,
mailingSameAsRegistered: mailingSame,
@@ -119,6 +137,17 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
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 */ }
@@ -130,34 +159,33 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
<Stepper current={step} labels={STEP_LABELS} />
{step === 0 && (
<StepAccount
onboard={onboard}
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")}
/>
)}
{step === 1 && !onboard && (
<StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{((onboard && step === 1) || (!onboard && step === 2)) && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
<StepAccount
onboard={onboard} creating={creating} invited={!!invitedEmail}
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/>
</>
)}
{step === 1 && (
<StepVerify
cc={cc} phone={phone} country={country}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{step === 2 && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(1)} onFinish={finish} />
</>
)}
</div>
@@ -171,11 +199,8 @@ function StepAccount(p: {
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];
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;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; invited?: boolean;
}) {
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
@@ -185,27 +210,32 @@ function StepAccount(p: {
const valid = EMAIL_RE.test(p.email);
return (
<div>
<h1>Create your account</h1>
<p className="sub">Enter your email to get started.</p>
<StepBack onClick={p.toLogin} />
<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 }}>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field">
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} 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>
{p.invited && <span className="faint" style={{ fontSize: 12 }}>This is the address you were invited with.</span>}
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button>
</form>
</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>
);
}
return (
<div>
{/* Non-onboarding users can step back to the email screen; onboarding starts
here (email came from Google), so there's nothing to go back to. */}
{!p.onboard && <StepBack onClick={() => setPhase("sso")} />}
<h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p>
@@ -259,47 +289,6 @@ function StepAccount(p: {
</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">{p.relationship === "Owner" ? "Owner" : "Customer"} account you own this property.</FlashNote></div>
) : (() => {
const isEmployee = p.relationship === "Employee";
const cfg = ({
Employee: { banner: "Registering as a LynkedUp Pro team member.", title: "Employee details", idLabel: "Employee ID", idPh: "EMP-12345" },
Contractor:{ banner: "Registering on behalf of the property owner.", title: "Contractor details", idLabel: "Contractor ID", idPh: "Alphanumeric ID" },
"Sub-Con": { banner: "Registering on behalf of the property owner.", title: "Sub-contractor details", idLabel: "Sub-contractor ID", idPh: "Alphanumeric ID" },
Vendor: { banner: "Registering on behalf of the property owner.", title: "Vendor details", idLabel: "Vendor ID", idPh: "Alphanumeric ID" },
} as Record<string, { banner: string; title: string; idLabel: string; idPh: string }>)[p.relationship]
?? { banner: "Registering on behalf of the property owner.", title: "Property Owner Details", idLabel: "Owner / Property ID", idPh: "Alphanumeric ID" };
return (
<div style={{ marginTop: 12 }}>
<FlashNote tone="info">{cfg.banner}</FlashNote>
<div className="dashed-block" style={{ marginTop: 12 }}>
<div className="row between" style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 13.5 }}>{cfg.title}</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">{cfg.idLabel}</label>
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder={cfg.idPh} />
</div>
{!isEmployee && (
<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 }}>
<label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
@@ -313,7 +302,9 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid || p.creating} onClick={p.onContinue}>
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <Icon name="arrowR" size={16} />}
</button>
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -323,10 +314,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — VERIFY ====================== */
/* ====================== STEP 1 VERIFY PHONE ======================
Email is already trusted (registration: Supabase auto-confirms on signup;
onboarding: Google-verified), so this step only verifies the phone via a real
SMS OTP: addPhone() Twilio texts a code verifyPhone() confirms it. Both
run against the live Supabase session established in the previous step. */
function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
cc: string; phone: string; country: typeof countryCodes[number];
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
@@ -334,12 +328,11 @@ function StepVerify(p: {
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1>
<p className="sub">Confirm both so we can secure your account.</p>
<h1>Verify your phone</h1>
<p className="sub">We&apos;ll text a one-time code to confirm your number. Your email is already verified.</p>
<div className="col gap-4" style={{ marginTop: 16 }}>
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
<PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
@@ -348,40 +341,53 @@ function StepVerify(p: {
);
}
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string;
type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
cc: string; country: typeof countryCodes[number]; initialPhone: string;
verified: boolean; onVerified: () => void;
}) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone);
const [via, setVia] = useState<"primary" | "wa">("primary");
const { addPhone, verifyPhone } = useAuth();
const [value, setValue] = useState(initialPhone);
const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false);
const primaryLabel = kind === "email" ? "Email" : "SMS";
const [otpError, setOtpError] = useState("");
const e164 = `${cc}${value.replace(/\D/g, "")}`;
function send() {
async function send() {
if (value.replace(/\D/g, "").length !== country.digits) { setState("invalid_mobile"); return; }
setState("sending");
setTimeout(() => {
if (kind === "email") {
if (!EMAIL_RE.test(value)) return setState("invalid_email");
if (value.includes("full")) return setState("mailbox_full");
if (value.includes("bo")) return setState("bounce_risk");
return setState("ok");
} else {
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile");
return setState("ok");
}
}, 700);
setOtpError("");
if (MOCK_OTP) { setState("sent"); return; } // demo: skip Twilio entirely
try {
await addPhone(e164); // Supabase → Twilio sends the SMS OTP
setState("sent");
} catch {
setState("send_error");
}
}
async function submit(code: string) {
setOtpError("");
if (MOCK_OTP) {
if (code === DEMO_OTP) onVerified();
else setOtpError(`Demo mode — enter ${DEMO_OTP} to verify.`);
return;
}
try {
await verifyPhone(e164, code); // confirm the OTP (phone_change)
onVerified();
} catch {
setOtpError("That code didn't match. Check the SMS and try again.");
}
}
if (verified) {
return (
<div className="vchannel verified">
<div className="row between">
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile</span>
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{cc} {value}</p>
</div>
);
}
@@ -389,43 +395,34 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
return (
<div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile number</span>
</div>
{kind === "email" ? (
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" />
) : (
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
{cc}
</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
)}
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
{cc}
</span>
<input className="input grow" inputMode="numeric" value={value} disabled={state === "sent"} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
<div className="row between" style={{ marginTop: 12 }}>
<div className="seg">
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button>
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
<span className="faint" style={{ fontSize: 12 }}>{MOCK_OTP ? "Demo verification (no SMS sent)." : "Standard SMS rates may apply."}</span>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
{state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
</button>
</div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>}
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full try another email.</FlashNote></div>}
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
{state === "sent" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>{MOCK_OTP ? `Demo mode — enter ${DEMO_OTP} to verify (SMS temporarily disabled).` : `Code sent to ${cc} ${value} by SMS.`}</p>}
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
{state === "send_error" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Couldn&apos;t send the code. Check the number and try again.</FlashNote></div>}
{state === "ok" && (
{state === "sent" && (
<div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
<OtpBoxes onComplete={submit} error={!!otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
</div>
)}
</div>
+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;
+95
View File
@@ -0,0 +1,95 @@
"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, useEffect, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import type { MailThread } from "./mail-api";
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 } : {});
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
const showMail = !state || state === "OPEN";
const mq = useQuery<MailThread[]>("crm.mail.list", {});
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
const refetchInbox = q.refetch;
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
const items = useMemo<UiInboxItem[]>(() => {
const inboxItems = q.data ?? [];
const mailItems: UiInboxItem[] = showMail
? (mq.data ?? []).map((t) => ({
id: `mail:${t.threadId}`,
kind: "MAIL",
state: "OPEN" as InboxState,
title: t.subject || "(no subject)",
...(t.lastMessage ? { summary: t.lastMessage } : {}),
priority: t.unread > 0 ? "HIGH" : "LOW",
threadId: t.threadId,
createdAt: t.lastAt ?? "",
}))
: [];
// Newest first; mail and inbox items interleave by time.
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
}, [q.data, mq.data, showMail]);
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 || (showMail && mq.loading),
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
error: q.error?.message ?? null,
items,
transition,
refetch: () => { q.refetch(); mq.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: () => {} };
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
// Mail data layer. A dedicated Mail reader over the be-crm data door (crm.mail.*), distinct from
// the Messenger chat and from the work-item Inbox. Live via the AppShell SDK; a small mock keeps the
// demo working before the Shell + be-crm are connected.
//
// Live contract (be-crm):
// query crm.mail.list {} -> MailThread[]
// query crm.mail.history { threadId } -> MailMessage[]
// cmd crm.mail.reply { threadId, content } -> { interactionId, threadId }
// cmd crm.mail.internal { recipientUserId, subject?, text?, html? } -> { threadId }
// cmd crm.mail.send { target, subject?, text?, html?, mirrorToUserId? } -> { commandId }
// query crm.messenger.directory { kind, limit } -> people to compose to (reused)
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface MailThread {
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
export interface MailMessage {
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
}
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
const SHELL = isShellConfigured();
/* ============================ Thread list ============================ */
export interface MailListData {
live: boolean; loading: boolean; error: string | null; threads: MailThread[]; refetch: () => void;
}
export function useMailThreads(): MailListData {
if (SHELL) {
const q = useQuery<MailThread[]>("crm.mail.list", {});
return { live: true, loading: q.loading, error: q.error?.message ?? null, threads: q.data ?? [], refetch: q.refetch };
}
return { live: false, loading: false, error: null, threads: MOCK_THREADS, refetch: () => {} };
}
/* ============================ One thread ============================ */
export interface MailThreadData {
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
}
export function useMailThread(threadId: string | null): MailThreadData {
if (SHELL) return useLiveThread(threadId);
return useMockThread(threadId);
}
function useLiveThread(threadId: string | null): MailThreadData {
const { sdk } = useAppShell();
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
if (!threadId) return;
await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
q.refetch();
}, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
}
/* ============================ Compose ============================ */
export interface ComposeData {
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
}
export function useMailCompose(onSent: () => void): ComposeData {
if (SHELL) {
const { sdk } = useAppShell();
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
onSent();
}, [sdk, onSent]);
const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
onSent();
}, [sdk, onSent]);
return { directory, sendInternal, sendExternal };
}
return { directory: MOCK_PEOPLE, sendInternal: async () => onSent(), sendExternal: async () => onSent() };
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/* ============================ Mock (demo mode) ============================ */
const now = () => new Date().toISOString();
const MOCK_PEOPLE: MailPerson[] = [
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
];
const MOCK_THREADS: MailThread[] = [
{ threadId: "mt_1", subject: "Welcome to the Founders Club", participants: ["you", "system"], unread: 1, lastMessage: "Thanks for joining…", lastAt: now() },
{ threadId: "mt_2", subject: "Storm response — East side", participants: ["you", "pp_sofia"], unread: 0, lastMessage: "Crew rolling out at 7", lastAt: now() },
];
function useMockThread(threadId: string | null): MailThreadData {
const [extra, setExtra] = useState<MailMessage[]>([]);
const base: MailMessage[] = threadId === "mt_1"
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
: threadId === "mt_2"
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
: [];
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
}, []);
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
// Media (attachment) helpers over the be-crm data door (crm.media.*). The browser transfers bytes
// DIRECTLY to IIOS storage via the signed URLs — be-crm only mints them. Used by Messenger + Mail.
import { useCallback } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
export interface UploadedAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
export const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
export function isImage(mime?: string | null): boolean {
return !!mime && mime.startsWith("image/");
}
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
export function useUploadAttachment() {
const { sdk } = useAppShell();
return useCallback(async (file: File): Promise<UploadedAttachment> => {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = file.type || "application/octet-stream";
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
}, [sdk]);
}
/** Mint a short-lived signed URL to display/download an attachment by its contentRef. */
export function useDownloadUrl() {
const { sdk } = useAppShell();
return useCallback(async (contentRef: string, mime?: string): Promise<string> => {
const { url } = (await sdk.command("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) })) as { url: string };
return url;
}, [sdk]);
}
+435
View File
@@ -0,0 +1,435 @@
"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 UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
export interface UiMessage {
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
parentInteractionId?: string | null;
attachment?: UiAttachment;
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; attachment?: UiAttachment }) => Promise<void>;
react: (interactionId: string, emoji: string) => void;
typingUserIds: string[];
seenIds: Set<string>;
refetch: () => void;
}
export interface UiMember { userId: string; displayName: string; role: string }
export interface GroupSettingsData {
loading: boolean; error: string | null;
members: UiMember[];
isAdmin: boolean;
rename: (subject: string) => Promise<void>;
addMember: (userId: string) => Promise<void>;
removeMember: (userId: string) => Promise<void>;
refetch: () => void;
}
export function useMessengerData(): MessengerData {
return SHELL ? useLiveMessenger() : useMockMessenger();
}
export function useThread(threadId: string): ThreadData {
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
}
export function useGroupSettings(threadId: string): GroupSettingsData {
return SHELL ? useLiveGroupSettings(threadId) : useMockGroupSettings(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; attachment?: UiAttachment }) => {
if (socket && socketReady) {
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
} else {
// REST fallback carries the attachment ref too; a socket reconnect will replace with the live copy.
const m = (await sdk.command("crm.messenger.send", { threadId, content, ...(opts?.attachment ? { attachment: opts.attachment } : {}) })) 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 useLiveGroupSettings(threadId: string): GroupSettingsData {
const { sdk } = useAppShell();
const { user } = useAuth();
const q = useQuery<UiMember[]>("crm.messenger.members", { threadId });
const members = useMemo(() => q.data ?? [], [q.data]);
const isAdmin = useMemo(() => members.some((m) => m.userId === user?.id && m.role === "ADMIN"), [members, user?.id]);
const rename = useCallback(async (subject: string) => {
await sdk.command("crm.messenger.group.rename", { threadId, subject });
q.refetch();
}, [sdk, threadId, q]);
const addMember = useCallback(async (userId: string) => {
await sdk.command("crm.messenger.participant.add", { threadId, userId });
q.refetch();
}, [sdk, threadId, q]);
const removeMember = useCallback(async (userId: string) => {
await sdk.command("crm.messenger.participant.remove", { threadId, userId });
q.refetch();
}, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, members, isAdmin, rename, addMember, removeMember, 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,
};
}
function useMockGroupSettings(threadId: string): GroupSettingsData {
useMockSubscription();
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
const t = MOCK_STORE.get(threadId);
const members: UiMember[] = (t?.participants ?? []).map((id) => ({
userId: id,
displayName: id === "me" ? "You" : (nameById[id] ?? `User ${shortId(id)}`),
role: id === "me" ? "ADMIN" : "MEMBER",
}));
const rename = useCallback(async (subject: string) => {
const th = MOCK_STORE.get(threadId);
if (th) { th.subject = subject; notifyMock(); }
}, [threadId]);
const addMember = useCallback(async (userId: string) => {
const th = MOCK_STORE.get(threadId);
if (th && !th.participants.includes(userId)) { th.participants = [...th.participants, userId]; notifyMock(); }
}, [threadId]);
const removeMember = useCallback(async (userId: string) => {
const th = MOCK_STORE.get(threadId);
if (th) { th.participants = th.participants.filter((p) => p !== userId); notifyMock(); }
}, [threadId]);
return { loading: false, error: null, members, isAdmin: true, rename, addMember, removeMember, refetch: notifyMock };
}
+149
View File
@@ -0,0 +1,149 @@
"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; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => 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 } : {}),
...(m.attachment ? { attachment: { contentRef: m.attachment.contentRef, mimeType: m.attachment.mimeType, sizeBytes: m.attachment.sizeBytes } } : {}),
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; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => {
const s = socketRef.current;
if (!s) throw new Error("Not connected");
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
await s.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : 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";
+15 -7
View File
@@ -38,6 +38,8 @@ export interface UiMember {
}
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 {
@@ -46,7 +48,9 @@ export interface TeamData {
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>;
@@ -68,9 +72,9 @@ const initialsOf = (name: string) =>
/* ---- 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 }
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 }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
@@ -153,12 +157,16 @@ function useLiveTeam(): TeamData {
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
const name = isYou && user?.displayName
? user.displayName
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
// 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: isYou && user?.email ? user.email : m.principalId,
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" : "—",
@@ -169,7 +177,7 @@ function useLiveTeam(): TeamData {
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),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
})), [invitesQ.data]);
return {