107 Commits

Author SHA1 Message Date
Mayur Shinde e10ffc25a1 Merge origin/goutamnextflow into feat/leads
Resolve conflicts in dashboard.tsx and dashboard.css:
- Keep goutamnextflow's SDK inbox/messenger, settings, notifications,
  realtime provider and smart gallery (the old messenger/inbox files
  were deleted on that branch)
- Graft the feat/leads additions (Leads, Verify views + their CSS) on top

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:50:27 +05:30
Mayur Shinde 704ea853e6 document for leads backend req 2026-07-23 18:40:00 +05:30
maaz519 041ad0e44a feat(dashboard): offline web push — service worker, subscribe flow, deep-link
Notifications phase 2: a /push-sw.js service worker shows a system notification
on push and, on click, focuses an open CRM tab (or opens one) and deep-links to
the thread. usePushNotifications registers the SW, requests permission, subscribes
with IIOS's VAPID key, and stores the subscription via the be-crm door. The topbar
bell becomes a real enable/disable control (hidden when push is unsupported/demo).
Dashboard listens for the SW's notif-click message + a ?thread= deep link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:38:14 +05:30
maaz519 be50d53c50 feat(dashboard): live notifications phase 1 — presence, live unread, in-app toasts
Shared RealtimeProvider opens ONE dashboard-wide IIOS message socket reused by
the messenger tab and the new NotificationCenter. CrmMessagingAdapter gains
setFocus (drives presence/push suppression) and subscribeActivity (joins all
of the caller's threads, fans out incoming messages). NotificationCenter shows
a clickable toast on new messages when you're not on the messenger tab and
deep-links to the conversation. Pulls @insignia/iios-messaging-ui@0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:26:17 +05:30
maaz519 904d003d32 feat(search): global conversation search in the topbar with deep-link nav
Topbar search box → debounced crm.search → dropdown of hits (chat/mail, highlighted
snippet). Clicking a hit switches to the right tab and focuses the exact thread:
mail → Inbox, chat → Messenger (via the SDK's new focusThreadId, 0.1.6). Snippet HTML
is escaped except the <em> highlight. Demo mode searches an in-memory set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:07:26 +05:30
maaz519 c874a726ef feat(settings): Email (SMTP) BYO card in Settings → Integrations
Replace the SMTP 'coming soon' stub with a real card: host/port/SSL/user/password/
from-address/from-name → crm.settings.smtp.configure, masked status from
crm.settings.smtp.status. Mirrors the Twilio SMS card; demo mode stores hints locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:57:45 +05:30
kaushik 03b685b194 Merge branch 'goutamnextflow' of https://git.lynkedup.cloud/Goutam/lynkeduppro-crm into goutamnextflow 2026-07-23 13:21:40 +05:30
maaz519 06a689bbd3 feat(messenger): wire member management (add/remove/rename) + pull SDK 0.1.5
CrmMessagingAdapter implements addMember/removeMember/renameConversation over
crm.messenger.participant.add/remove + group.rename, powering the SDK's new
group/channel settings panel. listMembers maps customer role too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:54:47 +05:30
kaushik e9914caa30 merge: bring gallery frontend branch up to date with goutamnextflow
goutamnextflow adopts @insignia/iios-messaging-ui (drops the bespoke
messenger/inbox/mail) and adds Org Settings → Integrations. Resolve against
the Smart Gallery work:
- dashboard.tsx: route messenger→MessengerSdk, inbox→InboxSdk, add settings→
  Settings (goutamnextflow) AND keep gallery→SmartGallery (ours). Drop the
  now-deleted Messenger/Inbox imports.
- next.config.ts / package.json: union transpilePackages + deps (keep
  @photo-gallery/sdk + ML deps AND @insignia/iios-messaging-ui).
- dashboard.css: keep both appended blocks (.gal* gallery + .settings-* org
  settings).

Gallery integrates with zero errors. Vendored @photo-gallery/sdk re-synced
(0-diff). Verified: tsc clean + `next build` compiles the whole merged app
(gallery AI routes + dashboard). The new @insignia/iios-messaging-ui private
dep is stubbed locally (see .claude/dev-scripts); it resolves on CI/Vercel.
2026-07-23 08:33:03 +05:30
kaushik 2613c767a6 feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations 2026-07-23 07:39:01 +05:30
maaz519 2c38ce811f feat(messenger): attachments in chat — upload, send, and render
CrmMessagingAdapter now implements upload() (presign-upload → PUT → presign-download
→ Attachment with contentRef), so the composer's attach button appears; send()
forwards the attachment to the socket; and incoming messages (history + realtime)
resolve a presigned display URL. Pull SDK 0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:06:06 +05:30
maaz519 0c2b7ef132 chore(crm): pull SDK 0.1.3 (mail card + emoji picker fixes) 2026-07-23 02:52:18 +05:30
maaz519 e04f1e6086 chore(crm): pull SDK 0.1.2 (uploading-progress chip) 2026-07-23 01:04:52 +05:30
maaz519 ae27830893 fix(inbox/messenger): directory limit 100 (was 400ing) + attachment download
- crm.messenger.directory limit 200 -> 100 (be-crm caps at 100, was 400 Bad
  Request) in both the inbox + messaging adapters.
- CrmInboxAdapter.downloadAttachment via crm.media.presignDownload, so mail
  attachments are downloadable. Pull SDK 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:53:08 +05:30
maaz519 84cb5c66b5 Merge pull request 'Feat/adopt messaging sdk' (#31) from feat/adopt-messaging-sdk into goutamnextflow
Reviewed-on: #31
2026-07-22 19:06:48 +00:00
maaz519 1fc1a28d96 chore(deps): consume @insignia/iios-messaging-ui from the Gitea registry
The SDK is now published to git.lynkedup.cloud (@insignia scope); switch from the
local file: tarball to ^0.1.0 so prod/CI installs resolve it. Install auth uses
GITEA_TOKEN (read:package), same as @insignia/iios-kernel-client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:29:18 +05:30
maaz519 0c7b3231ef chore(crm): pull SDK build with un-stretched compose/create buttons 2026-07-22 19:27:27 +05:30
maaz519 7a312a1ace feat(messenger): expose adapter directory() for DM/group picker + pull SDK build
CrmMessagingAdapter.directory() now returns the org people list (Person[]) for the
new 'New message' picker; internal name-resolution uses directoryMap(). The DM/group
create path rides the existing crm.messenger.conversation.open door (dedupe + group).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:23:57 +05:30
maaz519 eb7e1f7995 chore(crm): pull SDK build with body-portaled compose modal 2026-07-22 18:19:25 +05:30
maaz519 988b46f6e7 refactor(messaging): drop bespoke messenger/inbox/mail + wrapper page headers
Delete the now-dead pre-SDK stack (messenger/inbox/mail components + their
*-api libs + messenger-socket) — both tabs render the @insignia/iios-messaging-ui
SDK. Remove the redundant <PageHead> from the messenger/inbox SDK wrappers (the
Topbar already shows the title); the SDK itself renders no header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:55:10 +05:30
maaz519 8e2fe315ce feat(settings): Org Settings → Integrations with BYO Twilio SMS
New Settings tab renders an Integrations section; the SMS (Twilio) card lets a
tenant admin connect their own Twilio account (Account SID / write-only Auth Token
/ E.164 from-number) via crm.settings.sms.configure, and shows the masked status
(from-number + SID last-4) from crm.settings.sms.status. Demo mode stores hints
locally. Email (SMTP) shown as the next provider on the same registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:43:53 +05:30
Goutam faade49b13 feat(leads): source-driven fields + full-form wizard
- Add Lead source dropdown options (Door Knock, Referral, Storm Chase,
  Mailer/Postcard, Sign Call, Insurance Agent Referral, Repeat Customer,
  Social Media, Other)
- Quick form: Referral shows a referral-note textarea; Door Knock shows a
  canvasser search (by name or email) with picker + chip
- Full form is now a Next/Back wizard ending in Create Lead
- Reps gain email for canvasser search

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:08:47 +05:30
maaz519 462eb7f036 feat(inbox): wire mail attachments through CrmInboxAdapter
uploadAttachment presigns via crm.media.presignUpload + PUTs bytes to IIOS
storage; mailReply/composeInternal/composeExternal forward attachment refs
to crm.mail.reply/internal/send (server already supported them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:05:14 +05:30
maaz519 facc50e82f feat(inbox): migrate CRM Inbox to @insignia/iios-messaging-ui SDK
Replace the bespoke in-CRM inbox with the SDK's <Inbox>, driven by a new
CrmInboxAdapter over the be-crm data door (crm.inbox.* + crm.mail.*).
Demo mode falls back to the SDK MockInboxAdapter. Maps --miu-* tokens for
.miu-inbox to the CRM theme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:54:03 +05:30
maaz519 413f8d43b6 feat(messenger): channels + @mentions in the CRM SDK adapter
CrmMessagingAdapter now implements the full SDK contract:
- browseChannels/createChannel/leaveChannel over the be-crm channel door
  (crm.messenger.channel.*); joinChannel is a governed public self-join over
  the socket (openThread) — the BFF has no join verb, OPA enforces it
- listMembers over crm.messenger.members → drives @mention autocomplete
- send forwards mentions[] on the socket path (→ IIOS MENTION inbox items)

So the CRM messenger (rendered by the SDK) now gets sectioned Channels/DMs,
browse+join+create, and @mention autocomplete/highlight — no embedded code.
(SDK re-packed to the local tarball; publish for deploy.) tsc + next build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:35:41 +05:30
maaz519 cd8015ada8 feat(messenger): socket realtime in the CRM SDK adapter (parity with old messenger)
CrmMessagingAdapter is now hybrid: BFF for the conversation list, thread
creation, and directory (server-side tenancy); the IIOS MessageSocket
(delegated crm.messenger.realtime token) for everything live — history+join,
send, typing, read receipts, and reaction annotations. Falls back to the 4s
poll only when no socket is available.

- messenger-sdk useRealtimeSocket() opens the MessageSocket from the BFF token
  and passes it to the adapter (adapter rebuilds live once connected)
- socket path maps senderId (userId space) → actorId so 'mine'/seen work
  consistently, killing the actor-vs-user ambiguity in the poll fallback
- reaction annotations tracked per message and re-emitted as full sets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:49:43 +05:30
maaz519 e70904a219 feat(messenger): CRM consumes @insignia/iios-messaging-ui instead of embedding
The messenger tab now renders the shared SDK's <Messenger>, not the bespoke
in-CRM implementation. The CRM only supplies transport + theme:

- CrmMessagingAdapter implements the SDK's MessagingAdapter over the be-crm
  data door (crm.messenger.conversation.list/open, history, send, directory);
  live updates via a 4s poll for now (socket realtime is a follow-up that
  reuses messenger-socket.tsx). Tenancy/auth stay server-side.
- messenger-sdk.tsx wires MessagingProvider + adapter (live = CrmMessagingAdapter,
  demo = the SDK MockAdapter), themed by mapping --miu-* tokens to the CRM
  design system.
- dashboard renders <MessengerSdk/>; the old messenger.tsx is left in place
  for rollback and will be deleted once this is proven live.

NOTE: the SDK is installed from a local tarball (file:) for verification since
it isn't published yet. For deploy: publish @insignia/iios-messaging-ui to the
registry and change the dep to a version range. transpilePackages covers ESM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:37:47 +05:30
Goutam 102b66dfca Add Leads pipeline and Lead Verification workspace pages
Leads: card board with status stats, search + status tabs, a rich
lead-detail popup (contact, property, job, insurance, assignment,
storm banner) and a New Lead intake form (Quick / Full Form) with
multi-phone/email rows, site-photo dropzone and urgency picker.

Lead Verification: clickable stat tiles, status/source/assignee
filters, a full verification table, a detail popup with an activity
timeline, and a per-row actions menu (verify / reassign / pending…).

Both wired into the dashboard view switcher and styled with the
existing orange brand + glass-card system (dark & light themes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:21:42 +05:30
maaz519 036ed84255 feat(media): infer MIME from extension for empty-type uploads (.md, .html)
Browsers report an empty file.type for files with no OS-registered MIME (esp.
.md), which then went up as application/octet-stream and got rejected. Infer
the type from the extension for the text types IIOS allows (md/markdown/html/
htm/txt/csv) so those attachments upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:29:21 +05:30
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
tanweer919 3c6190d16a Merge pull request 'fix(onboarding): reliably prefill verified email (SDK 0.2.5)' (#15) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:36:29 +00:00
tanweer919 6ba00bfbf9 fix(onboarding): reliably prefill the verified email (SDK 0.2.5)
getUserEmail() relied on getSession(), which returns null here because the
Supabase session is intentionally never persisted to storage — so the
onboarding email field stayed empty and Continue was blocked. SDK 0.2.5
captures the email in memory from the token-exchange response instead.
Bump @abe-kap/appshell-sdk to ^0.2.5.
2026-07-13 04:06:13 +05:30
tanweer919 5e2fa574bd Merge pull request 'fix(onboarding): gate direct access behind OAuth handoff' (#14) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:32:35 +00:00
tanweer919 d79da8cd6a fix(onboarding): gate direct access behind the OAuth handoff
/portal/onboarding is only a step in the Google sign-in flow. Redirect to
login unless the login page's onboard_email handoff hint is present (and the
session is authenticated), instead of showing an empty form on direct/typed
URL access. Hold rendering until the check passes to avoid a form flash, and
clear the hint once onboarding completes so it can't be reused.
2026-07-13 04:01:01 +05:30
tanweer919 10141806dc Merge pull request 'fix(onboarding): prefill verified email; SDK 0.2.4' (#13) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:26:05 +00:00
tanweer919 08ef85869f fix(onboarding): prefill verified email in a disabled field; bump SDK 0.2.4
- Onboarding reads the OAuth email (session-storage hint + live Supabase
  session via getUserEmail) so Continue is no longer disabled.
- Replace the empty 'Signed in as' banner with a disabled, prefilled
  email field (email is fixed — Google-verified).
- Stash the verified email at OAuth callback time for onboarding prefill.
- Bump @abe-kap/appshell-sdk to ^0.2.4 (getUserEmail via getSession).
2026-07-13 03:55:05 +05:30
tanweer919 619ec4c9b1 Merge pull request 'feat(auth): Google onboarding flow (option 3)' (#12) from feat/login-methods into goutamnextflow 2026-07-12 22:06:03 +00:00
tanweer919 948abf75fd feat(auth): first-time Google onboarding (option 3)
After a Google sign-in with no CRM profile, route to /portal/onboarding — the
registration flow in mode='onboard': email skipped (Google-verified, prefilled
via getUserEmail), sets a password (updateUser) so email+password login also
works, skips the OTP-verify step, and persists the profile via crm.account.register.
Existing-profile users go straight to the dashboard. Bumps SDK ^0.2.3.
2026-07-13 03:35:55 +05:30
tanweer919 66cd8953ba Merge pull request 'feat(auth): gate Google sign-in on CRM profile' (#11) from feat/login-methods into goutamnextflow 2026-07-12 21:52:11 +00:00
tanweer919 34cb44cfa8 feat(auth): gate Google sign-in on an existing CRM profile
Google is sign-IN only: after OAuth, check crm.account.registrationStatus; if the
account has no CRM profile, sign back out and show an error on the login screen
instead of admitting a profile-less user. (Onboarding to collect the profile comes later.)
2026-07-13 03:22:04 +05:30
tanweer919 11931cbf6f Merge pull request 'polish: real role in user chip' (#10) from feat/login-methods into goutamnextflow 2026-07-12 21:33:24 +00:00
tanweer919 f38fa80bde polish(dashboard): show real ACE role in user chip instead of static 'Property Owner' 2026-07-13 03:03:17 +05:30
tanweer919 43f9a3eb83 Merge pull request 'feat(dashboard): real user + sign-out; remove demo text' (#9) from feat/login-methods into goutamnextflow 2026-07-12 21:27:58 +00:00
tanweer919 86af77c92b feat(dashboard): real user in sidebar + sign-out; strip demo/mock hints
- Sidebar footer now shows the ACE identity (was static 'James Carter') with a
  Sign-out menu; topbar user gets a Sign-out dropdown too. Both call useAuth().logout.
- Remove all visible 'Demo:' hints from login + register and the 'Demo mode' subtitle.
2026-07-13 02:57:51 +05:30
tanweer919 f5ff7bf6ea Merge pull request 'fix(auth): session persists on reload (SDK 0.2.2)' (#8) from feat/login-methods into goutamnextflow 2026-07-12 21:11:46 +00:00
tanweer919 27e7bdf8f5 fix(auth): bump appshell-sdk 0.2.2 (ready-after-restore) — session persists on reload 2026-07-13 02:41:39 +05:30
tanweer919 3e79b3bf31 Merge pull request 'fix(auth): session persists on reload (gate redirect on boot-ready)' (#7) from feat/login-methods into goutamnextflow 2026-07-12 21:05:20 +00:00
tanweer919 64be4b9b3f fix(auth): don't redirect from dashboard until SDK boot completes
AuthGate redirected to /portal/login whenever status !== authenticated, which
during boot (before restore() resolves) dropped a valid session on reload. Gate
the redirect on useAppShell().ready so we only bounce once auth has resolved.
2026-07-13 02:35:13 +05:30
tanweer919 e08fa357f7 Merge pull request 'chore: remove debug logging' (#6) from feat/login-methods into goutamnextflow 2026-07-12 20:47:30 +00:00
tanweer919 5186497c50 chore(auth): remove debug logging from OAuth + registration error paths 2026-07-13 02:17:22 +05:30
tanweer919 e37ef375eb Merge pull request 'fix(auth): PKCE verifier (SDK 0.2.1) — Google sign-in' (#5) from feat/login-methods into goutamnextflow 2026-07-12 20:32:32 +00:00
tanweer919 0e8a207640 fix(auth): bump appshell-sdk 0.2.1 (PKCE verifier fix); tidy OAuth error handling 2026-07-13 02:02:24 +05:30
tanweer919 66118ff63f Merge pull request 'fix(auth): surface OAuth error' (#4) from feat/login-methods into goutamnextflow 2026-07-12 20:15:17 +00:00
tanweer919 7570fe7be3 fix(auth): surface the real OAuth completion error (console + flash) 2026-07-13 01:45:10 +05:30
tanweer919 6d0c8890d3 Merge pull request 'fix(auth): complete Google OAuth after SDK boot' (#3) from feat/login-methods into goutamnextflow 2026-07-12 20:01:53 +00:00
tanweer919 6e1a895e7e fix(auth): complete Google OAuth after SDK boot (was stuck on ?code=…#identify)
The OAuth-return effect ran on mount before AppShell finished booting, so
completeOAuthLogin no-oped (sdk.auth undefined → null) and fell back to identify,
never retrying. Gate it on useAppShell().ready so it runs once auth is up, and
strip ?code= from the URL on success.
2026-07-13 01:31:37 +05:30
tanweer919 e9ec33d412 Merge pull request 'feat(auth): Google-only social; email/password sign-up; phone login' (#2) from feat/login-methods into goutamnextflow 2026-07-11 15:19:06 +00:00
tanweer919 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- SocialButtons: only Google (Microsoft/Apple hidden).
- Sign-up: remove all social (email/password only).
- Login: keep Google + email/password (single step) + email/SMS OTP; add a direct
  'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to
  'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
2026-07-11 20:48:47 +05:30
tanweer919 2a653b807b Merge pull request 'feat: AppShell integration — auth, email/SMS OTP login, live multi-role team management' (#1) from tanweer919/lynkeduppro-crm:feat/appshell-integration into goutamnextflow 2026-07-11 14:24:59 +00:00
tanweer919 339e2b007a feat(register): persist full registration to crm.account.register
Lift StepAddress/AddressBlock state up so finish() can send the complete
sign-up payload (name, phone, persona, allottee id/names, registered+mailing
address, consent) to be-crm crm.account.register after auth. Config-gated;
non-fatal if the domain persist fails (auth account already created).
2026-07-11 06:19:49 +05:30
tanweer919 565019a75c feat(team+auth): live multi-role team management + email/SMS OTP login
- team-management: new useTeamData() data layer serves live be-crm data door
  (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite
  commands) or the mock fallback, gated on isShellConfigured(). A member can
  hold MANY roles: role chips + multi-select picker in Edit/Invite modals.
- login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless)
  via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp.
- bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
2026-07-11 04:44:54 +05:30
tanweer919 1d37593102 feat(appshell): integrate @abe-kap/appshell-sdk (strangler, mock fallback)
Wire the AppShell SDK for auth/identity across the CRM, gated behind
isShellConfigured() so the existing mock portal + static demo user stay
the fallback until the Shell BFF is configured.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:52:34 +05:30
164 changed files with 32998 additions and 1061 deletions
+96
View File
@@ -0,0 +1,96 @@
# appshell-sdk / Shell BFF config. Leave unset to keep the existing mock portal
# (the app still runs). Set these once the Shell BFF is deployed for real auth.
# Browser-safe Supabase config (the anon key is PUBLISHABLE — never service_role):
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-anon-key>
# Where the browser reaches the Shell BFF (same-origin path; rewritten to BFF_ORIGIN).
NEXT_PUBLIC_BFF_BASE_URL=/shell
# The deployed Shell BFF origin — the /shell/* rewrite proxies here (see next.config.ts).
BFF_ORIGIN=http://localhost:4000
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
# locally: export NODE_AUTH_TOKEN=<token> before npm install
# Vercel: set NODE_AUTH_TOKEN as a project env var
# ===========================================================================
# SMART GALLERY — AI routes (/api/gallery/ai/*). Full docs: docs/SMART_GALLERY.md
# ===========================================================================
#
# AUTH: these routes are session-gated. When NEXT_PUBLIC_SUPABASE_URL above is
# SET, every AI request is verified against ${BFF_ORIGIN}/api/session/context and
# a non-200 is rejected. When it is UNSET the app is in local demo mode and the
# AI routes are UNAUTHENTICATED — never expose such a deployment publicly while
# RUNPOD_API_KEY is set, or anyone can spend your GPU budget.
#
# NOTHING below is required to run the gallery: object detection, faces, OCR and
# semantic search all run FREE in-browser with no key. Only generative editing,
# transcription, denoise and tilt need RunPod.
# --- RunPod credentials -----------------------------------------------------
# Server-side ONLY. Never prefix with NEXT_PUBLIC_ — that would ship the key to
# the browser. Read exclusively by src/lib/server/runpod/client.ts.
RUNPOD_API_KEY=rpa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# --- Per-model endpoint URLs (deploy each so the URL ends in /runsync) -------
# Several models can share one endpoint id — that is expected, not a mistake.
RUNPOD_SD_IMG2IMG_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #10 prompt / colorize (img2img)
RUNPOD_SD_INPAINT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #9 replace-sky / magic-eraser / generative-fill / outpaint (masked)
RUNPOD_YOLO_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #1 object detection → /api/gallery/ai/classify
RUNPOD_UPSCALE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #7 restore / upscale (Real-ESRGAN)
RUNPOD_STT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #3 speech-to-text → /api/gallery/ai/transcribe
RUNPOD_BG_REMOVE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #6 background removal (U²-Net)
RUNPOD_AUDIO_DENOISE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #12 audio denoise → /api/gallery/ai/denoise
# Optional, only if you deploy them:
# RUNPOD_TILT_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #2 camera tilt → /api/gallery/ai/tilt (needs the switch below)
# RUNPOD_COLORIZE_URL=https://api.runpod.ai/v2/<endpoint-id>/runsync # #8 DDColor (currently unused — colorize goes through img2img)
# --- Which backend serves /api/gallery/ai/edit ------------------------------
# auto = first configured of: runpod → local → huggingface → gemini
# runpod = the RUNPOD_*_URL endpoints above (recommended)
# local = your own Stable Diffusion (A1111/Forge/SD.Next); needs LOCAL_SD_URL
# huggingface = HF Inference API; needs HF_API_TOKEN (+ optional HF_IMAGE_MODEL)
# gemini = Google Gemini; needs GEMINI_API_KEY (image output requires a BILLED key)
# none = disable generative editing entirely (503 with a clear message)
AI_EDIT_PROVIDER=runpod
# Alternative backends (only read when AI_EDIT_PROVIDER selects them):
# LOCAL_SD_URL=http://127.0.0.1:7860
# LOCAL_SD_DENOISE=0.55
# LOCAL_SD_STEPS=25
# LOCAL_SD_SAMPLER=Euler a
# HF_API_TOKEN=hf_xxxxxxxx
# HF_IMAGE_MODEL=timbrooks/instruct-pix2pix
# GEMINI_API_KEY=
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
# Optional Stable Diffusion tuning (defaults in src/lib/server/runpod/endpoints.ts):
# RUNPOD_SD_STEPS=35
# RUNPOD_SD_STRENGTH=0.8
# RUNPOD_SD_GUIDANCE=7
# RUNPOD_SD_NEGATIVE_PROMPT=
# --- Client-side capability switches (NEXT_PUBLIC_, inlined at BUILD time) ---
# Each must be the literal string "true" to enable; anything else is off. Because
# they are inlined at build time, changing one requires a rebuild, not a restart.
#
# false/unset = object detection runs FREE in-browser (COCO-SSD). true = use the
# RunPod YOLO classifier via /api/gallery/ai/classify (COCO-SSD stays the fallback).
# NEXT_PUBLIC_APG_RUNPOD_DETECT=false
#
# false/unset = background removal runs in-browser (@imgly WASM, no key).
# true = use the RunPod U²-Net endpoint, falling back to @imgly on failure.
NEXT_PUBLIC_APG_RUNPOD_BG=true
#
# true = show the editor's Auto-straighten button and call /api/gallery/ai/tilt.
# Only enable this if RUNPOD_TILT_URL is actually deployed.
# NEXT_PUBLIC_APG_RUNPOD_TILT=false
# NOTE: the photo-gallery SDK also reads a family of NEXT_PUBLIC_APG_* THEMING
# vars (NEXT_PUBLIC_APG_THEME / _ACCENT / _RADIUS / _BG_DARK / _SIDEBAR_BG_* …)
# in its standalone demo. Those are NOT used here — the CRM passes `themeTokens`
# to the gallery component directly so the gallery inherits the dashboard's
# design tokens. Setting them in this file has no effect.
+7
View File
@@ -39,3 +39,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.vercel
.env*.local
certificates
AI_ASSISTANT_BACKEND_PLAN.md
TEAM_MANAGEMENT_BACKEND_PLAN.md
+6
View File
@@ -0,0 +1,6 @@
@abe-kap:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# @insignia/* (iios-kernel-client — the MessageSocket live stream) resolve from the
# self-hosted Gitea npm registry. Install needs a token with `read:package` in GITEA_TOKEN.
@insignia:registry=https://git.lynkedup.cloud/api/packages/insignia/npm/
//git.lynkedup.cloud/api/packages/insignia/npm/:_authToken=${GITEA_TOKEN}
+827
View File
@@ -0,0 +1,827 @@
# Leads & Lead Verification — Backend Requirements
**Project:** LynkedUp Pro CRM (`lynkeduppro-crm`)
**Domain service:** `be-crm` (the CRM "data door")
**Modules covered:** `Leads`, `Lead Verification`
**Document status:** Implementation-ready. Derived entirely from the current frontend.
**Author basis:** Reverse-engineered from the completed frontend + the existing backend integration conventions already used by the Team, Mail, Inbox, Messenger, Media and Account modules.
---
> ## ⚠️ Read this first — current state of the frontend
>
> Both modules are **fully built on the UI side but wired to client-side mock data only**. There is **no live backend call anywhere** in `leads.tsx`, `leads-data.ts`, `verify.tsx`, or `verify-data.ts`. Every "write" today (Create Lead, Verify, Mark Unverified, Change Assignee, Update Status, Call, Email, Refresh) only pushes a **toast notification** — no persistence, no network request.
>
> This means the **entire** Leads and Lead Verification backend is greenfield. However, the frontend precisely defines the required **fields, enums, filters, sub-statuses, actions and workflows**, so this document treats the mock shapes as the authoritative contract.
>
> Wherever a control exists visually but has no behaviour yet, it is tagged **`[Frontend Placeholder / Backend Pending]`** so you know it is a real requirement even though the UI does not call it yet.
>
> The design below deliberately **reuses the existing `be-crm` architecture** (query/command data door, not REST). Do **not** invent a REST API — see §11 (Existing Backend Integration) for the mandatory conventions.
---
## Table of Contents
1. [Module Overview](#1-module-overview)
2. [Existing Frontend Features (exhaustive)](#2-existing-frontend-features-exhaustive)
3. [User Flows](#3-user-flows)
4. [Database Models](#4-database-models)
5. [Entity Relationships](#5-entity-relationships)
6. [API Contract — Actions (Queries & Commands)](#6-api-contract--actions-queries--commands)
7. [Validation Rules](#7-validation-rules)
8. [Reference / Enum Data](#8-reference--enum-data)
9. [Backend Services](#9-backend-services)
10. [Error Handling](#10-error-handling)
11. [Existing Backend Integration (mandatory conventions)](#11-existing-backend-integration-mandatory-conventions)
12. [Gaps, Discrepancies & Backend-Pending Items](#12-gaps-discrepancies--backend-pending-items)
---
## 1. Module Overview
### 1.1 Leads module
**Purpose:** The **Leads** module is the storm-restoration sales pipeline board. It holds prospective roofing jobs (door-knocked, referred, storm-chased, etc.) inside a hail-storm territory (the demo is the Plano, TX hail zone, storm event `2026-04-28`). Each lead is a rich record combining **contact**, **property**, **job details**, **insurance** and **assignment** information, and moves through a sales status pipeline.
**Frontend surface:** `src/components/dashboard/leads.tsx` (+ `leads-data.ts`), rendered inside the dashboard shell when the `leads` nav key is active.
**Core capabilities visible in the UI:**
- A **stat strip** (Total / New / Contacted / Appointed / Closed counts).
- A **board of lead cards** (avatar, priority ring, status pill, address, primary phone, source, canvasser, "updated" relative time).
- **Search** (by name / address / city / source / canvasser) and **status filter tabs**.
- A **lead detail modal** (Contact, Property, Job Details, Insurance, Assignment, storm banner).
- A **New Lead intake** with two modes: **Quick** (single condensed form) and **Full Form** (a 5-step wizard: Contact → Property → Job Details → Insurance → Assignment).
### 1.2 Lead Verification module
**Purpose:** The **Lead Verification** module is the verification desk that sits **upstream of Leads**. Freshly captured leads (door-knock / web form / storm canvass / referral / call-in) must pass an **identity + insurance + ownership + damage** verification workflow before they become working sales leads. The desk's terminal success action is literally *"Verified and pushed to New Leads"* — i.e., a verified verification record becomes a Lead with status `new`.
**Frontend surface:** `src/components/dashboard/verify.tsx` (+ `verify-data.ts`), rendered when the `verify` nav key is active.
**Core capabilities visible in the UI:**
- **Clickable stat tiles** (Verified / In Progress / Assigned / Pending / Unverified) that also act as status filters.
- A **data table** (Lead ID · Customer · Phone · Source · Assigned To · Status · Verification sub-status · Created · Actions).
- **Search** + **status / source / assignee** dropdown filters.
- A **row action set** (View, Verify, and a `⋯` menu: View Details, Verify Lead, Mark Unverified, Change Assignee, Reassign → In Progress, Move to Pending).
- A **verification detail modal** (Contact, Assignment, Verification Notes, and an **Activity timeline**).
- A **Refresh** button.
### 1.3 Relationship between the two modules
```
Intake sources Lead Verification desk Leads pipeline
(door knock, web form, ─────► verify identity / ownership / ─────► status = "new"
storm canvass, referral, insurance / damage → contacted → appointed
call-in) (verified | in_progress | → closed
assigned | pending | unverified)
```
- A **Lead Verification** record is the *pre-lead*. When it reaches `verified`, the system **creates / promotes** it into a **Lead** (`status = "new"`). This is the single hard link between the two modules and the most important cross-module command to implement (see `crm.leadVerification.verify` in §6).
- Both modules are gated by the **same** CRM permission: **`leads.manage`** (see `NAV_PERMISSION` in `sidebar.tsx`). There is no separate verification permission in the frontend.
- Both are **tenant-scoped** — every record belongs to the signed-in user's organization/tenant (the demo boots as `tenant-acme-01`).
### 1.4 User journey (end-to-end)
1. A lead is **captured** at an intake source and lands in the **Verification** queue as `pending` (unassigned) or `assigned`.
2. A **verification specialist** (Wade Hollis, Darlene Brooks, Roy Schaefer in the mock) is assigned and works the record through sub-statuses (`Verifying Identity``Confirming Ownership``Reviewing Insurance``Confirming Damage`).
3. The specialist either **Verifies** it (→ becomes a Lead, `status = new`) or **Marks Unverified**.
4. In the **Leads** module, a sales rep / canvasser works the new lead: **Contacted****Appointed****Closed**, updating status, priority, follow-up date, insurance/claim details and assignment along the way.
---
## 2. Existing Frontend Features (exhaustive)
Only features actually present in the code are listed. Each is tagged **Wired** (has real logic, even if only client-side state) or **`[Placeholder / Backend Pending]`** (UI exists, no behaviour beyond a toast).
### 2.1 Leads module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| L1 | Lead board / card list | Wired (mock) | `LEADS` array rendered as `LeadCard`s |
| L2 | Header stat strip (Total, New, Contacted, Appointed, Closed) | Wired (mock) | `countByStatus`; `TOTAL_LEADS = 35` shown as total |
| L3 | Text search (name, address, city, source, canvasser) | Wired (mock) | `filtered` memo, case-insensitive substring |
| L4 | Status filter tabs (All / New / Contacted / Appointed / Closed) | Wired (mock) | `STATUS_TABS` |
| L5 | Lead detail modal (Contact, Property, Job, Insurance, Assignment, storm banner) | Wired (mock, read-only) | `LeadDetail` |
| L6 | Create Lead — Quick mode | **Placeholder** | `submit()` only validates name then toasts; no persistence |
| L7 | Create Lead — Full Form wizard (5 steps) | **Placeholder** | steps: Contact / Property / Job / Insurance / Assignment |
| L8 | Multiple phone numbers (with type Mobile/Home/Work + primary) | Wired (form state) | `addPhone/setPhone/removePhone` |
| L9 | Multiple email addresses | Wired (form state) | `addEmail/setEmail/removeEmail` |
| L10 | Site photos upload | **Placeholder** | `addPhoto()` just appends a label string `"Photo N"` — no real upload |
| L11 | Conditional field: Referral note (source = Referral) | Wired (form state) | `f.source === "Referral"` |
| L12 | Conditional field: Canvasser search (source = Door Knock) | Wired (form state) | `CanvasserSearch` typeahead over `REPS` |
| L13 | Priority picker (Low / Medium / High) | Wired (form state) | `PriorityPicker` |
| L14 | Urgency picker (Standard / High / Emergency) | Wired (form state) | `UrgencyPicker` |
| L15 | Rep assignment select (incl. "Unassigned") | Wired (form state) | `RepSelect`, options from `REPS` |
| L16 | Detail action: **Update Status** | **Placeholder** | footer button, no handler |
| L17 | Detail action: **Call** / **Email** | **Placeholder** | footer buttons, no handler |
| L18 | Empty state ("No leads match") | Wired | shown when `filtered.length === 0` |
**Not present in Leads (do not build unless requested):** edit-existing-lead form, delete, archive, bulk actions, tags editor (only a single static `tag` string exists), notes/comments thread, per-lead activity timeline, attachments list, pagination controls, sorting controls, export.
### 2.2 Lead Verification module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| V1 | Stat tiles (Verified / In Progress / Assigned / Pending / Unverified) | Wired (mock) | `STAT_ORDER`, counts from `V_LEADS` |
| V2 | Stat tile click = status filter toggle | Wired (mock) | `setStatus(status === s ? "all" : s)` |
| V3 | Verification table (9 columns) | Wired (mock) | see column list in §1.2 |
| V4 | Text search (name, lead ID, phone, source, address) | Wired (mock) | `rows` memo |
| V5 | Status filter dropdown | Wired (mock) | mirrors stat tiles |
| V6 | Source filter dropdown | Wired (mock) | `V_SOURCES` |
| V7 | Assignee filter dropdown | Wired (mock) | `V_ASSIGNEES` |
| V8 | Row action: **View details** | Wired (mock) | opens `VerifyDetail` |
| V9 | Row action: **Verify** (quick) | **Placeholder** | toast only |
| V10 | Row `⋯` menu | Wired (open/close) | portalled dropdown |
| V11 | Menu: Verify Lead | **Placeholder** | toast only |
| V12 | Menu: Mark Unverified | **Placeholder** | toast only |
| V13 | Menu: Change Assignee | **Placeholder** | toast only |
| V14 | Menu: Reassign (→ In Progress) | **Placeholder** | toast only |
| V15 | Menu: Move to Pending | **Placeholder** | toast only |
| V16 | Detail modal — Contact / Assignment sections | Wired (mock, read) | `VerifyDetail` |
| V17 | Detail modal — Verification Notes | Wired (mock, read) | shown when `notes` present |
| V18 | Detail modal — Activity timeline | Wired (mock, read + derived) | `buildActivity()` synthesizes when absent |
| V19 | Detail footer: Verify Lead / Call | **Placeholder** | buttons, no handler |
| V20 | Refresh button | **Placeholder** | toast only ("Queue refreshed") |
| V21 | Row count "X of Y leads" | Wired (mock) | implies server total vs filtered count |
| V22 | Derived email / created-at when absent | Wired (mock) | `deriveEmail`, `deriveCreatedAt` |
**Not present in Verification (do not build unless requested):** create-verification-from-UI (records arrive via intake, never created here — mirrors the Inbox "items are never created here" pattern), delete, bulk verify, attachments, document upload, editable contact fields.
---
## 3. User Flows
### 3.1 Lead Verification status flow
Statuses (`VStatus`): `unverified`, `pending`, `assigned`, `in_progress`, `verified`.
Each status also carries a human **sub-status** string (`verification`).
```
intake (door knock / web form / storm canvass / referral / call-in)
┌──────────── pending ("Pending Review", unassigned) ────────────┐
│ │ │
│ [Change Assignee] │
│ ▼ │
│ assigned ("Assigned") │
│ │ │
│ [Reassign / start work] │
│ ▼ │
│ in_progress ("Verifying Identity" / │
│ "Confirming Ownership" / │
│ "Reviewing Insurance" / │
│ "Confirming Damage") │
│ │ │ │
│ [Verify]│ │[Mark Unverified] │
│ ▼ ▼ │
│ verified unverified ◄─────────────────────┘
│ ("Verified") ("Unverified")
│ │
│ ▼
│ ┌───────────────────────────────┐
└────►│ PROMOTE → create Lead(status = │
│ "new") + activity "Verified │
│ and pushed to New Leads." │
└───────────────────────────────┘
[Move to Pending] can send an assigned/in_progress record back to pending.
```
**Allowed transitions (enforce server-side):**
| From | To | Trigger command |
|------|----|-----------------|
| `pending` | `assigned` | `assign` (set assignee) |
| `pending` / `assigned` / `in_progress` | `in_progress` | `reassign` / `setInProgress` |
| `assigned` / `in_progress` | `pending` | `moveToPending` |
| `pending` / `assigned` / `in_progress` | `verified` | `verify` (→ promotes to Lead) |
| `pending` / `assigned` / `in_progress` | `unverified` | `markUnverified` |
| any | (assignee change) | `assign` / `changeAssignee` |
> The frontend does not restrict transitions (every action is available on every row), so treat the table above as the **recommended** guard set; at minimum, forbid transitions out of a terminal `verified` record except via an explicit re-open (backend-pending, not in UI).
### 3.2 Lead sales status flow
Statuses (`LeadStatus`): `new`, `contacted`, `appointed`, `closed`.
```
(created directly OR promoted from a verified verification)
new
│ [Update Status] / rep works the lead
contacted
appointed (adjuster / inspection appointment set)
closed (won/installed/paid — mock leans "won")
```
`priority` (`high` / `medium` / `low`) and `job.urgency` (`Standard` / `High` / `Emergency`) are independent of status and set at creation / update.
### 3.3 Create Lead (Full Form wizard) flow
```
Step 1 Contact → firstName*, lastName, phones[] (type + primary), emails[]
Step 2 Property → address, city, state (default "TX"), zip, propertyType, photos[]
Step 3 Job → source, leadType, workType, tradeType, urgency, notes
(source=Referral → referralNote; source=Door Knock → canvasser)
Step 4 Insurance → insCompany, claimNumber, claimStatus, adjusterName,
adjusterPhone, policyNumber
Step 5 Assignment→ assignRep (or Unassigned), priority, followUp date
→ [Create Lead] → crm.lead.create
```
Quick mode collects a condensed subset (firstName*, lastName, phone[0], address, city, state, zip, source, [referralNote|canvasser], priority, followUp) and submits the same `crm.lead.create`.
`*` = the only field the frontend currently enforces is a non-empty **name** (first or last).
---
## 4. Database Models
Storage assumptions follow the existing `be-crm` domain service (relational, multi-tenant). **Every table carries `tenant_id`** and is always filtered by it. Primary keys are opaque server IDs; the human-facing **codes** (`SAL-001`, `LD-V-001`) are separate, per-tenant, monotonic display identifiers.
Timestamps are stored as `timestamptz` (UTC, ISO-8601 on the wire). Relative strings like `"3d ago"` and pretty dates like `"Jun 4, 2026"` seen in the mock are **presentation-only** — the API returns ISO timestamps and the client formats them (see `relTime()` in `team-api.ts`).
### 4.1 `leads`
**Purpose:** One row per sales lead (the Leads board card + detail modal).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | uuid / string PK | no | gen | opaque server id |
| `tenant_id` | uuid/string FK → organizations | no | — | tenant scope; **indexed** |
| `code` | text | no | seq | display code `SAL-001`; **unique per tenant** |
| `first_name` | text | no | — | |
| `last_name` | text | yes | null | name = `first + last`, trimmed |
| `status` | enum `lead_status` | no | `'new'` | `new` \| `contacted` \| `appointed` \| `closed` |
| `priority` | enum `lead_priority` | no | `'medium'` | `high` \| `medium` \| `low` (UI Quick default = Medium) |
| `tag` | text | yes | `'Storm Zone'` | single label chip |
| `property_address` | text | yes | null | |
| `property_city` | text | yes | null | |
| `property_state` | text | yes | `'TX'` | 2-letter; UI default TX |
| `property_zip` | text | yes | null | |
| `property_type` | text | yes | null | see `PROPERTY_TYPES` enum |
| `source` | text | yes | null | see `LEAD_SOURCES` enum |
| `referral_note` | text | yes | null | only when `source = 'Referral'` |
| `lead_type` | text | yes | null | see §12 note on the enum conflict |
| `work_type` | text | yes | null | see `WORK_TYPES` |
| `trade_type` | text | yes | null | see `TRADE_TYPES` |
| `urgency` | enum `lead_urgency` | no | `'Standard'` | `Standard` \| `High` \| `Emergency` |
| `job_notes` | text | yes | null | "Field Notes" |
| `canvasser_id` | FK → members | yes | null | door-knock canvasser (REP id, e.g. `LUP-1040`) |
| `insurance_company` | text | yes | null | |
| `insurance_claim_status` | enum `claim_status` | yes | null | `Not Filed`\|`Filed`\|`Approved`\|`Paid`\|`Denied` |
| `insurance_claim_number` | text | yes | null | |
| `insurance_policy_number` | text | yes | null | |
| `insurance_adjuster_name` | text | yes | null | |
| `insurance_adjuster_phone` | text | yes | null | |
| `assigned_to_id` | FK → members | yes | null | rep working the lead |
| `follow_up_date` | date | yes | null | |
| `storm_zone` | text | yes | null | e.g. "E Plano / Spring Creek Pkwy" |
| `storm_date` | date | yes | null | e.g. `2026-04-28` |
| `storm_detail` | text | yes | null | e.g. `2.5" hail (severe)` |
| `source_verification_id` | FK → lead_verifications | yes | null | set when promoted from a verification |
| `created_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
| `updated_at` | timestamptz | no | now() | drives "updated 3d ago" |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, code)` unique, `(tenant_id, assigned_to_id)`, `(tenant_id, source)`, `(tenant_id, updated_at desc)`.
**Search:** the board search covers name + property address + city + source + canvasser → back with a `tsvector` / trigram index over those columns.
**Example row (from `SAL-001`):**
```json
{
"id": "ld_9f2c…", "code": "SAL-001",
"firstName": "John", "lastName": "Martinez",
"status": "contacted", "priority": "high", "tag": "Storm Zone",
"propertyAddress": "4821 Spring Creek Pkwy", "propertyCity": "Plano",
"propertyState": "TX", "propertyZip": "75023", "propertyType": "Single Family",
"source": "Door Knock", "leadType": "Insurance",
"workType": "Roof Replacement", "tradeType": "Roofing", "urgency": "High",
"jobNotes": "Homeowner showed significant granule loss…",
"canvasserId": "LUP-1040",
"insuranceCompany": "State Farm", "insuranceClaimStatus": "Filed",
"insuranceClaimNumber": "CLM-2026-1000", "insurancePolicyNumber": "POL-080000",
"insuranceAdjusterName": "Marcus Powell", "insuranceAdjusterPhone": "(972) 700-3000",
"assignedToId": "LUP-…", "followUpDate": "2026-06-04",
"stormZone": "E Plano / Spring Creek Pkwy", "stormDate": "2026-04-28",
"stormDetail": "2.5\" hail (severe)",
"createdById": "LUP-1040", "createdAt": "2026-05-28T15:00:00Z", "updatedAt": "…"
}
```
### 4.2 `lead_phones`
**Purpose:** A lead has 1..N phone numbers, one primary.
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | **on delete cascade** |
| `number` | text | no | — | free-form display, e.g. `(469) 500-1000` |
| `type` | enum `phone_type` | no | `'Mobile'` | `Mobile` \| `Home` \| `Work` |
| `is_primary` | bool | no | false | exactly one true per lead (enforce) |
| `position` | int | no | 0 | display order |
**Indexes:** `(lead_id)`. **Constraint:** partial unique `(lead_id) where is_primary` to guarantee a single primary.
### 4.3 `lead_emails`
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `address` | text | no | — | validate email format |
| `type` | text | yes | null | optional label |
| `is_primary` | bool | no | false | |
| `position` | int | no | 0 | |
### 4.4 `lead_attachments` (site photos) — `[Backend Pending]`
**Purpose:** Backs the "Site Photos" uploader (L10). The frontend currently only stores placeholder labels; the real implementation must use the existing media presign flow (§11.5).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `content_ref` | text | no | — | IIOS object key from `crm.media.presignUpload` |
| `mime_type` | text | no | — | |
| `size_bytes` | bigint | no | — | ≤ `26_214_400` (25 MB) |
| `filename` | text | yes | null | |
| `uploaded_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
### 4.5 `lead_status_history` (audit) — recommended
**Purpose:** Backs "Update Status" auditing (no dedicated Leads timeline UI exists yet, but status changes must be auditable and this feeds a future timeline). Mirrors the verification activity concept.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `lead_id` | FK → leads | no | cascade |
| `from_status` | enum | yes | null on create |
| `to_status` | enum | no | |
| `actor_id` | FK → members | yes | |
| `note` | text | yes | |
| `created_at` | timestamptz | no | |
### 4.6 `lead_verifications`
**Purpose:** One row per verification-desk record (the Verification table row + detail).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | indexed |
| `code` | text | no | seq | display code `LD-V-001`; **unique per tenant** |
| `name` | text | no | — | customer full name (single field in UI) |
| `phone` | text | yes | null | |
| `email` | text | yes | null | derived client-side when absent (see `deriveEmail`) |
| `address` | text | yes | null | single-line address string in UI |
| `source` | text | no | — | see `V_SOURCES` (`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`) |
| `status` | enum `verification_status` | no | `'pending'` | `verified`\|`in_progress`\|`assigned`\|`pending`\|`unverified` |
| `sub_status` | text | no | `'Pending Review'` | the `verification` label, see §8.6 |
| `assignee_id` | FK → members | yes | null | verification specialist |
| `notes` | text | yes | null | "Verification Notes" |
| `promoted_lead_id` | FK → leads | yes | null | set when verified → Lead created |
| `verified_at` | timestamptz | yes | null | |
| `created_at` | timestamptz | no | now() | drives "Created" column |
| `updated_at` | timestamptz | no | now() | |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, source)`, `(tenant_id, assignee_id)`, `(tenant_id, code)` unique, `(tenant_id, created_at desc)`.
**Search:** name + code + phone + source + address → trigram/tsvector.
**Example rows (from `LD-V-001` and a lean one):**
```json
{ "id":"lv_1…","code":"LD-V-001","name":"Kevin Hartley",
"phone":"(972) 413-8902","email":"kevin.hartley@gmail.com",
"address":"2814 Ravenswood Dr, Plano, TX 75023","source":"Door Knock",
"status":"verified","subStatus":"Verified","assigneeId":"…Wade…",
"notes":"Ownership confirmed via county records…",
"verifiedAt":"2026-05-16T19:55:00Z","createdAt":"2026-05-14T14:40:00Z" }
{ "id":"lv_11…","code":"LD-V-011","name":"Aaron Blake",
"phone":"(469) 471-3350","address":"1188 Alma Dr, Plano, TX 75075",
"source":"Door Knock","status":"pending","subStatus":"Pending Review",
"assigneeId":null,"createdAt":"2026-05-27T…" }
```
### 4.7 `lead_verification_activities`
**Purpose:** The Activity timeline in the verification detail (V18). The frontend synthesizes these when absent (`buildActivity`), but the backend should persist real ones.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `verification_id` | FK → lead_verifications | no | cascade |
| `text` | text | no | e.g. "Assigned to Wade Hollis." |
| `actor_id` | FK → members | yes | maps to `who` (name shown; "System" when null) |
| `actor_label` | text | yes | denormalized display name / "System" |
| `occurred_at` | timestamptz | no | maps to `time` |
| `kind` | text | yes | optional: `submitted`\|`assigned`\|`in_progress`\|`verified`\|`unverified`\|`note` |
**Index:** `(verification_id, occurred_at)`.
### 4.8 Referenced existing tables (do **not** recreate)
- **`organizations` / tenant** — tenant scope (`tenant_id`). Established at boot / registration (`crm.account.register`).
- **`members`** — CRM team members (reps, canvassers, verification specialists). Already served by `crm.team.member.search` and carry ids like `LUP-1040` plus `principalId`. Leads/verifications reference members for `assigned_to`, `canvasser`, `assignee`, `created_by`, and activity `actor`. In the mock these are free-text names — the backend must resolve them to member ids (see §12).
- **Account/permissions** — `crm.account.me` drives `leads.manage` gating.
---
## 5. Entity Relationships
```
organizations (tenant)
1 ──────────────< leads
1 ──────────────< lead_verifications
1 ──────────────< members
members
1 ──< leads.assigned_to_id
1 ──< leads.canvasser_id
1 ──< leads.created_by_id
1 ──< lead_verifications.assignee_id
1 ──< lead_verification_activities.actor_id
1 ──< lead_status_history.actor_id
leads
1 ──< lead_phones (cascade)
1 ──< lead_emails (cascade)
1 ──< lead_attachments (cascade) [backend-pending]
1 ──< lead_status_history (cascade)
lead_verifications
1 ──< lead_verification_activities (cascade)
1 ──0..1 leads (promotion: lead_verifications.promoted_lead_id
⇆ leads.source_verification_id)
```
**Cardinality summary**
| Relationship | Type | Notes |
|--------------|------|-------|
| tenant → lead | 1tomany | all leads tenant-scoped |
| tenant → verification | 1tomany | |
| lead → phones | 1tomany | ≥1, exactly one primary |
| lead → emails | 1tomany | 0..N |
| lead → attachments | 1tomany | 0..N (pending) |
| lead → status history | 1tomany | audit |
| verification → activities | 1tomany | timeline |
| verification → lead | 1to(0..1) | promotion link, bidirectional FK |
| member → lead (assigned/canvasser/creator) | manyto1 each | nullable |
| member → verification (assignee) | manyto1 | nullable |
**Embedded value objects (not separate tables — stored as columns on `leads`):** `storm { zone, date, detail }`, `property { … }`, `job { … }`, `insurance { … }`, `assignment { … }`. They are grouped only for UI sectioning; there is no reuse that justifies separate tables. Phones and emails **are** separate tables because they are 1tomany.
---
## 6. API Contract — Actions (Queries & Commands)
> **Transport & style (mandatory):** These are **not REST routes**. They are `be-crm` **data-door actions** invoked through the AppShell SDK exactly like every other module:
> - **Reads:** `useQuery<T>("<action>", variables)` → resolves to `T` (or `null` while loading). Errors surface as an `Error` with `.message`.
> - **Writes:** `sdk.command<T>("<action>", variables)` → resolves to `T`. Commands carry an auto-generated idempotency key.
> - **Action naming:** `crm.<entity>.<verb>` — dot-namespaced, camelCase segments (matches `crm.team.member.setRoles`, `crm.inbox.transition`, `crm.mail.reply`).
> - **Collection reads** return either a bare array (like `crm.mail.list`, `crm.inbox.list`) or `{ items: T[], meta }` (like `crm.team.member.search`). For Leads/Verification use **`{ items, meta }`** because the UI needs totals + pagination (§6.1).
### 6.1 Standard collection envelope
```ts
interface Meta { total: number; page: number; perPage: number; }
interface Page<T> { items: T[]; meta: Meta; }
```
- `verify.tsx` renders `"{rows.length} of {V_LEADS.length} leads"``rows.length` is the filtered page length, `meta.total` is the (filtered or overall) count. Return the **filtered** total so the "X of Y" reads correctly, plus the unfiltered stat counts via the dedicated `*.stats` action.
- `leads.tsx` shows `TOTAL_LEADS = 35` while only rendering 5 cards → the board is paginated/capped server-side; provide `perPage` (UI has no pager control yet, so default a sensible `perPage`, e.g. 50, and return `meta.total` for the header).
### 6.2 Leads — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.search` | `{ query?, status?, priority?, source?, assigneeId?, page?, perPage? }` | `Page<LeadCardDTO>` | board list L1, search L3, tabs L4 |
| `crm.lead.get` | `{ id }` | `LeadDTO` (full detail incl. phones/emails/attachments) | detail modal L5 |
| `crm.lead.stats` | `{}` | `{ total: number, byStatus: { new, contacted, appointed, closed } }` | stat strip L2 |
`LeadCardDTO` (list projection — only what the card needs):
```ts
{ id, code, name, initials, gradient, priority, status, tag,
primaryPhone, propertyAddress, propertyCity, propertyState,
source, canvasserName, updatedAt }
```
`LeadDTO` (detail projection): all §4.1 columns + `phones: Phone[]` + `emails: Email[]` + `attachments: Attachment[]` + resolved assignee/canvasser/creator display names + `storm`, `property`, `job`, `insurance`, `assignment` groupings.
### 6.3 Leads — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.create` | full create payload (below) | `LeadDTO` | Create Lead L6/L7 |
| `crm.lead.update` | `{ id, patch: Partial<LeadInput> }` | `LeadDTO` | edit (backend-pending; UI edit form not built) |
| `crm.lead.updateStatus` | `{ id, status, note? }` | `LeadDTO` | detail "Update Status" L16 |
| `crm.lead.assign` | `{ id, assigneeId \| null }` | `LeadDTO` | rep assignment L15 |
| `crm.lead.attachment.add` | `{ id, attachment: { contentRef, mimeType, sizeBytes, filename } }` | `LeadDTO` | site photos L10 (pending) |
| `crm.lead.attachment.remove` | `{ id, attachmentId }` | `LeadDTO` | pending |
| `crm.lead.archive` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
| `crm.lead.delete` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
**`crm.lead.create` payload** (union of Quick + Full form, all optional except `firstName`/`lastName` where at least one non-empty):
```ts
{
firstName: string, lastName?: string,
phones: { number: string, type: "Mobile"|"Home"|"Work", primary?: boolean }[],
emails?: { address: string, primary?: boolean }[],
property?: { address?, city?, state?, zip?, type? },
photos?: { contentRef, mimeType, sizeBytes, filename }[], // real uploads (pending)
job?: { source?, referralNote?, canvasserId?, leadType?, workType?, tradeType?,
urgency?: "Standard"|"High"|"Emergency", notes? },
insurance?: { company?, claimNumber?, claimStatus?, adjusterName?,
adjusterPhone?, policyNumber? },
assignment?: { assigneeId?: string|null, priority?: "Low"|"Medium"|"High",
followUp?: string /* ISO date */ }
}
```
> Note the **priority casing mismatch**: the create form emits `"Low"|"Medium"|"High"` (title-case) while list/detail data uses `"low"|"medium"|"high"`. Normalize to lowercase on write (see §12).
### 6.4 Lead Verification — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.search` | `{ query?, status?, source?, assigneeId?, page?, perPage? }` | `Page<VerificationRowDTO>` | table V3, search V4, filters V5V7 |
| `crm.leadVerification.get` | `{ id }` | `VerificationDTO` (+ activities) | detail V16V18 |
| `crm.leadVerification.stats` | `{}` | `{ verified, in_progress, assigned, pending, unverified }` | stat tiles V1 |
| `crm.leadVerification.activity.list` | `{ id }` | `VerificationActivity[]` | timeline V18 (or embed in `.get`) |
`VerificationRowDTO`:
```ts
{ id, code, name, initials, address, phone, source,
assignee: { id, initials, name } | null,
status, verification /* sub_status */, createdAt }
```
`VerificationDTO`: adds `email`, `notes`, `verifiedAt`, `promotedLeadId`, `activities[]`.
### 6.5 Lead Verification — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.verify` | `{ id, notes? }` | `{ verification: VerificationDTO, lead: LeadDTO }` | Verify V9/V11/V19 — **promotes to Lead** |
| `crm.leadVerification.markUnverified` | `{ id, reason? }` | `VerificationDTO` | Mark Unverified V12 |
| `crm.leadVerification.assign` | `{ id, assigneeId }` | `VerificationDTO` | Change Assignee V13 |
| `crm.leadVerification.reassign` | `{ id, assigneeId? }` | `VerificationDTO` | Reassign → In Progress V14 |
| `crm.leadVerification.moveToPending` | `{ id }` | `VerificationDTO` | Move to Pending V15 |
| `crm.leadVerification.updateStatus` | `{ id, status, subStatus? }` | `VerificationDTO` | generic status set (covers sub-status changes) |
| `crm.leadVerification.note.add` | `{ id, text }` | `VerificationDTO` | verification notes (pending edit UI) |
**`crm.leadVerification.verify` behaviour (the critical cross-module command):**
1. Load verification; guard status is not already `verified`.
2. Set `status = 'verified'`, `sub_status = 'Verified'`, `verified_at = now()`, persist `notes` if provided.
3. **Create a `leads` row** (`status = 'new'`) from the verification's contact/address/source, linking `leads.source_verification_id = verification.id` and `verification.promoted_lead_id = lead.id`.
4. Append activity: `"Verified and pushed to New Leads."` (actor = current user).
5. Return both records so the UI can refresh both queues.
6. Idempotent: a second call with the same idempotency key must not create a duplicate Lead.
### 6.6 Shared / reused actions (already exist — reuse, don't rebuild)
| Action | Use in Leads/Verification |
|--------|---------------------------|
| `crm.account.me` | `leads.manage` permission gate for both modules |
| `crm.team.member.search { perPage }` | rep / canvasser / assignee pickers (`REPS`, `V_ASSIGNEES`) |
| `crm.media.presignUpload { mime, sizeBytes }``{ objectKey, uploadUrl }` | site-photo upload (browser PUTs bytes directly) |
| `crm.media.presignDownload { contentRef }``{ url }` | render/download a lead photo |
---
## 7. Validation Rules
### 7.1 `crm.lead.create` / `crm.lead.update`
| Field | Required | Rule |
|-------|----------|------|
| name (`firstName`/`lastName`) | **Yes** (at least one non-empty after trim) | matches the only current UI guard: `` `${first} ${last}`.trim() `` must be non-empty → else error `name_required` |
| `phones[].number` | No | free-form; if present, strip to digits server-side for storage/dedupe (form already does `phone.replace(/\D/g,"")` in registration) |
| `phones[].type` | with phone | enum `Mobile\|Home\|Work` |
| `phones` primary | — | at most one `primary: true`; if none set, mark the first as primary |
| `emails[].address` | No | RFC-5322-ish email format when present |
| `property.state` | No | 2-letter US state; default `TX` |
| `property.zip` | No | 5-digit (or ZIP+4) when present |
| `property.type` | No | one of `PROPERTY_TYPES` (§8.4) |
| `source` | No | one of `LEAD_SOURCES` (§8.1) |
| `referralNote` | Conditional | accept only when `source = 'Referral'` (UI shows it only then) |
| `canvasserId` | Conditional | resolve to a member; UI surfaces the picker only when `source = 'Door Knock'`, but accept whenever provided |
| `leadType` | No | see §12 enum conflict — accept the UI's `Residential\|Commercial\|Multi-Family` **and** the data model's `Insurance\|Retail`; store raw, do not hard-reject |
| `workType` | No | one of `WORK_TYPES` |
| `tradeType` | No | one of `TRADE_TYPES` |
| `urgency` | No | enum `Standard\|High\|Emergency`; default `Standard` |
| `insurance.claimStatus` | No | enum `Not Filed\|Filed\|Approved\|Paid\|Denied` |
| `priority` | No | accept `Low\|Medium\|High` (form) → normalize to `low\|medium\|high`; default `medium` |
| `assignment.followUp` | No | valid ISO date; may be null/`"—"` |
| `assignment.assigneeId` | No | resolve to a member or `null` (Unassigned) |
**Business validations:**
- `code` (`SAL-###`) is server-generated, unique per tenant — never accepted from the client.
- Duplicate detection (recommended, not in UI): warn (not block) if an active lead exists with the same primary phone **or** same property address within the tenant.
- All member references (`assigneeId`, `canvasserId`) must belong to the same tenant.
### 7.2 Verification commands
| Command | Rule |
|---------|------|
| `verify` | reject if already `verified` (`already_verified`); require the record exists in tenant; create exactly one Lead (idempotent) |
| `assign` / `reassign` | `assigneeId` must be a tenant member; `assign` moves `pending → assigned` (or updates assignee); `reassign` sets `in_progress` |
| `markUnverified` | allowed from any non-terminal state; optional `reason` recorded as activity |
| `moveToPending` | allowed from `assigned` / `in_progress`; clears/keeps assignee per product choice (UI does not specify — keep assignee, set `status=pending`, `sub_status='Pending Review'`) |
| `updateStatus` | `status ∈ verification_status`; if `subStatus` omitted, default it from the status (§8.6 mapping) |
| `note.add` | `text` non-empty, trimmed, reasonable max length (e.g. 5 000 chars) |
**Enums must be validated server-side even though the frontend does not enforce them** — the frontend is permissive (free selects), so the backend is the source of truth.
---
## 8. Reference / Enum Data
Exact values, copied from `leads-data.ts` and `verify-data.ts`. Provide these either as DB enums or as a reference-data query (`crm.lead.options` — optional) so the UI selects stay in sync.
### 8.1 `LEAD_SOURCES`
`Door Knock`, `Referral`, `Storm Chase`, `Mailer / Postcard`, `Sign Call`, `Insurance Agent Referral`, `Repeat Customer`, `Social Media`, `Other`
### 8.2 `WORK_TYPES`
`Roof Replacement`, `Roof Repair`, `Inspection`, `Gutter Install`
### 8.3 `TRADE_TYPES`
`Roofing`, `Gutters`, `Siding`, `Windows`
### 8.4 `PROPERTY_TYPES`
`Single Family`, `Multi Family`, `Commercial`
*(the Full-form Property step also offers `Residential`, `Commercial`, `Multi-Family`, `Industrial` via `PROPERTY_TYPE_OPTS` — accept the superset; see §12)*
### 8.5 `CLAIM_STATUSES`
`Not Filed`, `Filed`, `Approved`, `Paid`, `Denied`
### 8.6 Verification statuses → default sub-status label
| `status` | default `sub_status` | other observed sub-status labels |
|----------|----------------------|----------------------------------|
| `verified` | `Verified` | — |
| `in_progress` | `Verifying Identity` | `Reviewing Insurance`, `Confirming Ownership`, `Confirming Damage` |
| `assigned` | `Assigned` | — |
| `pending` | `Pending Review` | — |
| `unverified` | `Unverified` | — |
### 8.7 Verification sources (`V_SOURCES`)
`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`
*(note: different set from Lead sources §8.1 — keep them as separate reference lists)*
### 8.8 Lead statuses / priorities / urgency
- `lead_status`: `new`, `contacted`, `appointed`, `closed`
- `lead_priority`: `high`, `medium`, `low` (create form emits title-case `Low`/`Medium`/`High`)
- `lead_urgency`: `Standard`, `High`, `Emergency`
- `phone_type`: `Mobile`, `Home`, `Work`
- `lead_type`: **conflicting** — `Insurance`/`Retail` (data) vs `Residential`/`Commercial`/`Multi-Family` (form). See §12.
### 8.9 Seed members (reps / canvassers / verification specialists)
Reps (`REPS`, ids are real member codes): `LUP-1040 Cody Tatum`, `LUP-1041 Hannah Reyes`, `LUP-1042 Travis Boone`, `LUP-1043 Shelby Greer`, `LUP-1044 Dalton Pruitt`.
Verification specialists (`V_ASSIGNEES`): `Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`.
---
## 9. Backend Services
Break the implementation into services mirroring the existing `be-crm` module layout (one bounded context per data-door namespace).
| Service | Namespace | Responsibilities |
|---------|-----------|------------------|
| **LeadService** | `crm.lead.*` | CRUD + search + stats for leads; owns phones/emails child collections; code generation (`SAL-###`); status transitions; assignment. |
| **LeadVerificationService** | `crm.leadVerification.*` | Verification queue search/stats; status & sub-status transitions; assign/reassign/pending; note management; code generation (`LD-V-###`). |
| **LeadPromotionService** (or a method on LeadVerificationService) | part of `crm.leadVerification.verify` | The cross-module promotion: verified verification → new Lead, bidirectional linking, idempotency. Owns the transaction that touches both `leads` and `lead_verifications`. |
| **LeadActivityService** | activities/history | Append + list `lead_verification_activities` and `lead_status_history`; generates the timeline the UI reads (replaces the client-side `buildActivity` synthesis). |
| **LeadAssignmentService** | assignment concerns | Resolve member ids for `assignedTo` / `canvasser` / `assignee`; validate tenant membership; (future) round-robin. Can be a thin helper over the existing member/team module rather than a standalone service. |
| **MediaService (existing — reuse)** | `crm.media.*` | Presign upload/download for site photos. **Do not reimplement** — see media-api.ts. |
| **Account/AccessService (existing — reuse)** | `crm.account.me` | `leads.manage` authorization for every action here. |
| **NotificationService** — `[Backend Pending]` | — | The frontend today only toasts locally. No server notification is required for parity, but promotion/verify events are natural triggers if/when the Inbox projection (`crm.inbox.*`) should surface them. Do not build unless requested. |
**Layering convention (match existing modules):** Controller/handler (validates action variables, applies auth) → Service (business rules, transitions) → Repository (tenant-scoped data access). DTO projections are shaped for the UI (list vs detail) exactly as the current modules return purpose-built DTOs (e.g. `MemberDTO`, `MailThread`).
---
## 10. Error Handling
Errors propagate to the UI as a thrown `Error`; the SDK hooks expose `error.message` (see `q.error?.message ?? null` throughout the API layer). Permission failures use the SDK's `PermissionError` (OPA). Provide a **stable machine `code` + human `message`**; the UI currently shows the message in a toast/inline state.
| Scenario | When | Suggested code | HTTP-equivalent | UI effect |
|----------|------|----------------|-----------------|-----------|
| Unauthenticated | no/expired session | `unauthenticated` | 401 | `auth-gate` redirects to `/portal/login` |
| Forbidden | member lacks `leads.manage` | `forbidden` / `PermissionError` | 403 | nav item hidden; action rejected |
| Validation — name required | create with empty name | `name_required` | 422 | matches current client guard toast |
| Validation — bad enum | invalid status/source/type | `invalid_value` | 422 | inline/toast |
| Validation — bad email/zip/phone | format fails | `invalid_format` | 422 | |
| Not found | unknown `id` / wrong tenant | `not_found` | 404 | toast; row disappears on refetch |
| Conflict — already verified | `verify` on a verified record | `already_verified` | 409 | toast |
| Conflict — invalid transition | disallowed status change | `invalid_state_transition` | 409 | toast |
| Conflict — duplicate lead | dup phone/address (if enforced) | `duplicate_lead` | 409 | warn (prefer soft-warn, not block) |
| Conflict — duplicate code | code collision (should be internal) | `duplicate_code` | 409 | internal retry |
| Assignee invalid | assignee not a tenant member | `invalid_assignee` | 422 | toast |
| Upload too large | photo > 25 MB | `file_too_large` | 413 | matches media-api guard ("max 25 MB") |
| Upload failed | presigned PUT fails | `upload_failed` | 502 | toast |
| Rate / idempotency replay | duplicate command key | (return original result) | 200 | no-op, safe |
| Server error | unexpected | `internal_error` | 500 | generic toast |
**Error envelope (recommended, consistent with a thrown Error carrying structured data):**
```json
{ "error": { "code": "already_verified",
"message": "This lead has already been verified.",
"details": { "verificationId": "lv_1…" } } }
```
---
## 11. Existing Backend Integration (mandatory conventions)
The new backend **must** follow the patterns already used by Team, Mail, Inbox, Messenger, Media and Account. Never invent a different architecture.
### 11.1 Data door, not REST
The frontend never calls REST endpoints. It calls **actions** through the AppShell SDK:
```ts
// read
const q = useQuery<Page<LeadCardDTO>>("crm.lead.search", { status, query, page, perPage });
// write
await sdk.command("crm.leadVerification.verify", { id, notes });
```
`DataClient.query(action, variables)` and `DataClient.command(action, variables)` call the **Shell BFF's cookie-authed `/data` proxy**; the BFF attaches the session token server-side and forwards to `be-crm`. Apps never hold a token, set an `Authorization` header, or know the domain endpoint.
### 11.2 Transport path
```
browser (SDK) ──► Next rewrite /shell/:path* ──► BFF (BFF_ORIGIN, default http://localhost:4000)/api/:path* ──► be-crm domain API
```
(from `next.config.ts` `rewrites()` and `providers.tsx` `bffBaseUrl = "/shell"`). The session is an **HttpOnly cookie**; that is why the same-origin `/shell` prefix is used (never `/api`, to avoid clobbering the local `/api/geo` route).
### 11.3 Authentication & authorization
- Auth is Supabase-backed via the SDK (`appId: "crm-web"`); the BFF exchanges it for a domain session. When `NEXT_PUBLIC_SUPABASE_URL` is unset the app runs in **mock mode** (see §11.6).
- Authorization is **permission-based** via `crm.account.me` → `{ registered, isMember, roleSlugs, permissions, isSuperadmin }`. Both modules require **`leads.manage`** (already declared in `access.ts` `ALL_CRM_PERMISSIONS` and mapped in `sidebar.tsx` `NAV_PERMISSION`). Enforce it on **every** `crm.lead.*` and `crm.leadVerification.*` action — the frontend gate is UX-only ("be-crm still enforces every action").
- OPA/IIOS may additionally gate row visibility; keep everything **tenant-scoped**.
### 11.4 Response & naming conventions
- **Actions:** `crm.<entity>.<verb>` dot-namespaced; segments camelCase (e.g. `member.setRoles`, `invitation.create`, `inbox.transition`, `mail.reply`). Use `search`/`list` for reads, imperative verbs for writes.
- **DTOs:** server returns UI-shaped DTOs (list vs detail projections). Fields are **camelCase**. Timestamps are **ISO-8601 strings**; the client formats relative/pretty (`relTime`). Nullable fields are `null`, not omitted, where the UI reads them.
- **Collections:** `{ items: T[], meta: { total, page, perPage } }` for paginated sets (Team pattern) — used here for `search`. Bare arrays are acceptable for small always-full lists (Mail/Inbox pattern) but Leads/Verification use the envelope for totals.
- **Commands** are idempotent (idempotency key auto-attached); write-then-refetch is the client norm (`cmd(...)` then `refetch()`), so commands should return the updated entity to allow optimistic UI too.
### 11.5 Media / attachments (reuse exactly)
Site photos must use the existing two-step flow (from `media-api.ts`):
1. `sdk.command("crm.media.presignUpload", { mime, sizeBytes })` → `{ objectKey, uploadUrl }`.
2. Browser `PUT`s the file bytes **directly** to `uploadUrl` (IIOS storage) — be-crm only mints the URL.
3. Store `{ contentRef: objectKey, mimeType, sizeBytes, filename }` on the lead via `crm.lead.attachment.add`.
4. Display via `crm.media.presignDownload { contentRef }` → `{ url }`.
Enforce the **25 MB** cap (`MAX_ATTACHMENT_BYTES = 26_214_400`).
### 11.6 Mock-mode parity (important)
Every existing data-layer file (`team-api`, `mail-api`, `inbox-api`, `messenger-api`, `access`) chooses **mock vs live at module load** via `isShellConfigured()` (`Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL)`). When you wire Leads/Verification to the live door, follow the same shape: create `src/lib/leads-api.ts` and `src/lib/verify-api.ts` exposing one hook per module that returns `{ live, loading, error, …data, …commands, refetch }`, serving the current mock (`LEADS`, `V_LEADS`) when the Shell isn't configured and the live `crm.lead.*` / `crm.leadVerification.*` actions when it is. This keeps the demo working and matches the established convention. *(Frontend wiring task — noted for completeness; the backend itself only needs to implement the live actions.)*
### 11.7 DTO / repository / service pattern summary
- **DTO:** dedicated interfaces per projection (`LeadCardDTO`, `LeadDTO`, `VerificationRowDTO`, `VerificationDTO`) — do not leak raw table rows.
- **Repository:** always parameterize by `tenantId`; never a query without tenant scope.
- **Service:** owns transitions, code generation, promotion transaction, activity emission.
- **Controller/handler:** maps the action name + variables to a service call, validates, applies auth. One handler per action, matching the `crm.<entity>.<verb>` registry the BFF forwards to.
---
## 12. Gaps, Discrepancies & Backend-Pending Items
Explicitly flagged so the backend developer is not surprised.
1. **Everything is mock today.** No `crm.lead.*` or `crm.leadVerification.*` action is called anywhere yet. All actions in §6 are new. Frontend wiring (§11.6) is a separate follow-up task.
2. **`leadType` enum conflict.** `leads-data.ts` defines `LEAD_TYPES = ["Insurance","Retail"]` and mock rows use `"Insurance"`, but the Full-form Job step's `LEAD_TYPE_OPTS = ["Residential","Commercial","Multi-Family"]`. **Recommendation:** store `lead_type` as free text (do not hard-reject), accept both sets, and raise with product which taxonomy is canonical. **[Needs product decision]**
3. **`propertyType` superset.** `PROPERTY_TYPES` (data) = `Single Family / Multi Family / Commercial`; the form offers `Residential / Commercial / Multi-Family / Industrial`. Accept the union.
4. **Priority casing.** Create form emits `Low/Medium/High`; list/detail use `low/medium/high`. **Normalize to lowercase** on write; return lowercase.
5. **Assignee/canvasser/creator are free-text names in the mock** (`"Jesus Gonzales"`, `"Cody Tatum"`, `"Wade Hollis"`). The backend must model these as **member FKs** and resolve display names in DTOs. The verification specialists (`Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`) are not in the `REPS` list — they must exist as members/records too. **[Seed/link required]**
6. **Site photos are placeholders.** `addPhoto()` stores label strings (`"Photo 1"`). Real upload via the media presign flow (§11.5) is **backend-pending** but fully specified.
7. **Detail actions are inert.** Leads "Call / Email / Update Status" and Verification "Call / Verify" footer buttons have **no handlers**. `updateStatus`, `verify` etc. are specified in §6 but the buttons must be wired (frontend task).
8. **Activity timeline is client-synthesized.** `buildActivity()` fabricates timeline entries when a verification lacks `activity[]`. The backend should persist **real** activities (`lead_verification_activities`) and the client should stop synthesizing once live.
9. **No pagination controls in the UI**, yet `TOTAL_LEADS = 35` (only 5 loaded) and the verification footer shows "X of Y". Implement server pagination + `meta.total`; pick a sane default `perPage`. A pager UI is a future frontend task.
10. **No edit / delete / archive / bulk / tags-editor / sort UI.** Endpoints for update/archive/delete are included as **optional/pending** — implement `crm.lead.update` (needed for status/assignment) but treat `archive`/`delete`/bulk as **do-not-build-unless-requested**.
11. **Verification records are never created from the UI** (like the Inbox, they "arrive"). Provide an intake ingestion path (webhook / internal command) **outside** these two module screens — its exact source is not defined by the frontend. **[Needs definition]**
12. **Single-line vs structured address.** Leads store structured address (address/city/state/zip); verifications store a single `address` string (`"2814 Ravenswood Dr, Plano, TX 75023"`). When promoting a verification → lead, parse or carry the single string into `property_address` (best-effort parse; keep the raw string too). **[Parsing rule needed]**
---
*End of document.*
+57
View File
@@ -0,0 +1,57 @@
# AppShell integration (lynkeduppro-crm)
This app now consumes **`@abe-kap/appshell-sdk`** for authentication, identity, and
(eventually) the data door. The integration follows a **strangler pattern**: the SDK
is wired in everywhere, but every real call is gated behind `isShellConfigured()`. When
the Shell isn't configured (no `NEXT_PUBLIC_SUPABASE_URL`), the app behaves exactly as
before — the existing mock portal and static demo user remain the fallback, so the
deployed demo keeps working until the Shell BFF is live.
## What flips it on
Set these (see `.env.local.example`) and redeploy:
| Var | Purpose |
| --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | Presence flips the app from mock → real auth (the `isShellConfigured()` switch). |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe publishable anon key. |
| `NEXT_PUBLIC_BFF_BASE_URL` | Where the browser reaches the BFF (default `/shell`). |
| `BFF_ORIGIN` | Deployed Shell BFF origin; the `/shell/*` rewrite proxies here. |
| `NODE_AUTH_TOKEN` | `read:packages` token to install the SDK from GitHub Packages (local + Vercel). |
## How it's wired
- **`src/app/providers.tsx`** — mounts `<AppShellProvider>` app-wide (via the root
layout). `auth` is passed **only when Supabase is configured**; otherwise the provider
mounts without auth and `useAuth()` reports `unauthenticated` (never crashes).
- **`next.config.ts`** — `transpilePackages` for the SDK + a `/shell/* → ${BFF_ORIGIN}/api/*`
rewrite so the HttpOnly session cookie flows same-origin. We use `/shell` (not `/api`)
to avoid clobbering the existing `/api/geo` route.
- **Login** (`components/portal/login-flow.tsx`) — password sign-in → `login()`,
social buttons → `loginWithOAuth()` (google/microsoft→azure/apple), and the OAuth
return is finished with `completeOAuthLogin()`. A resolved ACE goes straight to
`/dashboard` (Supabase handles MFA server-side; no mock 2-step).
- **Register** (`components/portal/register-flow.tsx`) — email/password sign-ups call
`register()` on finish. (SSO sign-ups already hold a session from OAuth.)
- **Dashboard gate** (`components/dashboard/auth-gate.tsx`) — bounces unauthenticated
visitors to the portal; pass-through when the Shell is off.
- **Topbar** (`components/dashboard/topbar.tsx`) — shows the real identity from the
App Context Envelope (`useAuth().user`), falling back to the static demo user.
## Local build note
`next build` needs Node ≥ 20.9. Installing the SDK needs a GitHub Packages token; for a
token-free local build you can symlink the workspace copy:
```
mkdir -p node_modules/@abe-kap
ln -s /abs/path/to/appshell-sdk node_modules/@abe-kap/appshell-sdk
```
## Not yet wired (follow-ups)
- Register-via-OAuth still uses the mock `startSocial` (login OAuth is real).
- Email/phone OTP steps in register are still mock (SDK has `sendEmailOtp`/`verifyEmailOtp`).
- Passkey enrollment, tenant switcher, logout menu, and the data door (`useQuery`/
`command`) are available in the SDK but not surfaced in the UI yet.
- Backend integration (frontend ↔ be-crm through the data door) is deliberately deferred.
+230
View File
@@ -0,0 +1,230 @@
# Smart Gallery — AI route surface
The Smart Gallery embeds `@photo-gallery/sdk` into the CRM. The SDK never talks to a
model directly: it calls a pluggable `AIProvider`, which the CRM supplies via
`createCrmAIProvider()` in [`src/lib/gallery-ai.ts`](../src/lib/gallery-ai.ts).
Roughly half the intelligence runs **in the browser** for free, and the other half is
proxied through **five server routes** under `/api/gallery/ai/*` so the RunPod API key
never reaches the client.
---
## 1. Where each capability runs
| Capability | Where | Backend | Needs a key? |
| --- | --- | --- | --- |
| Object detection (default) | Browser | TensorFlow.js COCO-SSD (80 COCO classes) | No |
| Object detection (opt-in) | **Server** | RunPod YOLO → `/classify` | Yes |
| Face detection + recognition | Browser | `@vladmandic/face-api` (128-D descriptors → People) | No |
| OCR / document search | Browser | `tesseract.js` | No |
| Semantic ("beach photos") search | Browser | CLIP via `@huggingface/transformers` | No |
| Background removal (default) | Browser | `@imgly/background-removal` (WASM) | No |
| Background removal (opt-in) | **Server** | RunPod U²-Net → `/edit` | Yes |
| Generative edits, restore, upscale, outpaint | **Server** | `/edit` | Yes |
| Speech-to-text | **Server** | `/transcribe` | Yes |
| Audio denoise | **Server** | `/denoise` | Yes |
| Camera tilt / auto-straighten | **Server** | `/tilt` | Yes |
Every in-browser model is loaded with `await import(...)` on first use, so none of it
lands in the initial bundle. **Every capability degrades gracefully** — a model that
fails to load returns `[]` / `''` / `null` with a `console.warn` rather than throwing,
so a blocked CDN never breaks the gallery UI.
---
## 2. Auth model
All five routes share one gate: `requireGallerySession()` in
[`src/lib/server/session.ts`](../src/lib/server/session.ts).
These routes proxy a **paid, rate-limited GPU backend** using a secret held only on the
server. An unauthenticated route here is not just an information leak — it is an open
invitation to spend the operator's GPU budget. (The upstream SDK demo's routes are
completely unauthenticated; that is the single biggest thing this port fixes.)
**When the Shell is configured** (`NEXT_PUBLIC_SUPABASE_URL` is set): the incoming
`cookie` header is forwarded to `${BFF_ORIGIN}/api/session/context` and only a `200`
is accepted.
- `401` upstream → `401 { error: "Not signed in" }`
- any other non-200, or a network/timeout failure → `503 { error: "Session service unavailable" }`
(**fail closed** — if we cannot prove a session, we do not spend GPU budget)
- **Nothing is cached.** A cached "yes" would keep a revoked session alive, so every AI
request costs one BFF round trip. Correctness over latency for a spend gate.
- The BFF is trusted absolutely — `BFF_ORIGIN` must only ever point at an origin the
operator controls.
**When the Shell is NOT configured** (local demo mode): the request is allowed and a
warning is logged once. **Never deploy to a public origin with the Shell unconfigured
and a real `RUNPOD_API_KEY` present** — that combination is an open, billable endpoint.
This is authentication only, not authorization. Per-resource gallery policy lives in
be-crm behind the `crm.gallery` resource.
---
## 3. Rate limits
[`src/lib/server/rate-limit.ts`](../src/lib/server/rate-limit.ts) — a fixed-window
counter keyed by the authenticated principal when known, otherwise the first hop of
`x-forwarded-for`. Each route has its own namespace, so spending your `edit` budget
does not consume your `classify` budget. Exceeding it returns
`429 { error: "Too many requests — slow down." }` with a `Retry-After` header.
| Route | Limit |
| --- | --- |
| `/classify` | 30 / min |
| `/tilt` | 30 / min |
| `/transcribe` | 20 / min |
| `/denoise` | 20 / min |
| `/edit` | 12 / min (most expensive) |
> **This limiter is per-instance and in-memory.** With N instances behind a load
> balancer a caller gets up to N x the budget, and a restart clears all counters. It is
> a cost guard, not a security boundary. **Replace it with Redis before running more
> than one instance.** The `x-forwarded-for` key is also client-controlled unless a
> trusted proxy overwrites it — the session gate, not this, is the security boundary.
---
## 4. The routes
All five are `POST` only, and all declare `runtime = "nodejs"`, `maxDuration = 60`,
`dynamic = "force-dynamic"`. Failures always return `{ error: string }`.
Shared status codes: `400` invalid body/params · `401` not signed in · `413` payload too
large · `429` rate limited · `500` server misconfigured (e.g. missing `RUNPOD_API_KEY`) ·
`502` upstream failed · `503` not configured / session service unavailable ·
`504` upstream timed out (the RunPod client's budget is 55s, under the 60s cap).
Upstream error bodies are truncated to a **160-character excerpt**; the RunPod key is
never included in any response.
### `POST /api/gallery/ai/classify` — object detection
Backed by `RUNPOD_YOLO_URL`. Max ~4 MB of base64. Returns boxes as **fractions 0..1**
of the image, matching the SDK's `DetectedObject`.
```jsonc
// request
{ "imageBase64": "…", "width": 1280, "height": 853 }
// response
{ "objects": [ { "label": "excavator", "confidence": 0.91,
"box": { "x": 0.12, "y": 0.30, "width": 0.25, "height": 0.40 } } ] }
```
### `POST /api/gallery/ai/edit` — generative image editing
The backend is pluggable via `AI_EDIT_PROVIDER` (`auto` | `runpod` | `local` |
`huggingface` | `gemini` | `none`). Image + mask are budgeted **together** against the
~4 MB cap. Prompts come from an allow-listed op set — arbitrary server-side prompts are
never accepted, and free-text is clamped to 500 chars.
```jsonc
// request
{ "imageBase64": "…", "mimeType": "image/jpeg",
"op": { "type": "restore" }, // or prompt | colorize | replace-sky |
// magic-eraser | generative-fill | upscale |
// remove-background
"maskBase64": "…", // required for magic-eraser / generative-fill
"params": { "strength": 0.8 } }
// response
{ "imageBase64": "…", "mimeType": "image/png" }
```
Op → RunPod endpoint: `restore`/`upscale``RUNPOD_UPSCALE_URL` ·
`prompt`/`colorize``RUNPOD_SD_IMG2IMG_URL` · `replace-sky`/`magic-eraser`/
`generative-fill``RUNPOD_SD_INPAINT_URL` · `remove-background`
`RUNPOD_BG_REMOVE_URL`. `upscale`, `magic-eraser` and `generative-fill` are RunPod-only
and return `400` under another backend. Outpaint is client-side padding plus a
`generative-fill` call — it needs no separate route.
### `POST /api/gallery/ai/tilt` — camera tilt
Backed by `RUNPOD_TILT_URL`. Max ~4 MB (`413` over). Only reachable when
`NEXT_PUBLIC_APG_RUNPOD_TILT=true`, which also reveals the editor's Auto-straighten button.
```jsonc
{ "image": "…" } { "rollDegrees": -2.4, "pitchDegrees": 1.1, "fovDegrees": 68.2 }
```
### `POST /api/gallery/ai/transcribe` — speech to text
Backed by `RUNPOD_STT_URL`. Expects base64 **WAV 16 kHz mono PCM16**. Max ~8 MB (`413`).
```jsonc
{ "audio": "…", "language": "en" }
{ "transcript": "…", "segments": [ { "text": "…", "startSec": 0, "endSec": 1.8 } ] }
```
### `POST /api/gallery/ai/denoise` — audio noise removal
Backed by `RUNPOD_AUDIO_DENOISE_URL`. Expects base64 **WAV 48 kHz mono PCM16**. Max
~12 MB (`413`). Used before transcription on noisy sites.
```jsonc
{ "audio": "…" } { "audio": "…" }
```
---
## 5. Environment variables
See [`.env.local.example`](../.env.local.example) for the fully commented template.
| Var | Backs |
| --- | --- |
| `RUNPOD_API_KEY` | all five routes (server-only — never `NEXT_PUBLIC_`) |
| `RUNPOD_YOLO_URL` | `/classify` |
| `RUNPOD_SD_IMG2IMG_URL` | `/edit` — prompt, colorize, maskless replace-sky |
| `RUNPOD_SD_INPAINT_URL` | `/edit` — magic-eraser, generative-fill, outpaint, masked replace-sky |
| `RUNPOD_UPSCALE_URL` | `/edit` — restore, upscale |
| `RUNPOD_BG_REMOVE_URL` | `/edit` — remove-background |
| `RUNPOD_STT_URL` | `/transcribe` |
| `RUNPOD_AUDIO_DENOISE_URL` | `/denoise` |
| `RUNPOD_TILT_URL` | `/tilt` |
| `AI_EDIT_PROVIDER` | which backend `/edit` uses |
| `BFF_ORIGIN` | the session gate |
Client switches (`NEXT_PUBLIC_`, **inlined at build time** — a change needs a rebuild,
and each must be the literal string `"true"`):
`NEXT_PUBLIC_APG_RUNPOD_DETECT`, `NEXT_PUBLIC_APG_RUNPOD_BG`, `NEXT_PUBLIC_APG_RUNPOD_TILT`.
> The SDK's standalone demo also reads a family of `NEXT_PUBLIC_APG_*` **theming** vars
> (`_THEME`, `_ACCENT`, `_RADIUS`, `_BG_DARK`, `_SIDEBAR_BG_*`, …). Those are **not used
> here** — the CRM passes `themeTokens` to the gallery directly so it inherits the
> dashboard's design tokens. Setting them in this app has no effect.
---
## 6. CSP / CDN hosts
**The CRM ships no Content-Security-Policy today**, so the in-browser models load
without any allow-listing. This section is what would need to be permitted **if a CSP
is ever added** — miss any of these and the affected capability silently degrades
(returns empty + `console.warn`) rather than erroring visibly, which makes it easy to
misdiagnose.
| Host | Needed by | Directive |
| --- | --- | --- |
| `https://cdn.jsdelivr.net` | face-api models; tesseract worker, WASM core **and** language data | `script-src`, `connect-src`, `worker-src` |
| `https://huggingface.co`, `https://cdn-lfs.huggingface.co` | CLIP model weights (transformers.js) | `connect-src` |
| `https://storage.googleapis.com` | TensorFlow.js COCO-SSD model | `connect-src` |
| `https://staticimgly.com` | `@imgly/background-removal` WASM + assets | `connect-src`, `worker-src` |
Also required by the ML runtimes themselves:
- **`wasm-unsafe-eval` in `script-src`** — ONNX Runtime (CLIP), tesseract-core and the
`@imgly` remover are all WebAssembly. Without it, semantic search, OCR and in-browser
background removal all fail.
- **`blob:` in `worker-src`/`child_src`** — tesseract and the WASM runtimes spawn
workers from blob URLs.
- **`data:` and `blob:` in `img-src`** — canvas round-trips and generated results.
Notes:
- All three tesseract assets (worker, core, tessdata) are pinned to **jsDelivr** on
purpose. Left at its defaults, tesseract fetches language data from
`tessdata.projectnaptha.com`, which would be a second host to allow-list.
- `tesseract.js` is pinned to **exactly `5.1.1`** (no caret) in `package.json` because
the worker CDN URL embeds the version — a floating range would let the worker drift
out of sync with the installed main-thread code.
- The tfjs providers prefer the **WebGL** backend, which avoids `eval` and is therefore
CSP-friendly; they fall back to the default backend if WebGL is unavailable.
- The SDK's own demo app runs a strict per-request nonce CSP with these hosts already
allow-listed — see that repo's middleware for a working reference.
@@ -0,0 +1,240 @@
# Messaging UI SDK — Design
**Date:** 2026-07-17
**Status:** Approved, pending implementation plan
## Problem
Messaging UI is rewritten from scratch in every app that needs it. `lynkeduppro-crm`
has a rich, working messenger — conversation list, thread view, bubbles, reactions,
reply threading, typing, read receipts — built as 352 lines in
`src/components/dashboard/messenger.tsx` over 371 lines in `src/lib/messenger-api.ts`.
None of it is reusable.
### Why not just use `@insignia/iios-message-web`?
Because it does not solve this problem, and the CRM already rejected it.
`iios-message-web` is 109 lines across 2 files. It is fully headless: its only JSX is
the context provider element itself. It exports `MessageProvider`, `useThread`,
`useMessages` and a `Message` type — nothing more. It ships no components, no CSS, no
theming.
It also guessed its API wrong. `useMessages.send` narrows the options bag to
`{ contentRef? }`, while the underlying `MessageSocket.sendMessage` accepts
`parentInteractionId`, `mentions`, and `attachment`. Threading, mentions and
attachments are unreachable through its public API. The socket is held in a
module-private context with no escape hatch. It has zero consumers outside the iios
repo, and its own docs reference a `useSendMessage` hook that does not exist.
The CRM consequently bypassed it and depends on `@insignia/iios-kernel-client`
directly.
**The lesson drives this design:** the headless layer is already an SDK
(`iios-kernel-client` — sockets, threads, receipts, typing, published, consumed). A
second headless package saves no app any work. The unsolved part is the UI.
### Why tower is not a consumer
Tower's messaging is a WhatsApp group ingest → moderate → forward pipeline, not chat.
There is no `Conversation` model; `Message` is a captured group post keyed by
`senderJid` + `sourceGroupId` with a moderation `status` enum
(`RAW/PENDING/APPROVED/...`) — no recipient, no delivery state. "Send" is a BullMQ job
rate-limited to 20 forwards/minute to avoid WhatsApp bans. There is no
socket.io/websocket/SSE in the browser anywhere in the repo. Its `threads` and
`drafts` mean different things than a chat SDK's would.
Tower would pay the abstraction cost for realtime machinery it never turns on. It is
explicitly out of scope.
## Constraint: one real consumer
`lynkeduppro-crm` is the only consumer. Genericity is not achievable by intent — it is
forced by a second consumer. This design therefore ports only what is already proven
in production and refuses to invent abstraction for imagined needs. `iios-message-web`
is the cautionary example of the opposite approach.
## Architecture
One package, `@insignia/messaging-ui`, published to the existing Gitea registry
(`https://git.lynkedup.cloud/api/packages/insignia/npm/`). React as a peer dependency.
```
@insignia/messaging-ui
. → components + provider + hooks
./styles.css → structural CSS + token defaults
./adapters/kernel → optional iios-kernel-client adapter
./adapters/mock → in-memory adapter for demos/tests
```
**The core has zero transport knowledge.** `iios-kernel-client` is reachable only via
the optional `./adapters/kernel` subpath, so an app on a different backend never pulls
socket code. This is the specific mistake `iios-message-web` made by welding itself to
`MessageSocket`.
This boundary is load-bearing for the actual consumer: the CRM does **not** talk to
iios directly. It routes messaging through be-crm's data door (`crm.messenger.*`) via
`@abe-kap/appshell-sdk`, socket-primary with a 4s REST poll fallback. An SDK that
hardcoded `iios-kernel-client` could not be adopted by the only app that wants it.
## The adapter contract
Lifted from the existing `MessengerData`/`ThreadData` interfaces in
`src/lib/messenger-api.ts`, which already survived two implementations (live + mock).
Two implementations is the minimum real evidence that a seam is genuine rather than
imagined. This contract was not designed for an SDK — it earned its shape.
```ts
interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
react?(messageId: string, emoji: string): Promise<void>;
upload?(file: File): Promise<{ url: string; mime: string; name: string }>;
currentActorId(): string | null;
}
interface SendOpts {
parentInteractionId?: string;
attachment?: { url: string; mime: string; name: string };
}
```
### Graceful degradation
`react` and `upload` are optional. When an adapter omits them the UI hides the
reaction picker or the attach button respectively. This is how one component set
serves both a full CRM messenger and a stripped-down widget without a `mode` prop.
### `currentActorId` fixes a live bug
Today the CRM infers the current actor id by scanning for a message you sent:
```ts
// src/lib/messenger-socket.tsx — current behaviour
const mine = socketMsgs.find((m) => m.mine && m.actorId);
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
```
Until you have sent a message in a thread, `myActorId` is `null`. Because the REST
poll fallback computes `mine: !!myActorId && m.actorId === myActorId`, **every message
renders as not-yours** in that state. The root cause is that the kernel's receipt
event carries no `threadId`, making it a global stream the CRM compensates for.
Making identity an explicit adapter responsibility eliminates this class of bug rather
than porting it. The two-tier socket/poll fallback stays in the adapter, not the SDK —
the CRM's adapter keeps its 4s poll; a socket-only app implements `subscribe` and
never polls.
## Components
Composable primitives plus one all-in-one for drop-in use:
```tsx
<MessagingProvider adapter={adapter}>
<Messenger onNewChat={openPicker} /> {/* all-in-one: list + thread */}
{/* ...or compose: */}
<ConversationList onSelect={setId} renderRow={custom} />
<ThreadView threadId={id} />
<Composer threadId={id} />
</MessagingProvider>
```
Hooks remain exported (`useConversations`, `useThread`, `useMessages`) so a host
wanting entirely custom UI can use the SDK headlessly. This makes `iios-message-web`'s
use case a strict subset of this package rather than a competitor.
### Explicitly out of scope
- **Inbox.** Coupled to iios semantics, not chat transport. Items are projected
server-side by iios from domain events (`MENTION`, `NEEDS_REPLY`, `SUPPORT_UPDATE`,
`CRM_OWNER_INTEREST`); authz is OPA policy. A chat SDK cannot own this.
- **People picker / directory.** Fed by `crm.messenger.directory`. "Who exists and who
may I message" is host and tenant territory. `<Messenger>` takes an `onNewChat`
callback; the host renders its own picker.
- **Presence.** No consumer needs it.
## Theming
Structural CSS with token defaults, overridden by the host. No Tailwind, no CSS-in-JS,
no build coupling — the CRM has no shadcn and near-zero Tailwind (its real styling is
1142 lines of hand-rolled `dashboard.css` plus inline style objects), so a
Tailwind-based SDK would force a restyle of the only consumer.
```css
:root {
--msg-font; --msg-radius; --msg-gap;
--msg-bubble-own-bg; --msg-bubble-other-bg;
--msg-accent; --msg-muted; --msg-surface; --msg-border;
}
```
Every component accepts `className`; `<Messenger>` accepts a `classNames` slot map for
per-part overrides. The CRM's existing `#6366f1 → #8b5cf6` group-avatar gradient
becomes a token value rather than a hardcode.
## Attachments
The SDK renders attachments (image thumbnail, file chip, download) and calls
`adapter.upload(file)`, passing the result into `send`. **Storage, auth, and
size/mime limits are host concerns** — baking in an upload target would break the next
app. The attach button is hidden when `upload` is absent.
`MessageSocket.sendMessage` already accepts an `attachment` field, so this exercises
an existing wire contract rather than inventing one. No consumer has exercised it yet;
the CRM has no file upload anywhere today.
## Data flow
1. Host constructs an adapter (CRM: wrapping appshell data door + socket).
2. `MessagingProvider` holds the adapter in context.
3. `useConversations` calls `listConversations`; `useMessages(threadId)` calls
`history` then `subscribe`.
4. `Composer` calls `send` with optimistic append; on rejection the optimistic message
is rolled back and the input text restored (matching current CRM behaviour).
5. `subscribe` events reconcile against optimistic state by message id.
## Error handling
- Adapter method rejection surfaces via hook `error` state; components render an
inline error affordance, never throw.
- Optimistic send failure restores composer text — the CRM's current behaviour, kept.
- `subscribe` disconnect is the adapter's problem, not the SDK's. The SDK renders a
`connected: boolean` from the adapter as a banner (the CRM's existing "Demo mode"
banner generalises to this).
## Validation
The migration is the validation. There is no second app, so the honest bar is:
1. Rewrite the CRM's `messenger.tsx` to consume the SDK; its data-door implementation
becomes `CrmMessagingAdapter`. **Success = identical behaviour with the 352-line
component deleted**, and the mock adapter preserving demo-mode fallback.
2. Then `support.tsx`'s `MessageCenter` — currently pure `setTimeout` theatre with no
backend — becomes a zero-risk second surface.
Two surfaces in one app is not a true second consumer. It is the best honest test
available of the adapter boundary, and it should be understood as such. **The design
should be revisited when a genuine second app appears** rather than treated as settled.
## Testing
- **Component tests against the mock adapter** — no network. This is the payoff of the
injected seam.
- **`CrmMessagingAdapter` tested against the contract** independently of UI.
- **A shared adapter conformance suite** any adapter can run, so the kernel and CRM
adapters are verified against one definition of correct.
- Explicit regression test for the `currentActorId` bug: messages render as own before
the user has sent anything in the thread.
## Open questions
- Package name: `@insignia/messaging-ui` assumed, not confirmed.
- Whether `CrmMessagingAdapter` lives in the CRM repo or ships as
`./adapters/crm`. Preference: the CRM repo — it depends on appshell-sdk, which the
SDK must not.
+10 -1
View File
@@ -1,7 +1,16 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// The SDKs ship ESM/TS source (their `exports` point at src/), so Next must
// transpile them rather than treat them as prebuilt CJS.
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui", "@photo-gallery/sdk"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
async rewrites() {
const bff = process.env.BFF_ORIGIN ?? "http://localhost:4000";
return [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }];
},
};
export default nextConfig;
+1953 -43
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -6,18 +6,31 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"sync:gallery-sdk": "node scripts/sync-gallery-sdk.mjs"
},
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.7",
"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk",
"@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0",
"@vladmandic/face-api": "^1.7.15",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.21.0",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"tesseract.js": "5.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/leaflet": "^1.9.21",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

+50
View File
@@ -0,0 +1,50 @@
/* Web Push service worker for CRM offline notifications.
*
* Receives push payloads from IIOS (WebPushDelivery), shows a system notification, and on click
* focuses an open CRM tab (or opens one) and posts the thread to deep-link to. The payload shape is
* IIOS's NotificationPayload: { title, body, data: { threadId, interactionId? } }.
*
* Served from /public at /push-sw.js → root scope ('/'), so it controls the whole app.
*/
self.addEventListener("push", (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: "New notification", body: event.data ? event.data.text() : "" };
}
const title = payload.title || "New message";
const threadId = payload.data && payload.data.threadId;
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || "",
// Coalesce repeated pings for the same thread into one notification.
tag: threadId ? `thread:${threadId}` : undefined,
renotify: Boolean(threadId),
data: payload.data || {},
icon: "/icons/i_bell.svg",
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const threadId = event.notification.data && event.notification.data.threadId;
event.waitUntil(
(async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Prefer an already-open CRM tab: focus it and tell the app which thread to open.
for (const client of all) {
if ("focus" in client) {
await client.focus();
client.postMessage({ type: "notif-click", threadId: threadId || null });
return;
}
}
// No tab open — launch one deep-linked via the query string.
const url = threadId ? `/dashboard?thread=${encodeURIComponent(threadId)}` : "/dashboard";
if (self.clients.openWindow) await self.clients.openWindow(url);
})(),
);
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Re-sync the vendored @photo-gallery/sdk from the sibling SDK checkout.
*
* The SDK is not published to a registry yet, so it is vendored into `vendor/photo-gallery-sdk`
* and depended on as `file:./vendor/photo-gallery-sdk` — a path INSIDE this repo, so CI and Vercel
* (which only ever check out this repo) can resolve it. See vendor/README.md.
*
* npm run sync:gallery-sdk
* git add vendor/photo-gallery-sdk && git commit -m "chore: sync @photo-gallery/sdk"
*
* Only what the package would publish is copied. Its `node_modules` is deliberately left behind:
* without it the SDK's sources resolve `@types/react` from this repo (React 19) instead of the
* pnpm workspace's React 18, which is what lets them type-check here.
*/
import { cp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source =
process.env.GALLERY_SDK_PATH ??
resolve(root, "../advance-photo-gallery-web-sdk/packages/photo-sdk");
const target = resolve(root, "vendor/photo-gallery-sdk");
const ENTRIES = ["src", "package.json", "README.md"];
const exists = async (p) => {
try {
await stat(p);
return true;
} catch {
return false;
}
};
if (!(await exists(source))) {
console.error(`[sync:gallery-sdk] SDK checkout not found at ${source}`);
console.error(" Clone advance-photo-gallery-web-sdk beside this repo, or set GALLERY_SDK_PATH.");
process.exit(1);
}
for (const entry of ENTRIES) {
const from = resolve(source, entry);
if (!(await exists(from))) continue;
const to = resolve(target, entry);
await rm(to, { recursive: true, force: true });
await cp(from, to, { recursive: true });
console.log(`[sync:gallery-sdk] ${entry}`);
}
/*
* Strip devDependencies + scripts from the vendored manifest.
*
* npm never installs a published package's devDependencies, but it DOES install them for a local
* `file:` path package. Left in place, the SDK's `@types/react@18` lands in
* vendor/photo-gallery-sdk/node_modules and its sources then type-check against React 18 inside a
* React 19 program — "Type 'bigint' is not assignable to type 'ReactNode'". Removing them makes the
* vendored copy behave exactly like the tarball it stands in for.
*/
const manifestPath = resolve(target, "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
delete manifest.devDependencies;
delete manifest.scripts;
manifest._vendored = {
from: "advance-photo-gallery-web-sdk/packages/photo-sdk",
note: "Generated by scripts/sync-gallery-sdk.mjs — do not hand-edit. See vendor/README.md.",
};
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log("[sync:gallery-sdk] package.json (devDependencies + scripts stripped)");
console.log("[sync:gallery-sdk] done — commit vendor/photo-gallery-sdk, then restart the dev server.");
+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>
);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Shared entry gate for every /api/gallery/ai/* route: authenticate, then
* throttle. Lives in a `_`-prefixed file so the App Router never treats it as a
* route (only `route.ts` defines an endpoint).
*
* Order matters: we authenticate FIRST so the rate limit can be keyed by
* principal rather than by a spoofable `x-forwarded-for` hop wherever possible.
* The session check is one cheap BFF round trip; the work it guards is a GPU
* call, so paying it before throttling is the right trade.
*
* SERVER-ONLY.
*/
import { NextResponse } from "next/server";
import { limit } from "@/lib/server/rate-limit";
import { rateLimitKey, requireGallerySession } from "@/lib/server/session";
/** Per-minute budgets, per the Smart Gallery route contract. */
export const RATE_LIMITS = {
classify: 30,
edit: 12,
tilt: 30,
transcribe: 20,
denoise: 20,
} as const;
const WINDOW_MS = 60_000;
export type GuardResult =
| { ok: true; principalId?: string }
/** Ready-to-return error response — the route should return it unchanged. */
| { ok: false; response: NextResponse };
/**
* @param route Which budget to apply (also namespaces the limiter key so a
* caller's `edit` spend does not consume their `classify` budget).
*/
export async function guard(req: Request, route: keyof typeof RATE_LIMITS): Promise<GuardResult> {
const session = await requireGallerySession(req);
if (!session.ok) {
return {
ok: false,
response: NextResponse.json({ error: session.error }, { status: session.status }),
};
}
const key = `${route}:${rateLimitKey(req, session.principalId)}`;
const { ok, retryAfter } = limit(key, RATE_LIMITS[route], WINDOW_MS);
if (!ok) {
return {
ok: false,
response: NextResponse.json(
{ error: "Too many requests — slow down." },
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
),
};
}
return { ok: true, principalId: session.principalId };
}
+71
View File
@@ -0,0 +1,71 @@
import { type NextRequest, NextResponse } from "next/server";
import { RunpodError } from "@/lib/server/runpod/client";
import { rpDetect } from "@/lib/server/runpod/endpoints";
import { guard } from "../_guard";
export const runtime = "nodejs";
export const maxDuration = 60;
export const dynamic = "force-dynamic";
/**
* Object-detection proxy for the RunPod YOLO construction-material classifier (#1).
* The key + endpoint URL stay server-side. The client calls this only when
* NEXT_PUBLIC_APG_RUNPOD_DETECT is on; otherwise detection runs fully in-browser
* (COCO-SSD) with no server round-trip. Returns the SDK's DetectedObject[] shape
* (box as 0..1 fractions) so it drops straight into the Objects browser / smart
* albums / search.
*
* POST { imageBase64, width, height } -> { objects: [{ label, confidence, box }] }
* Auth: session-gated (see lib/server/session.ts). Rate limit: 30/min.
*/
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — under serverless body limits
export async function POST(req: NextRequest) {
const gate = await guard(req, "classify");
if (!gate.ok) return gate.response;
if (!process.env.RUNPOD_API_KEY || !process.env.RUNPOD_YOLO_URL) {
return NextResponse.json(
{ error: "RunPod detection is not configured (set RUNPOD_API_KEY + RUNPOD_YOLO_URL)." },
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { imageBase64, width, height } = (body ?? {}) as {
imageBase64?: unknown;
width?: unknown;
height?: unknown;
};
if (
typeof imageBase64 !== "string" ||
imageBase64.length === 0 ||
imageBase64.length > MAX_BASE64
) {
return NextResponse.json({ error: "Invalid or oversized image." }, { status: 400 });
}
const w = Number(width);
const h = Number(height);
try {
const objects = await rpDetect(
imageBase64,
Number.isFinite(w) && w > 0 ? w : 1,
Number.isFinite(h) && h > 0 ? h : 1,
);
return NextResponse.json({ objects });
} catch (err) {
const message = err instanceof Error ? err.message : "Detection failed.";
const status = err instanceof RunpodError ? err.status : 502;
return NextResponse.json({ error: message }, { status });
}
}
+48
View File
@@ -0,0 +1,48 @@
import { type NextRequest, NextResponse } from "next/server";
import { RunpodError } from "@/lib/server/runpod/client";
import { rpDenoiseAudio } from "@/lib/server/runpod/endpoints";
import { guard } from "../_guard";
export const runtime = "nodejs";
export const maxDuration = 60; // cold-start denoise worker can take a while
export const dynamic = "force-dynamic";
/**
* Audio noise-removal proxy. Accepts base64 WAV (48 kHz mono PCM16, produced
* in-browser) and returns a cleaned base64 WAV. Calls the RunPod audio-denoise
* endpoint (RUNPOD_AUDIO_DENOISE_URL) — key stays server-side. Used before
* transcription on noisy sites.
*
* POST { audio } -> { audio }
* Auth: session-gated. Rate limit: 20/min.
*/
const MAX_BASE64 = 12_000_000; // ~9 MB decoded WAV
export async function POST(req: NextRequest) {
const gate = await guard(req, "denoise");
if (!gate.ok) return gate.response;
let body: { audio?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
const audio = typeof body.audio === "string" ? body.audio : "";
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
if (audio.length > MAX_BASE64) {
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
}
try {
const { audioB64 } = await rpDenoiseAudio(audio);
return NextResponse.json({ audio: audioB64 });
} catch (e) {
const msg = e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Denoise failed.";
return NextResponse.json({ error: msg }, { status: 502 });
}
}
+419
View File
@@ -0,0 +1,419 @@
import { type NextRequest, NextResponse } from "next/server";
import { RunpodError } from "@/lib/server/runpod/client";
import {
rpImg2Img,
rpInpaint,
rpRemoveBackground,
rpUpscale,
} from "@/lib/server/runpod/endpoints";
import { guard } from "../_guard";
export const runtime = "nodejs";
export const maxDuration = 60; // SD / cold-start models can take a while
export const dynamic = "force-dynamic";
/**
* Generative image-edit proxy. The BACKEND is pluggable — pick one with env
* `AI_EDIT_PROVIDER` (default `auto`):
*
* - `runpod` → RunPod serverless GPU endpoints (one per model). Maps each
* op → endpoint: restore/upscale → Real-ESRGAN (#7), colorize
* → img2img (#10), replace-sky / magic-eraser / generative-fill
* → SD 3.5 masked inpaint (#9), prompt → SD 3.5 img2img (#10).
* Env: RUNPOD_API_KEY + per-model RUNPOD_*_URL. Key stays
* server-side.
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
* SD.Next img2img API). Env: LOCAL_SD_URL.
* - `huggingface` → Hugging Face Inference API. Env: HF_API_TOKEN, HF_IMAGE_MODEL.
* - `gemini` → Google Gemini image model (needs a billed key for image output).
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
* - `auto` → first configured of: runpod → local → huggingface → gemini.
*
* NOTE: `remove-background` runs in-browser by default (@imgly, no key), so it
* usually never reaches here. Object detection uses its own route (./classify).
*
* POST { imageBase64, mimeType?, op, maskBase64?, params? } -> { imageBase64, mimeType }
* Auth: session-gated. Rate limit: 12/min (the most expensive route).
*/
const OP_PROMPTS: Record<string, string> = {
restore:
"Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.",
colorize: "Colorize this image with natural, realistic, well-balanced colors.",
"replace-sky":
"Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.",
};
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits
type Provider = "runpod" | "local" | "huggingface" | "gemini" | "none";
function resolveProvider(): Provider {
const explicit = (process.env.AI_EDIT_PROVIDER || "auto").toLowerCase();
if (
explicit === "runpod" ||
explicit === "local" ||
explicit === "huggingface" ||
explicit === "gemini"
)
return explicit;
if (explicit === "none") return "none";
// auto: prefer RunPod GPU endpoints, then a private local server, then HF, then Gemini.
// Detect RunPod when the key + ANY image endpoint URL is set (an upscale/colorize-only
// deployment is valid — not just the SD ones).
if (
process.env.RUNPOD_API_KEY &&
(process.env.RUNPOD_SD_IMG2IMG_URL ||
process.env.RUNPOD_SD_INPAINT_URL ||
process.env.RUNPOD_UPSCALE_URL ||
process.env.RUNPOD_COLORIZE_URL ||
process.env.RUNPOD_BG_REMOVE_URL)
)
return "runpod";
if (process.env.LOCAL_SD_URL) return "local";
if (process.env.HF_API_TOKEN) return "huggingface";
if (process.env.GEMINI_API_KEY) return "gemini";
return "none";
}
/** Ops that only the RunPod (mask/fixed-function) backend can serve. */
const RUNPOD_ONLY_OPS = new Set(["upscale", "magic-eraser", "generative-fill"]);
interface EditResult {
imageBase64: string;
mimeType: string;
}
export async function POST(req: NextRequest) {
const gate = await guard(req, "edit");
if (!gate.ok) return gate.response;
const provider = resolveProvider();
if (provider === "none") {
return NextResponse.json(
{
error:
"AI image editing is not configured. Set AI_EDIT_PROVIDER=runpod + RUNPOD_API_KEY + the per-model RUNPOD_*_URL vars (RunPod GPU), or LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key.",
},
{ status: 503 },
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { imageBase64, mimeType, op, maskBase64, params } = (body ?? {}) as {
imageBase64?: unknown;
mimeType?: unknown;
op?: { type?: string; prompt?: string; factor?: number };
maskBase64?: unknown;
params?: unknown;
};
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
return NextResponse.json({ error: "Invalid image." }, { status: 400 });
}
const hasMask = typeof maskBase64 === "string" && maskBase64.length > 0;
// Image + mask share one request body — budget them together against the cap.
if (imageBase64.length + (hasMask ? (maskBase64 as string).length : 0) > MAX_BASE64) {
return NextResponse.json(
{ error: "Image (plus mask) is too large — try a smaller image." },
{ status: 400 },
);
}
const safeMime =
typeof mimeType === "string" && /^image\/(jpeg|png|webp)$/.test(mimeType)
? mimeType
: "image/jpeg";
const opType = op?.type ?? "";
if (provider !== "runpod" && RUNPOD_ONLY_OPS.has(opType)) {
return NextResponse.json(
{ error: "This edit needs the RunPod backend (set AI_EDIT_PROVIDER=runpod)." },
{ status: 400 },
);
}
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
let instruction = "";
if (opType === "prompt" || opType === "generative-fill") {
const p = typeof op?.prompt === "string" ? op.prompt.trim() : "";
if (!p) return NextResponse.json({ error: "Empty prompt." }, { status: 400 });
instruction = p.slice(0, 500);
} else if (opType === "replace-sky") {
instruction =
typeof op?.prompt === "string" && op.prompt.trim()
? `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`
: OP_PROMPTS["replace-sky"]!;
} else if (opType === "magic-eraser") {
instruction =
"Fill the selected region with a clean, seamless, plausible background. Photorealistic.";
} else if (OP_PROMPTS[opType]) {
instruction = OP_PROMPTS[opType]!;
} else if (opType !== "upscale" && opType !== "remove-background") {
return NextResponse.json({ error: "Unsupported operation." }, { status: 400 });
}
try {
let result: EditResult;
if (provider === "runpod")
result = await editRunPod(
op ?? {},
imageBase64,
instruction,
hasMask ? (maskBase64 as string) : undefined,
params,
);
else if (provider === "local") result = await editLocal(instruction, imageBase64);
else if (provider === "huggingface") result = await editHuggingFace(instruction, imageBase64);
else result = await editGemini(instruction, imageBase64, safeMime);
return NextResponse.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : "AI request failed.";
const status = err instanceof AiError || err instanceof RunpodError ? err.status : 502;
return NextResponse.json({ error: message }, { status });
}
}
// ---------------------------------------------------------------------------
// Backend: RunPod serverless GPU endpoints (one model per endpoint).
// Each op maps to its endpoint; the API key + URLs stay server-side.
// ---------------------------------------------------------------------------
interface SdParams {
negativePrompt?: string;
strength?: number;
steps?: number;
seed?: number;
guidanceScale?: number;
}
function sanitizeParams(raw: unknown): SdParams {
const p = (raw ?? {}) as Record<string, unknown>;
const out: SdParams = {};
if (typeof p.negativePrompt === "string" && p.negativePrompt.trim())
out.negativePrompt = p.negativePrompt.trim().slice(0, 300);
const strength = Number(p.strength);
if (Number.isFinite(strength)) out.strength = Math.max(0, Math.min(1, strength));
const steps = Number(p.steps);
if (Number.isFinite(steps)) out.steps = Math.max(1, Math.min(60, Math.round(steps)));
const guidance = Number(p.guidanceScale);
if (Number.isFinite(guidance)) out.guidanceScale = Math.max(1, Math.min(20, guidance));
const seed = Number(p.seed);
if (Number.isFinite(seed)) out.seed = Math.max(0, Math.min(2_147_483_647, Math.round(seed)));
return out;
}
async function editRunPod(
op: { type?: string; prompt?: string; factor?: number },
imageBase64: string,
instruction: string,
maskBase64: string | undefined,
rawParams: unknown,
): Promise<EditResult> {
const params = sanitizeParams(rawParams);
switch (op.type) {
case "remove-background":
// U²-Net via rembg (#6) — a real endpoint replacing the flaky in-browser remover.
return rpRemoveBackground(imageBase64);
case "restore":
// Real-ESRGAN (#7) with the GFPGAN face pass = "Restore & Enhance".
return rpUpscale(imageBase64, 4, true);
case "upscale":
return rpUpscale(imageBase64, op.factor === 4 ? 4 : 2, false);
case "colorize":
// The dedicated DDColor endpoint kept hard-crashing (modelscope). Route
// colorize through the img2img model as an instruction instead.
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params });
case "prompt":
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params }); // SD 3.5 img2img (#10)
case "replace-sky":
// True sky replacement is masked inpaint (#9). Without a mask (no in-app sky
// segmentation yet) degrade to a low-strength img2img (#10) so the foreground
// is mostly preserved.
if (maskBase64)
return rpInpaint({
imageB64: imageBase64,
maskB64: maskBase64,
prompt: instruction,
...params,
});
return rpImg2Img({
imageB64: imageBase64,
prompt: instruction,
...params,
strength: params.strength ?? 0.4,
});
case "magic-eraser":
case "generative-fill":
// SD 3.5 masked inpaint (#9) — white in the mask = the region to regenerate.
if (!maskBase64) throw new AiError("This edit needs a mask/selection.", 400);
return rpInpaint({
imageB64: imageBase64,
maskB64: maskBase64,
prompt: instruction,
...params,
});
default:
throw new AiError("Unsupported operation.", 400);
}
}
class AiError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.status = status;
}
}
// ---------------------------------------------------------------------------
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
// ---------------------------------------------------------------------------
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
const base = process.env.LOCAL_SD_URL;
if (!base || !/^https?:\/\//i.test(base)) {
throw new AiError("LOCAL_SD_URL is not a valid http(s) URL.", 500);
}
const url = `${base.replace(/\/$/, "")}/sdapi/v1/img2img`;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
init_images: [imageBase64],
prompt: instruction,
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
cfg_scale: 7,
sampler_name: process.env.LOCAL_SD_SAMPLER || "Euler a",
}),
cache: "no-store",
});
} catch {
throw new AiError("Could not reach your local Stable Diffusion server (LOCAL_SD_URL).", 502);
}
if (!res.ok) {
throw new AiError(`Local SD server error (${res.status}).`, 502);
}
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
const out = data?.images?.[0];
if (!out) throw new AiError("Local SD server did not return an image.", 502);
// A1111 returns raw base64 PNG (no data: prefix).
return { imageBase64: out.includes(",") ? out.split(",")[1]! : out, mimeType: "image/png" };
}
// ---------------------------------------------------------------------------
// Backend: Hugging Face Inference API — instruction image editing.
// ---------------------------------------------------------------------------
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
const token = process.env.HF_API_TOKEN;
if (!token) throw new AiError("HF_API_TOKEN is not set.", 500);
const model = process.env.HF_IMAGE_MODEL || "timbrooks/instruct-pix2pix";
let res: Response;
try {
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
// Wait for the model to warm up instead of a fast 503.
"x-wait-for-model": "true",
},
body: JSON.stringify({
inputs: imageBase64,
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
}),
cache: "no-store",
});
} catch {
throw new AiError("Could not reach the Hugging Face Inference API.", 502);
}
if (!res.ok) {
// Truncated on purpose — never surface a full upstream body.
const detail = (await res.text().catch(() => "")).slice(0, 160);
if (res.status === 503) throw new AiError("The model is loading — try again in ~20s.", 503);
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
}
// Success returns raw image bytes.
const outMime = res.headers.get("content-type") || "image/png";
if (outMime.startsWith("application/json")) {
const j = (await res.json().catch(() => null)) as { error?: string } | null;
throw new AiError(
j?.error ? `Hugging Face: ${j.error}` : "Hugging Face returned no image.",
502,
);
}
const buf = await res.arrayBuffer();
return { imageBase64: Buffer.from(buf).toString("base64"), mimeType: outMime };
}
// ---------------------------------------------------------------------------
// Backend: Google Gemini image model (needs a billed key for image output).
// ---------------------------------------------------------------------------
async function editGemini(
instruction: string,
imageBase64: string,
safeMime: string,
): Promise<EditResult> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new AiError("GEMINI_API_KEY is not set.", 500);
const model = process.env.GEMINI_IMAGE_MODEL || "gemini-2.5-flash-image";
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
let res: Response;
try {
res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
{
method: "POST",
headers: { "content-type": "application/json", "x-goog-api-key": apiKey },
body: JSON.stringify({
contents: [
{
role: "user",
parts: [
{ inlineData: { mimeType: safeMime, data: imageBase64 } },
{ text: prompt },
],
},
],
generationConfig: { responseModalities: ["IMAGE"] },
}),
cache: "no-store",
},
);
} catch {
throw new AiError("Could not reach the AI service.", 502);
}
if (!res.ok) {
// Truncated on purpose — never surface a full upstream body.
const detail = (await res.text().catch(() => "")).slice(0, 160);
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
}
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
if (!out)
throw new AiError(
"The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).",
502,
);
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? "image/png";
return { imageBase64: out, mimeType: outMime };
}
interface GeminiPart {
text?: string;
inlineData?: { mimeType?: string; data?: string };
inline_data?: { mime_type?: string; data?: string };
}
interface GeminiResponse {
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
}
+48
View File
@@ -0,0 +1,48 @@
import { type NextRequest, NextResponse } from "next/server";
import { RunpodError } from "@/lib/server/runpod/client";
import { rpTilt } from "@/lib/server/runpod/endpoints";
import { guard } from "../_guard";
export const runtime = "nodejs";
export const maxDuration = 60; // cold-start tilt worker can take a while
export const dynamic = "force-dynamic";
/**
* Camera-tilt estimation proxy. Accepts a base64 image and returns
* {rollDegrees, pitchDegrees, fovDegrees} from the RunPod tilt endpoint
* (RUNPOD_TILT_URL) so the editor can auto-straighten.
*
* POST { image } -> { rollDegrees, pitchDegrees, fovDegrees }
* Auth: session-gated. Rate limit: 30/min.
*/
const MAX_BASE64 = 4_000_000; // ~3 MB decoded
export async function POST(req: NextRequest) {
const gate = await guard(req, "tilt");
if (!gate.ok) return gate.response;
let body: { image?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
const image = typeof body.image === "string" ? body.image : "";
if (!image) return NextResponse.json({ error: "Missing image." }, { status: 400 });
if (image.length > MAX_BASE64) {
return NextResponse.json({ error: "Image too large." }, { status: 413 });
}
try {
const tilt = await rpTilt(image);
return NextResponse.json(tilt);
} catch (e) {
const msg =
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Tilt estimate failed.";
return NextResponse.json({ error: msg }, { status: 502 });
}
}
@@ -0,0 +1,49 @@
import { type NextRequest, NextResponse } from "next/server";
import { RunpodError } from "@/lib/server/runpod/client";
import { rpTranscribe } from "@/lib/server/runpod/endpoints";
import { guard } from "../_guard";
export const runtime = "nodejs";
export const maxDuration = 60; // cold-start STT worker can take a while
export const dynamic = "force-dynamic";
/**
* Speech-to-text proxy for voice annotations. Accepts base64 WAV (16 kHz mono
* PCM16, produced in-browser) and returns the transcript. Calls the RunPod
* voice-to-text endpoint (RUNPOD_STT_URL) — the key stays server-side.
*
* POST { audio, language? } -> { transcript, segments? }
* Auth: session-gated. Rate limit: 20/min.
*/
const MAX_BASE64 = 8_000_000; // ~6 MB decoded WAV — stays under serverless body limits
export async function POST(req: NextRequest) {
const gate = await guard(req, "transcribe");
if (!gate.ok) return gate.response;
let body: { audio?: unknown; language?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
}
const audio = typeof body.audio === "string" ? body.audio : "";
const language = typeof body.language === "string" ? body.language : undefined;
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
if (audio.length > MAX_BASE64) {
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
}
try {
const { transcript, segments } = await rpTranscribe(audio, { language, punctuation: true });
return NextResponse.json({ transcript, segments });
} catch (e) {
const msg =
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Transcription failed.";
return NextResponse.json({ error: msg }, { status: 502 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
// Server-side proxy to be-crm's public invitation lookup, so the invite landing page
// can learn the invited email + roles and whether an account exists — before the
// invitee has a session. Server-to-server avoids CORS. No secrets involved (the token
// is the only credential, and it was emailed to the invitee).
export const runtime = "nodejs";
const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, "");
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token") ?? "";
if (token.length < 16 || token.length > 256) {
return NextResponse.json({ ok: false, status: "invalid" }, { status: 400 });
}
try {
const res = await fetch(`${CRM_BASE_URL}/public/invitations/lookup?token=${encodeURIComponent(token)}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
}
}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,5 +1,10 @@
import { Dashboard } from "@/components/dashboard/dashboard";
import { AuthGate } from "@/components/dashboard/auth-gate";
export default function DashboardPage() {
return <Dashboard />;
return (
<AuthGate>
<Dashboard />
</AuthGate>
);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { siteConfig } from "@/config/site";
import { Providers } from "./providers";
import "./globals.css";
const geistSans = Geist({
@@ -31,7 +32,9 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { CookieBanner, Spinner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
interface InviteInfo { ok: boolean; status?: string; email?: string; roleNames?: string[]; hasAccount?: boolean }
/**
* Team invitation landing page (/portal/invite?token=). It looks the token up
* (public, pre-auth) to learn the invited email and whether an account exists, then:
* - signed in + registered accept now (creates the membership with the invited role);
* - signed in, no CRM profile onboarding, which redeems the token on finish;
* - not signed in + email already has an account sign in (email prefilled);
* - not signed in + first-time invitee register (email prefilled + locked).
* The register/login/onboarding flows redeem the stashed token on completion.
*/
export default function InvitePage() {
const router = useRouter();
const { status, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [error, setError] = useState("");
const started = useRef(false);
useEffect(() => {
let token = "";
try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ }
if (!token) { setError("This invitation link is invalid or incomplete."); return; }
if (!isShellConfigured()) { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } router.replace("/portal/register"); return; }
if (!ready || started.current) return;
started.current = true;
(async () => {
try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
// Look the invitation up (pre-auth) to get the email + account state.
let info: InviteInfo = { ok: false };
try {
const res = await fetch(`/api/invite/lookup?token=${encodeURIComponent(token)}`, { cache: "no-store" });
info = await res.json();
} catch { /* treat as unavailable below */ }
if (!info.ok) {
const msg = info.status === "expired" ? "This invitation has expired. Ask for a new one."
: info.status === "accepted" ? "This invitation has already been used."
: info.status === "revoked" ? "This invitation was revoked."
: "This invitation link is invalid.";
setError(msg);
try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ }
return;
}
if (info.email) { try { sessionStorage.setItem("invite_email", info.email); } catch { /* ignore */ } }
// Signed in already: accept if registered, else finish onboarding first.
if (status === "authenticated") {
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
if (!st?.registered) {
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
return;
}
} catch { /* fall through to accept */ }
try {
await sdk.command("crm.team.invitation.accept", { token });
try { sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_email"); } catch { /* ignore */ }
router.replace("/dashboard");
} catch {
setError("We couldn't accept this invitation — it may have expired or already been used.");
}
return;
}
// Not signed in: existing account → sign in; first-time invitee → register.
router.replace(info.hasAccount ? "/portal/login" : "/portal/register");
})();
}, [ready, status, router, sdk, getUserEmail]);
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
{error ? (
<>
<h1>Invitation problem</h1>
<p className="sub">{error}</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => router.replace("/portal/login")}>
Go to sign in
</button>
</>
) : (
<div className="interstitial">
<Spinner lg />
<h1 style={{ fontSize: 20, marginTop: 8 }}>Checking your invitation</h1>
<p className="sub">One moment while we set things up.</p>
</div>
)}
</div>
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { RegisterFlow } from "@/components/portal/register-flow";
import { CookieBanner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
/**
* Post-OAuth onboarding a first-time Google user completes their CRM profile
* (everything except email, which Google already verified).
*
* This screen is ONLY a step in the OAuth onboarding handoff: the login page
* stashes the verified email in `sessionStorage` right before routing here. Any
* other way in a typed URL, a fresh tab, a lost session has no handoff hint
* (and possibly no session), so we bounce back to sign in rather than showing an
* empty form.
*/
export default function OnboardingPage() {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
if (!isShellConfigured()) { setAllowed(true); return; } // local dev without the Shell
if (!ready) return; // wait for session restore
let hasHandoff = false;
try { hasHandoff = !!sessionStorage.getItem("onboard_email"); } catch { /* ignore */ }
if (status === "unauthenticated" || !hasHandoff) {
router.replace("/portal/login");
return;
}
setAllowed(true);
}, [ready, status, router]);
if (!allowed) return <main className="portal-main"><span className="portal-grid" /></main>;
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<RegisterFlow mode="onboard" />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
// AppShell runs in the browser (boot, React context), so the provider is a
// Client Component. When Supabase env is NOT configured (no BFF yet) we mount the
// provider WITHOUT `auth` — `useAuth()` still works (returns nulls / status
// "unauthenticated"), and the existing mock portal remains the fallback. Once the
// env is set + the Shell BFF is deployed, real auth flows through appshell-sdk.
import { AppShellProvider } from "@abe-kap/appshell-sdk/react";
import type { AppShellOptions } from "@abe-kap/appshell-sdk";
import type { ReactNode } from "react";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const bffBaseUrl = process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell";
const options: AppShellOptions = supabaseUrl
? {
auth: {
supabaseUrl,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
appId: "crm-web",
},
bffBaseUrl,
}
: { bffBaseUrl };
export function Providers({ children }: { children: ReactNode }) {
return <AppShellProvider options={options}>{children}</AppShellProvider>;
}
+566
View File
@@ -0,0 +1,566 @@
// ============================================================
// LynkedUp Pro — Account / Profile / Support mock data (D).
// Everything here is a client-side mock so the screens are
// fully clickable without a backend. Ready-to-paste shapes.
//
// Anchored to the same demo identity used by the auth portal:
// James Carter · Allotment YEA-654321 · House 181.
// ============================================================
/* ---------------------------------------------------------- */
/* helpers */
/* ---------------------------------------------------------- */
export function maskEmail(email: string): string {
const [user = "", domain = ""] = email.split("@");
if (!domain) return email;
if (user.length <= 2) return `${user[0] ?? ""}••@${domain}`;
return `${user[0]}${"•".repeat(Math.max(4, user.length - 2))}${user[user.length - 1]}@${domain}`;
}
export function maskPhone(phone: string): string {
const tail = phone.replace(/\D/g, "").slice(-2);
return `+91 ••••• ••${tail}`;
}
/* ---------------------------------------------------------- */
/* USER — the signed-in account holder */
/* ---------------------------------------------------------- */
export type RevealField = { label: string; masked: string; full: string };
export type User = {
id: string;
name: string;
firstName: string;
lastName: string;
initials: string;
role: string;
avatarGradient: string;
// property / allotment
allotmentNo: string;
plot: string; // house number — verify-to-reveal key
sector: string;
pocket: string;
category: string;
size: string;
// allottee vs account holder
isAllottee: boolean;
relationship: string; // "Self" when account holder IS allottee
allotteeName: string;
// contact (masked until verified)
email: RevealField;
mobile: RevealField;
whatsapp: RevealField;
pan: RevealField;
aadhaar: RevealField;
memberSince: string;
status: "active" | "suspended";
};
export const user: User = {
id: "usr_LUP_00181",
name: "James Carter",
firstName: "James",
lastName: "Carter",
initials: "JC",
role: "Property Owner",
avatarGradient: "linear-gradient(135deg,#fda913,#fd6d13)",
allotmentNo: "YEA-654321",
plot: "181",
sector: "Sector 18",
pocket: "Pocket B",
category: "Residential",
size: "300 sq.m",
isAllottee: true,
relationship: "Self",
allotteeName: "James Carter",
email: { label: "Email", masked: maskEmail("james.carter@example.com"), full: "james.carter@example.com" },
mobile: { label: "Mobile", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
whatsapp: { label: "WhatsApp", masked: "+91 ••••• ••012", full: "+91 98765 43012" },
pan: { label: "PAN", masked: "•••••••• 12F", full: "ABCPS1234F" },
aadhaar: { label: "Aadhaar", masked: "•••• •••• 9012", full: "4321 8765 9012" },
memberSince: "March 2021",
status: "active",
};
/* ---------------------------------------------------------- */
/* COUNTRY CODES */
/* ---------------------------------------------------------- */
export type CountryCode = { code: string; flag: string; name: string; digits: number; example: string };
export const countryCodes: CountryCode[] = [
{ code: "+91", flag: "🇮🇳", name: "India", digits: 10, example: "98765 43210" },
{ code: "+1", flag: "🇺🇸", name: "United States", digits: 10, example: "201 555 0123" },
{ code: "+44", flag: "🇬🇧", name: "United Kingdom", digits: 10, example: "7400 123456" },
{ code: "+971", flag: "🇦🇪", name: "UAE", digits: 9, example: "50 123 4567" },
{ code: "+65", flag: "🇸🇬", name: "Singapore", digits: 8, example: "8123 4567" },
{ code: "+61", flag: "🇦🇺", name: "Australia", digits: 9, example: "412 345 678" },
];
/* ---------------------------------------------------------- */
/* KYC */
/* ---------------------------------------------------------- */
export type KycUse = "id" | "address" | "any";
export type KycDocType = {
id: string;
label: string;
use: KycUse; // what this document can prove
hint: string;
formats: string; // accepted attachment formats
maxMb: number;
};
// Note rule: an ID document and an ADDRESS document must be two
// DIFFERENT document types. A passport (use:"any") may satisfy
// either slot but not both at once.
export const kycDocTypes: KycDocType[] = [
{ id: "aadhaar", label: "Aadhaar Card", use: "any", hint: "Front & back, masked first 8 digits", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "pan", label: "PAN Card", use: "id", hint: "Clear photo of the front", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "passport", label: "Passport", use: "any", hint: "Photo page, must be valid", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "voter", label: "Voter ID", use: "id", hint: "Front & back", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "dl", label: "Driving Licence", use: "id", hint: "Front & back, not expired", formats: "PDF, JPG, PNG", maxMb: 5 },
{ id: "utility", label: "Utility Bill", use: "address", hint: "Electricity / water, < 3 months old", formats: "PDF, JPG, PNG", maxMb: 8 },
{ id: "bank", label: "Bank Statement", use: "address", hint: "First page with address, < 3 months", formats: "PDF", maxMb: 8 },
{ id: "rent", label: "Registered Rent Agreement", use: "address", hint: "All pages, registered copy", formats: "PDF", maxMb: 10 },
];
export type KycSlot = {
key: "photo" | "id" | "address";
title: string;
required: boolean;
docId: string | null; // chosen kycDocTypes id
fileName: string | null;
status: "missing" | "uploaded" | "verified" | "rejected";
note?: string;
};
export const kyc: { completion: number; slots: KycSlot[] } = {
completion: 33,
slots: [
{ key: "photo", title: "Live Photo / Selfie", required: true, docId: null, fileName: "selfie_rajesh.jpg", status: "verified", note: "Face match passed" },
{ key: "id", title: "Identity Proof", required: true, docId: "pan", fileName: "pan_front.pdf", status: "uploaded", note: "Under review" },
{ key: "address", title: "Address Proof", required: true, docId: null, fileName: null, status: "missing" },
],
};
/* ---------------------------------------------------------- */
/* NOTIFICATIONS — channels + per-destination matrix */
/* ---------------------------------------------------------- */
export type NotifCategory = {
id: string;
label: string;
desc: string;
locked?: boolean; // statutory / security — cannot disable
email: boolean;
sms: boolean;
whatsapp: boolean;
push: boolean;
};
export const contactPrefs: NotifCategory[] = [
{ id: "security", label: "Security & sign-in alerts", desc: "New device, password and 2FA changes", locked: true, email: true, sms: true, whatsapp: false, push: true },
{ id: "kyc", label: "KYC & verification", desc: "Document status and re-verification requests", email: true, sms: true, whatsapp: true, push: true },
{ id: "tickets", label: "Support tickets", desc: "Replies, status changes and resolutions", email: true, sms: false, whatsapp: true, push: true },
{ id: "billing", label: "Billing & dues", desc: "Invoices, payment reminders and receipts", email: true, sms: true, whatsapp: false, push: false },
{ id: "inspections", label: "Inspections & schedule", desc: "Upcoming visits and rescheduling", email: true, sms: false, whatsapp: true, push: true },
{ id: "marketing", label: "Offers & product news", desc: "Promotions, surveys and newsletters", email: false, sms: false, whatsapp: false, push: false },
];
export type ContactDestination = { id: string; channel: "email" | "sms" | "whatsapp"; label: string; value: string; verified: boolean; primary: boolean };
export const contactDestinations: ContactDestination[] = [
{ id: "e1", channel: "email", label: "Personal email", value: "james.carter@example.com", verified: true, primary: true },
{ id: "e2", channel: "email", label: "Work email", value: "j.carter@acme.co", verified: false, primary: false },
{ id: "s1", channel: "sms", label: "Registered mobile", value: "+91 98765 43012", verified: true, primary: true },
{ id: "w1", channel: "whatsapp", label: "WhatsApp", value: "+91 98765 43012", verified: true, primary: true },
];
export const contactTimezones = [
"Asia/Kolkata (IST · GMT+5:30)",
"Asia/Dubai (GST · GMT+4:00)",
"Europe/London (GMT/BST)",
"America/New_York (ET)",
"Asia/Singapore (SGT · GMT+8:00)",
"Australia/Sydney (AET)",
];
// Contact-time windows the user is willing to be reached in.
export type ContactTime = { id: string; label: string; range: string; enabled: boolean };
export const contactTimes: ContactTime[] = [
{ id: "morning", label: "Morning", range: "08:00 12:00", enabled: true },
{ id: "afternoon", label: "Afternoon", range: "12:00 16:00", enabled: true },
{ id: "evening", label: "Evening", range: "16:00 20:00", enabled: true },
{ id: "night", label: "Night", range: "20:00 22:00", enabled: false },
];
/* ---------------------------------------------------------- */
/* SECURITY — login activity / devices */
/* ---------------------------------------------------------- */
export type LoginActivity = {
id: string;
device: string;
os: string;
kind: "desktop" | "mobile" | "tablet";
browser: string;
location: string;
ip: string;
current: boolean;
trusted: boolean;
lastActive: string;
event: "sign-in" | "password-change" | "2fa-enabled" | "new-device" | "sign-out";
};
export const loginActivity: LoginActivity[] = [
{ id: "d1", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: true, trusted: true, lastActive: "Active now", event: "sign-in" },
{ id: "d2", device: "iPhone 15", os: "iOS 18.3", kind: "mobile", browser: "Safari", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "2 hours ago", event: "sign-in" },
{ id: "d3", device: "Windows PC", os: "Windows 11", kind: "desktop", browser: "Edge 131", location: "Gurugram, IN", ip: "182.71.x.x", current: false, trusted: false, lastActive: "Yesterday, 9:14 PM", event: "new-device" },
{ id: "d4", device: "iPad Air", os: "iPadOS 18", kind: "tablet", browser: "Safari", location: "Dubai, AE", ip: "94.205.x.x", current: false, trusted: false, lastActive: "3 days ago", event: "sign-in" },
{ id: "d5", device: "MacBook Pro 14\"", os: "macOS 15.2", kind: "desktop", browser: "Chrome 132", location: "Noida, IN", ip: "49.36.x.x", current: false, trusted: true, lastActive: "5 days ago", event: "password-change" },
];
/* ---------------------------------------------------------- */
/* PRIVACY & CONSENT */
/* ---------------------------------------------------------- */
export type ConsentItem = { id: string; label: string; desc: string; granted: boolean; locked?: boolean };
export type ConsentGroup = { id: string; title: string; items: ConsentItem[] };
export const consentGroups: ConsentGroup[] = [
{
id: "essential",
title: "Essential & statutory",
items: [
{ id: "tos", label: "Terms of Use & service agreement", desc: "Required to operate your account.", granted: true, locked: true },
{ id: "kyc-process", label: "Identity verification processing", desc: "Process KYC documents to meet regulatory obligations.", granted: true, locked: true },
],
},
{
id: "operational",
title: "Operational data sharing",
items: [
{ id: "authority", label: "Share records with the authority", desc: "Sync allotment & inspection records with the development authority.", granted: true },
{ id: "processors", label: "Trusted processors", desc: "Allow vetted vendors to process data on our behalf.", granted: true },
],
},
{
id: "marketing",
title: "Marketing & analytics",
items: [
{ id: "analytics", label: "Product analytics", desc: "Help improve the portal with anonymous usage data.", granted: true },
{ id: "personalize", label: "Personalised recommendations", desc: "Tailor content based on your activity.", granted: false },
{ id: "promos", label: "Promotional messages", desc: "Offers, surveys and partner news.", granted: false },
],
},
];
/* ---------------------------------------------------------- */
/* SUPPORT — channels, team, tickets, threads, help */
/* ---------------------------------------------------------- */
export type SupportChannel = {
id: string;
title: string;
desc: string;
icon: string; // Icon name
accent: string; // token var
meta: string; // SLA / availability
action: string; // CTA label
status: "online" | "busy" | "offline";
};
export const supportChannels: SupportChannel[] = [
{ id: "chat", title: "Live Chat", desc: "Instant help from a support agent", icon: "chat", accent: "var(--orange)", meta: "Avg. wait < 2 min", action: "Start chat", status: "online" },
{ id: "ticket", title: "Raise a Ticket", desc: "Track a request end-to-end", icon: "ticket", accent: "var(--blue)", meta: "First reply in 4 hrs", action: "New ticket", status: "online" },
{ id: "callback", title: "Request Callback", desc: "We'll call you in your time window", icon: "phone", accent: "var(--green)", meta: "Within working hours", action: "Schedule", status: "online" },
{ id: "email", title: "Email Support", desc: "care@lynkeduppro.com", icon: "mail", accent: "var(--purple)", meta: "Reply in 1 business day", action: "Send email", status: "online" },
{ id: "help", title: "Help Center", desc: "Guides, FAQs and how-tos", icon: "book", accent: "var(--cyan)", meta: "120+ articles", action: "Browse", status: "online" },
];
export type SupportAgent = {
id: string;
name: string;
initials: string;
role: string;
team: string;
gradient: string;
status: "online" | "away" | "offline";
rating: number;
};
export const supportTeam: SupportAgent[] = [
{ id: "a1", name: "Emma Wilson", initials: "EW", role: "Senior Support Lead", team: "Accounts & KYC", gradient: "linear-gradient(135deg,#fda913,#fd6d13)", status: "online", rating: 4.9 },
{ id: "a2", name: "Liam Foster", initials: "LF", role: "Support Specialist", team: "Billing", gradient: "linear-gradient(135deg,#285ef0,#09b9c6)", status: "online", rating: 4.8 },
{ id: "a3", name: "Sophie Turner", initials: "ST", role: "Technical Support", team: "Portal & Login", gradient: "linear-gradient(135deg,#9036e9,#285ef0)", status: "away", rating: 4.7 },
{ id: "a4", name: "Noah Mitchell", initials: "NM", role: "Field Coordinator", team: "Inspections", gradient: "linear-gradient(135deg,#14bc83,#09b9c6)", status: "offline", rating: 4.9 },
];
// Category → department routing + attachment rules for the New Ticket form.
export type TicketCategory = {
id: string;
label: string;
department: string;
sla: string;
requiresAttachment: boolean;
attachmentNote: string;
};
export const ticketCategories: TicketCategory[] = [
{ id: "kyc", label: "KYC / Document verification", department: "Accounts & KYC", sla: "4 business hours", requiresAttachment: true, attachmentNote: "Attach the document in question (PDF/JPG/PNG, ≤ 8 MB)." },
{ id: "login", label: "Login / Account access", department: "Portal & Login", sla: "2 business hours", requiresAttachment: false, attachmentNote: "Screenshots optional (PNG/JPG, ≤ 5 MB)." },
{ id: "billing", label: "Billing & payments", department: "Billing", sla: "1 business day", requiresAttachment: true, attachmentNote: "Attach invoice or receipt (PDF, ≤ 8 MB)." },
{ id: "allotment", label: "Allotment / Property records", department: "Accounts & KYC", sla: "1 business day", requiresAttachment: false, attachmentNote: "Allotment letter optional (PDF, ≤ 10 MB)." },
{ id: "inspection", label: "Inspection / Site visit", department: "Inspections", sla: "1 business day", requiresAttachment: false, attachmentNote: "Site photos optional (JPG/PNG, ≤ 10 MB each)." },
{ id: "other", label: "Something else", department: "General Support", sla: "1 business day", requiresAttachment: false, attachmentNote: "Attachments optional (≤ 8 MB)." },
];
export type TicketStatus = "open" | "in-progress" | "awaiting-customer" | "resolved" | "closed";
export type TicketEvent = { status: TicketStatus | "created"; label: string; at: string; by: string };
export type Ticket = {
id: string;
subject: string;
category: string; // TicketCategory label
department: string;
priority: "low" | "medium" | "high" | "urgent";
status: TicketStatus;
createdAt: string;
updatedAt: string;
agentId: string | null;
timeline: TicketEvent[];
};
export const tickets: Ticket[] = [
{
id: "LUP-48217",
subject: "Aadhaar re-upload not reflecting",
category: "KYC / Document verification",
department: "Accounts & KYC",
priority: "high",
status: "in-progress",
createdAt: "24 Jun 2026",
updatedAt: "28 Jun 2026",
agentId: "a1",
timeline: [
{ status: "created", label: "Ticket created", at: "24 Jun, 10:12 AM", by: "You" },
{ status: "open", label: "Routed to Accounts & KYC", at: "24 Jun, 10:13 AM", by: "System" },
{ status: "in-progress", label: "Emma Wilson picked up the ticket", at: "24 Jun, 11:40 AM", by: "Emma Wilson" },
{ status: "awaiting-customer", label: "Requested a clearer scan", at: "25 Jun, 09:05 AM", by: "Emma Wilson" },
{ status: "in-progress", label: "You re-uploaded the document", at: "28 Jun, 04:22 PM", by: "You" },
],
},
{
id: "LUP-47980",
subject: "Unable to receive login OTP on mobile",
category: "Login / Account access",
department: "Portal & Login",
priority: "urgent",
status: "resolved",
createdAt: "18 Jun 2026",
updatedAt: "19 Jun 2026",
agentId: "a3",
timeline: [
{ status: "created", label: "Ticket created", at: "18 Jun, 08:40 PM", by: "You" },
{ status: "in-progress", label: "Sophie Turner investigating SMS gateway", at: "18 Jun, 09:02 PM", by: "Sophie Turner" },
{ status: "resolved", label: "OTP delivery restored", at: "19 Jun, 10:15 AM", by: "Sophie Turner" },
],
},
{
id: "LUP-47512",
subject: "Duplicate maintenance invoice for June",
category: "Billing & payments",
department: "Billing",
priority: "medium",
status: "awaiting-customer",
createdAt: "12 Jun 2026",
updatedAt: "26 Jun 2026",
agentId: "a2",
timeline: [
{ status: "created", label: "Ticket created", at: "12 Jun, 02:10 PM", by: "You" },
{ status: "in-progress", label: "Liam Foster reviewing billing run", at: "12 Jun, 03:30 PM", by: "Liam Foster" },
{ status: "awaiting-customer", label: "Need bank reference for the second debit", at: "26 Jun, 11:00 AM", by: "Liam Foster" },
],
},
{
id: "LUP-46330",
subject: "Reschedule roof inspection visit",
category: "Inspection / Site visit",
department: "Inspections",
priority: "low",
status: "closed",
createdAt: "02 Jun 2026",
updatedAt: "06 Jun 2026",
agentId: "a4",
timeline: [
{ status: "created", label: "Ticket created", at: "02 Jun, 06:20 PM", by: "You" },
{ status: "in-progress", label: "Noah Mitchell coordinating slot", at: "03 Jun, 10:00 AM", by: "Noah Mitchell" },
{ status: "resolved", label: "Visit moved to 09 Jun, 11 AM", at: "04 Jun, 04:45 PM", by: "Noah Mitchell" },
{ status: "closed", label: "Inspection completed", at: "06 Jun, 01:30 PM", by: "System" },
],
},
];
// Message Center — inbox threads + messages (with a typing flag).
export type ChatMsg = { id: string; from: "me" | "agent"; text: string; at: string };
export type SupportThread = {
id: string;
agentId: string;
subject: string;
preview: string;
unread: number;
pinned: boolean;
typing: boolean;
updatedAt: string;
messages: ChatMsg[];
};
export const supportThreads: SupportThread[] = [
{
id: "t1",
agentId: "a1",
subject: "KYC — Aadhaar re-upload",
preview: "Thanks, I can see the new scan now…",
unread: 2,
pinned: true,
typing: true,
updatedAt: "2 min ago",
messages: [
{ id: "m1", from: "agent", text: "Hi James, this is Emma from Accounts & KYC. I'm looking into ticket LUP-48217.", at: "11:40 AM" },
{ id: "m2", from: "me", text: "Thanks Emma. The earlier scan was blurry so I uploaded a sharper one.", at: "11:42 AM" },
{ id: "m3", from: "agent", text: "Thanks, I can see the new scan now. Reviewing it with the verification team.", at: "11:43 AM" },
],
},
{
id: "t2",
agentId: "a2",
subject: "Billing — duplicate invoice",
preview: "Could you share the bank reference number?",
unread: 0,
pinned: false,
typing: false,
updatedAt: "Yesterday",
messages: [
{ id: "m1", from: "agent", text: "Hello! I'm reviewing the June billing run for the duplicate charge.", at: "3:30 PM" },
{ id: "m2", from: "agent", text: "Could you share the bank reference number for the second debit?", at: "3:32 PM" },
],
},
{
id: "t3",
agentId: "a3",
subject: "Login — OTP not received",
preview: "Glad it's working now. Closing this out.",
unread: 0,
pinned: false,
typing: false,
updatedAt: "19 Jun",
messages: [
{ id: "m1", from: "me", text: "I stopped getting the OTP on my registered mobile last night.", at: "8:40 PM" },
{ id: "m2", from: "agent", text: "There was a gateway issue in your region — fixed now. Please retry.", at: "10:14 AM" },
{ id: "m3", from: "me", text: "Working now, thank you!", at: "10:20 AM" },
{ id: "m4", from: "agent", text: "Glad it's working now. Closing this out.", at: "10:21 AM" },
],
},
];
/* ---------------------------------------------------------- */
/* HELP CENTER */
/* ---------------------------------------------------------- */
export type HelpArticle = { q: string; a: string };
export type HelpTopic = { id: string; title: string; icon: string; accent: string; count: number; articles: HelpArticle[] };
export const helpTopics: HelpTopic[] = [
{
id: "getting-started",
title: "Getting started",
icon: "rocket",
accent: "var(--orange)",
count: 3,
articles: [
{ q: "How do I verify my property details?", a: "Open Profile → Personal Info and click Verify to reveal. Enter your house number (e.g. 181) to unmask and confirm your records." },
{ q: "What does allottee mean if I'm a representative?", a: "If you manage the property on someone's behalf, the account holder and the allottee differ. Your relationship is shown on the Personal Info tab." },
{ q: "How do I update my profile photo?", a: "Use the camera button on your profile header card. JPG or PNG up to 5 MB." },
],
},
{
id: "kyc",
title: "KYC & verification",
icon: "shield",
accent: "var(--blue)",
count: 4,
articles: [
{ q: "Which documents do I need for KYC?", a: "A live photo (mandatory), one identity proof and one address proof. The identity and address documents must be two different document types." },
{ q: "Can I use Aadhaar for both ID and address?", a: "No. ID and address proofs must be different document types. Aadhaar can fill either slot, but not both at the same time." },
{ q: "How long does verification take?", a: "Most documents are reviewed within 4 business hours. You'll be notified on your registered channels." },
{ q: "My document was rejected — what now?", a: "Open the rejected slot, read the reason, and re-upload a clearer copy. A support ticket is created automatically for high-priority cases." },
],
},
{
id: "security",
title: "Login & security",
icon: "lock",
accent: "var(--purple)",
count: 4,
articles: [
{ q: "How do I enable two-factor authentication?", a: "Profile → Security → Two-factor. Enabling 2FA requires both your password and a one-time code for confirmation." },
{ q: "I'm not receiving my OTP", a: "OTPs are sent to your registered mobile. Check signal and spam filters; if it persists, raise a ticket under Login / Account access." },
{ q: "What is the password policy?", a: "At least 8 characters with upper & lower case, a number and a special character — and no common patterns like 1234 or password." },
{ q: "How do I sign out other devices?", a: "Profile → Devices shows every active session. Use Sign out on any device, or Sign out all other devices." },
],
},
{
id: "billing",
title: "Billing & payments",
icon: "card",
accent: "var(--green)",
count: 3,
articles: [
{ q: "Where can I download invoices?", a: "Invoices are emailed to your primary email and available under Billing. Receipts are issued within minutes of payment." },
{ q: "I was charged twice", a: "Raise a Billing ticket and attach the invoice/receipt. Keep your bank reference handy to speed up the reconciliation." },
{ q: "How do I change my billing email?", a: "Add a destination under Notifications and mark it primary for the Billing category in the per-destination matrix." },
],
},
];
/* ---------------------------------------------------------- */
/* RULES — the 113 business-rules checklist */
/* ---------------------------------------------------------- */
export type Rule = { n: number; title: string; detail: string; tag: string; tagColor: string };
export const rules: Rule[] = [
{ n: 1, title: "Verify-to-reveal", detail: "Masked personal details (email, mobile, PAN, Aadhaar) stay hidden until the account holder confirms their house number. For this account the house number is 181.", tag: "Profile", tagColor: "var(--orange)" },
{ n: 2, title: "OTP goes to the registered mobile", detail: "Any sensitive change — email, mobile or password — sends a one-time code to the registered mobile number, never to the new/unverified destination.", tag: "Security", tagColor: "var(--blue)" },
{ n: 3, title: "Allottee ≠ account holder", detail: "When the relationship is not Self, the account holder is a representative and the allottee's name is shown separately. Actions are logged against the account holder.", tag: "Profile", tagColor: "var(--orange)" },
{ n: 4, title: "KYC needs two different documents", detail: "Identity proof and address proof must be two different document types. A single document (e.g. Aadhaar) cannot satisfy both slots at once.", tag: "KYC", tagColor: "var(--purple)" },
{ n: 5, title: "Live photo is mandatory", detail: "A live photo / selfie is required for KYC completion in addition to the ID and address proofs. Completion percentage never reaches 100% without it.", tag: "KYC", tagColor: "var(--purple)" },
{ n: 6, title: "Password policy", detail: "Minimum 8 characters with upper & lower case, a number and a special character. Common patterns like 1234, qwerty or password are rejected.", tag: "Security", tagColor: "var(--blue)" },
{ n: 7, title: "2FA requires password + OTP", detail: "Turning two-factor authentication on or off requires re-entering the password and confirming a one-time code. Authenticator apps and login alerts build on this.", tag: "Security", tagColor: "var(--blue)" },
{ n: 8, title: "Ticket lifecycle", detail: "Tickets move Open → In Progress → Awaiting Customer → Resolved → Closed. Every transition is timestamped and attributed in the status timeline.", tag: "Support", tagColor: "var(--green)" },
{ n: 9, title: "Category → department routing", detail: "Each ticket category auto-routes to the right department (e.g. KYC → Accounts & KYC) with its own SLA shown before submission.", tag: "Support", tagColor: "var(--green)" },
{ n: 10, title: "Attachment rules", detail: "Some categories require an attachment; each defines accepted formats and a maximum file size. The form blocks submission until required attachments are present.", tag: "Support", tagColor: "var(--green)" },
{ n: 11, title: "Contact-time windows + timezone", detail: "Callbacks and proactive messages are only sent inside the contact-time windows the user enables, interpreted in their selected timezone.", tag: "Notifications", tagColor: "var(--cyan)" },
{ n: 12, title: "Statutory consent is locked", detail: "Essential and statutory consents (Terms of Use, KYC processing) cannot be switched off while the account is active. Marketing consents and sub-toggles are fully optional.", tag: "Privacy", tagColor: "var(--red)" },
{ n: 13, title: "New-device login alerts", detail: "Signing in from an untrusted device raises a security alert on the registered channels and lists the device under Devices for review or sign-out.", tag: "Security", tagColor: "var(--blue)" },
];
/* ---------------------------------------------------------- */
/* password strength (shared with Security tab) */
/* ---------------------------------------------------------- */
export type Strength = { score: number; label: "Weak" | "Fair" | "Good" | "Strong"; checks: { ok: boolean; label: string }[] };
export function passwordStrength(pw: string): Strength {
const checks = [
{ ok: pw.length >= 8, label: "At least 8 characters" },
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: "Upper & lower case" },
{ ok: /\d/.test(pw), label: "A number" },
{ ok: /[^A-Za-z0-9]/.test(pw), label: "A special character" },
];
const passed = checks.filter((c) => c.ok).length;
const weakList = ["1234", "abcd", "password", "qwerty", "0000"];
const isWeak = weakList.some((w) => pw.toLowerCase().includes(w));
const score = isWeak ? Math.min(passed, 1) : passed;
const label = score <= 1 ? "Weak" : score === 2 ? "Fair" : score === 3 ? "Good" : "Strong";
return { score, label, checks };
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
// ============================================================
// AI Assistant — "Lynk AI", a creative conversational copilot.
// · Empty state : warm greeting, capability cards, prompt chips
// · Active chat : message thread with a typing indicator and a
// simulated, keyword-aware assistant reply
// · Composer : auto-suggesting prompt row + send / new chat
// All responses are canned + client-side so it demos with no API.
// ============================================================
import { useEffect, useRef, useState } from "react";
import { user } from "./account-data";
import { Avatar, Btn, Icon, Pill, useToast } from "./ui";
type Msg = { id: number; from: "me" | "ai"; text: string };
const CAPABILITIES = [
{ icon: "leads", tone: "var(--orange)", title: "Qualify leads", desc: "Score and summarise new leads, draft the first outreach." },
{ icon: "estimates", tone: "var(--blue)", title: "Build estimates", desc: "Turn scope notes into a clean, line-itemed proposal." },
{ icon: "schedule", tone: "var(--green)", title: "Plan the week", desc: "Suggest a crew schedule around weather and priorities." },
{ icon: "leaderboard", tone: "var(--purple)", title: "Analyse pipeline", desc: "Spot stalled deals and where revenue is leaking." },
];
const PROMPTS = [
{ icon: "leads", text: "Summarise my new leads from this week" },
{ icon: "estimates", text: "Draft an estimate for a 28-square asphalt reroof" },
{ icon: "storm", text: "Which territories were hit by the last storm?" },
{ icon: "pipeline", text: "Show deals stuck in the pipeline over 14 days" },
];
let msgSeq = 1;
export function AiAssistant() {
const toast = useToast();
const [messages, setMessages] = useState<Msg[]>([]);
const [draft, setDraft] = useState("");
const [typing, setTyping] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const started = messages.length > 0;
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}, [messages, typing]);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
function send(text: string) {
const body = text.trim();
if (!body || typing) return;
setMessages((m) => [...m, { id: msgSeq++, from: "me", text: body }]);
setDraft("");
setTyping(true);
timer.current = setTimeout(() => {
setMessages((m) => [...m, { id: msgSeq++, from: "ai", text: replyFor(body) }]);
setTyping(false);
}, 1100);
}
function reset() {
if (timer.current) clearTimeout(timer.current);
setMessages([]); setDraft(""); setTyping(false);
}
return (
<div className="view ai-view">
<div className="ai-head">
<div className="ai-head-l">
<span className="ai-mark"><Icon name="ai" size={20} /></span>
<div>
<div className="ai-eyebrow">LynkedUp Pro</div>
<h1>Lynk AI <Pill tone="orange">Beta</Pill></h1>
</div>
</div>
<div className="ai-head-actions">
<span className="ai-model"><span className="ai-model-dot" /> Opus 4.8</span>
{started && <Btn variant="outline" size="sm" icon="plus" onClick={reset}>New chat</Btn>}
</div>
</div>
<div className="ai-stage card card-pad-0">
<div className="ai-scroll" ref={scrollRef}>
{!started ? (
<div className="ai-hero">
<div className="ai-orb-wrap">
<span className="ai-orb-ring" aria-hidden />
<span className="ai-orb-ring d2" aria-hidden />
<span className="ai-orb-ring d3" aria-hidden />
<span className="ai-hero-orb"><Icon name="ai" size={34} /></span>
</div>
<h2>{greeting()}, {user.firstName}.</h2>
<p>I&apos;m your roofing copilot. Ask me to summarise leads, draft estimates, plan crews or dig into your pipeline.</p>
<div className="ai-caps">
{CAPABILITIES.map((c) => (
<button className="ai-cap" key={c.title} style={{ ["--rc" as string]: c.tone }} onClick={() => send(`Help me ${c.title.toLowerCase()}`)}>
<span className="ai-cap-ic"><Icon name={c.icon} size={18} /></span>
<span className="ai-cap-title">{c.title}</span>
<span className="ai-cap-desc">{c.desc}</span>
</button>
))}
</div>
<div className="ai-suggest">
<span className="ai-suggest-lbl">Try asking</span>
<div className="ai-chips">
{PROMPTS.map((p) => (
<button className="ai-chip" key={p.text} onClick={() => send(p.text)}>
<Icon name={p.icon} size={14} /> {p.text}
</button>
))}
</div>
</div>
</div>
) : (
<div className="ai-thread">
{messages.map((m) => (
<div className={`ai-msg ${m.from}`} key={m.id}>
{m.from === "ai"
? <span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
: <Avatar initials={user.initials} gradient={user.avatarGradient} size={32} />}
<div className="ai-bubble">
{m.text.split("\n").map((line, i) => <p key={i}>{line}</p>)}
{m.from === "ai" && (
<div className="ai-msg-actions">
<button onClick={() => { navigator.clipboard?.writeText(m.text); toast.push({ tone: "success", title: "Copied to clipboard" }); }} aria-label="Copy"><Icon name="copy" size={13} /></button>
<button onClick={() => toast.push({ tone: "success", title: "Thanks for the feedback" })} aria-label="Good response"><Icon name="check" size={13} /></button>
</div>
)}
</div>
</div>
))}
{typing && (
<div className="ai-msg ai">
<span className="ai-msg-av ai-mark"><Icon name="ai" size={16} /></span>
<div className="ai-bubble ai-typing"><span /><span /><span /></div>
</div>
)}
</div>
)}
</div>
<div className="ai-composer">
{started && (
<div className="ai-quickrow">
{PROMPTS.slice(0, 3).map((p) => (
<button key={p.text} className="ai-quick" onClick={() => send(p.text)}><Icon name={p.icon} size={13} /> {p.text}</button>
))}
</div>
)}
<div className="ai-inputbar">
<button className="ai-attach" aria-label="Attach" onClick={() => toast.push({ tone: "info", title: "Attachments", desc: "File uploads land here soon." })}><Icon name="paperclip" size={18} /></button>
<textarea
className="ai-textarea"
rows={1}
placeholder="Ask Lynk AI anything about your roofing business…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(draft); } }}
/>
<button className="ai-send" aria-label="Send" disabled={!draft.trim() || typing} onClick={() => send(draft)}><Icon name="send" size={17} /></button>
</div>
<div className="ai-foot">Lynk AI can make mistakes. Verify estimates and figures before sending to owners.</div>
</div>
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* helpers */
/* ---------------------------------------------------------- */
function greeting(): string {
// Deterministic across renders to avoid hydration drift — anchored to a
// fixed demo time-of-day rather than the live clock.
return "Good evening";
}
// Lightweight keyword router so the demo feels responsive without an API.
function replyFor(q: string): string {
const t = q.toLowerCase();
if (t.includes("lead")) {
return "You have 6 new leads this week — 4 inbound from the storm landing page and 2 referrals.\nTop priority: Mark Reynolds (Sector 18) — full roof replacement, insurance claim already filed. I've drafted a first-touch SMS; want me to queue it?";
}
if (t.includes("estimate") || t.includes("proposal") || t.includes("reroep") || t.includes("reroof")) {
return "Here's a starting estimate for a 28-square asphalt reroof:\n• Tear-off & disposal — $3,920\n• Architectural shingles (28 sq) — $7,560\n• Underlayment, flashing & ridge vent — $2,180\n• Labour (3-day crew) — $5,400\nSubtotal $19,060 · 10% margin → $20,966. Want it itemised into a sendable proposal?";
}
if (t.includes("schedule") || t.includes("crew") || t.includes("week") || t.includes("plan")) {
return "Clear weather TueThu, rain Friday. Suggested plan:\n• Tue: Crew A → 181 Sector 18 (reroof, day 1)\n• Wed: Crew A continues; Crew B → inspections in Pocket B\n• Thu: finish 181, start estimates follow-ups\nShould I block these in Team Schedule?";
}
if (t.includes("storm") || t.includes("territory") || t.includes("weather")) {
return "Last cell tracked through Sectors 14, 18 and Pocket B on 26 Jun with 1.5\" hail. 23 owners in those zones are good storm-canvass targets — 9 already have open claims. Want a canvass route?";
}
if (t.includes("pipeline") || t.includes("stuck") || t.includes("stall") || t.includes("deal")) {
return "3 deals have been idle over 14 days:\n• Bennett reroof — $21k, no contact in 18 days\n• Foster gutters — $4.2k, awaiting estimate\n• Turner inspection — booked, never closed\nThe Bennett deal is your biggest risk. Draft a re-engagement email?";
}
return "Got it. I can pull leads, build estimates, plan crew schedules, read storm/territory data and analyse your pipeline. Tell me which one to start with, or ask a specific question and I'll dig in.";
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
/**
* Client gate for the dashboard. When the Shell is live, an unauthenticated
* visitor is bounced to the portal; while the session is resolving we hold a
* lightweight splash. When the Shell is NOT configured this is a pass-through,
* so the existing mock demo (any login /dashboard) keeps working unchanged.
*/
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const shell = isShellConfigured();
useEffect(() => {
// Only bounce to login once the SDK has finished booting AND restore() has
// resolved to unauthenticated. Redirecting while boot is still in flight would
// drop a perfectly valid session on reload (the status is transiently not-yet
// "authenticated" during boot).
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
}, [shell, ready, status, router]);
if (shell && (!ready || status !== "authenticated")) {
return (
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
</div>
);
}
return <>{children}</>;
}
-180
View File
@@ -1,180 +0,0 @@
"use client";
import { useEffect, useState } from "react";
/* ---- vertical bar chart (HTML/CSS, animated heights) ---- */
export function BarsChart({
labels,
series,
max,
yTicks,
height = 132,
highlight,
barWidth = 7,
}: {
labels: string[];
series: { color: string; values: number[] }[];
max: number;
yTicks?: string[];
height?: number;
highlight?: number; // single-series: index to highlight, others muted
barWidth?: number;
}) {
const [on, setOn] = useState(false);
useEffect(() => {
const t = setTimeout(() => setOn(true), 40);
return () => clearTimeout(t);
}, []);
return (
<div className="chart" style={{ height }}>
{yTicks && (
<div className="chart-y">
{yTicks.map((t) => <span key={t}>{t}</span>)}
</div>
)}
<div className="chart-plot">
{labels.map((lab, i) => (
<div className="bar-group" key={i}>
<div className="bars">
{series.map((s, si) => {
const muted = highlight !== undefined && i !== highlight;
return (
<span
key={si}
className="vbar"
style={{
width: barWidth,
height: on ? `${Math.max(2, (s.values[i] / max) * 100)}%` : "0%",
background: muted ? "var(--track)" : s.color,
transitionDelay: `${i * 35 + si * 20}ms`,
}}
/>
);
})}
</div>
<span className="bar-x">{lab}</span>
</div>
))}
</div>
</div>
);
}
/* ---- mini sparkline bars (stat cards) ---- */
export function Spark({ values, color, height = 40, width = 8 }: { values: number[]; color: string; height?: number; width?: number }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 40); return () => clearTimeout(t); }, []);
const max = Math.max(...values, 1);
return (
<div className="spark" style={{ height }}>
{values.map((v, i) => (
<span key={i} style={{ width, height: on ? `${(v / max) * 100}%` : "0%", background: color, transitionDelay: `${i * 30}ms` }} />
))}
</div>
);
}
/* ---- donut chart ---- */
export function Donut({
segments, total, label, size = 150, thickness = 18,
}: {
segments: { value: number; color: string }[];
total: string;
label: string;
size?: number;
thickness?: number;
}) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
const r = (size - thickness) / 2;
const c = 2 * Math.PI * r;
const sum = segments.reduce((a, s) => a + s.value, 0) || 1;
let offset = 0;
return (
<div className="donut" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--track)" strokeWidth={thickness} />
{segments.map((s, i) => {
const frac = s.value / sum;
const len = on ? frac * c : 0;
const dash = `${len} ${c - len}`;
const el = (
<circle
key={i}
cx={size / 2} cy={size / 2} r={r} fill="none"
stroke={s.color} strokeWidth={thickness} strokeLinecap="round"
strokeDasharray={dash}
strokeDashoffset={-offset * c}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dasharray .9s cubic-bezier(.2,.7,.2,1)" }}
/>
);
offset += frac;
return el;
})}
</svg>
<div className="donut-c">
<div className="dv">{total}</div>
<div className="dl">{label}</div>
</div>
</div>
);
}
/* ---- semicircle gauge ---- */
export function Gauge({
value, max = 100, color, size = 150, label, sub,
}: {
value: number; max?: number; color: string; size?: number; label?: string; sub?: string;
}) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 60); return () => clearTimeout(t); }, []);
const sw = 14;
const r = (size - sw) / 2;
const cx = size / 2, cy = size / 2;
const semi = Math.PI * r; // half circumference
const frac = Math.min(1, value / max);
const len = on ? frac * semi : 0;
return (
<div className="gauge" style={{ width: size, height: size / 2 + 16 }}>
<svg width={size} height={size / 2 + 16} viewBox={`0 0 ${size} ${size / 2 + 16}`}>
<path d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`} fill="none" stroke="var(--track)" strokeWidth={sw} strokeLinecap="round" />
<path
d={`M ${sw / 2} ${cy} A ${r} ${r} 0 0 1 ${size - sw / 2} ${cy}`}
fill="none" stroke={color} strokeWidth={sw} strokeLinecap="round"
strokeDasharray={`${len} ${semi}`}
style={{ transition: "stroke-dasharray 1s cubic-bezier(.2,.7,.2,1)" }}
/>
</svg>
{(label || sub) && (
<div className="gauge-c">
{label && <div className="gv">{label}</div>}
{sub && <div className="gl">{sub}</div>}
</div>
)}
</div>
);
}
/* ---- progress bar ---- */
export function Progress({ value, color, track }: { value: number; color: string; track?: boolean }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
return (
<div className="bar">
<span style={{ width: on ? `${value}%` : "0%", background: color, transition: "width .9s cubic-bezier(.2,.7,.2,1)" }} />
</div>
);
}
/* ---- horizontal segmented bar (pipeline) ---- */
export function HBar({ value, max, color }: { value: number; max: number; color: string }) {
const [on, setOn] = useState(false);
useEffect(() => { const t = setTimeout(() => setOn(true), 50); return () => clearTimeout(t); }, []);
return (
<div className="bar" style={{ height: 9 }}>
<span style={{ width: on ? `${(value / max) * 100}%` : "0%", background: color, transition: "width .8s cubic-bezier(.2,.7,.2,1)" }} />
</div>
);
}
+83 -223
View File
@@ -1,258 +1,118 @@
"use client";
// ============================================================
// Dashboard shell — premium dark theme. Keeps the sidebar +
// header and swaps the inner content. Profile / Support / Rules
// render real views; other workspace modules show a placeholder.
// ============================================================
import { useEffect, useState } from "react";
import { TrendingUp, Cloud, MoreHorizontal, ArrowUpRight } from "lucide-react";
import { Sidebar } from "./sidebar";
import { Sidebar, NAV_ITEMS } from "./sidebar";
import { Topbar } from "./topbar";
import { FigIcon } from "./figicon";
import { BarsChart, Spark, Donut, Gauge, Progress, HBar } from "./charts";
import { ToastProvider, PageHead, Btn, Icon } from "./ui";
import { Profile } from "./profile";
import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings";
import { NotificationCenter } from "./notification-center";
import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery";
import { Leads } from "./leads";
import { Verify } from "./verify";
import "../../app/dashboard/dashboard.css";
const C = {
orange: "#fda913", orange2: "#fd6d13", yellow: "#ffd60a",
cyan: "#09b9c6", green: "#14bc83", blue: "#285ef0", purple: "#9036e9", red: "#f0563f",
};
const rnd = (v: number, p = 0.12) => Math.max(1, Math.round(v * (1 + (Math.random() * 2 - 1) * p)));
const arr = (base: number[], p = 0.14) => base.map((v) => rnd(v, p));
export function Dashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark");
const [active, setActive] = useState("dashboard");
// Deep link from global search: which conversation to focus once we switch tabs.
const [deepLink, setDeepLink] = useState<{ surface: "messenger" | "inbox"; threadId: string } | null>(null);
function navigateToConversation(surface: "messenger" | "inbox", threadId: string) {
setActive(surface);
setDeepLink({ surface, threadId });
}
useEffect(() => {
// Sync the persisted theme from localStorage (an external system) on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
}, []);
// Deep-link from a clicked push notification. Two paths from the service worker:
// - a tab was already open → it postMessages { type: 'notif-click', threadId } to focus here
// - no tab was open → it opens /dashboard?thread=<id>, which we read once on mount
useEffect(() => {
try {
const t = new URLSearchParams(window.location.search).get("thread");
if (t) {
navigateToConversation("messenger", t);
window.history.replaceState({}, "", window.location.pathname);
}
} catch {}
if (!("serviceWorker" in navigator)) return;
const onMessage = (e: MessageEvent) => {
if (e.data?.type === "notif-click" && e.data.threadId) navigateToConversation("messenger", e.data.threadId);
};
navigator.serviceWorker.addEventListener("message", onMessage);
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
}, []);
function toggle() {
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
}
// ---- live data ----
const [pnl, setPnl] = useState({
revenue: [4, 6, 3, 7, 5, 6, 4], gross: [5, 3, 6, 4, 7, 5, 6],
comm: [3, 5, 4, 6, 5, 7, 4], net: [4, 6, 5, 3, 6, 4, 7],
});
const [rev, setRev] = useState([5, 7, 4, 8, 6, 9, 16, 7, 5, 8]);
const [sparks, setSparks] = useState({
revenue: [4, 7, 5, 9, 6, 10, 8], leads: [6, 4, 8, 5, 9, 6, 10], meets: [5, 8, 6, 9, 7, 10, 8],
});
useEffect(() => {
const id = setInterval(() => {
setPnl((p) => ({ revenue: arr(p.revenue), gross: arr(p.gross), comm: arr(p.comm), net: arr(p.net) }));
setRev((r) => r.map((v, i) => (i === 6 ? 16 : rnd(v, 0.18))));
setSparks((s) => ({ revenue: arr(s.revenue), leads: arr(s.leads), meets: arr(s.meets) }));
}, 2600);
return () => clearInterval(id);
}, []);
const days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const yk = ["8k", "6k", "4k", "2k", "0"];
const item = NAV_ITEMS.find((i) => i.key === active);
const title = item?.label ?? "Dashboard";
const subtitle = item?.subtitle ?? "Workspace module";
return (
<div className="dash-root" data-theme={theme}>
<RealtimeProvider>
<Sidebar active={active} onSelect={setActive} />
<div className="dash-main">
<Topbar theme={theme} onToggle={toggle} />
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
<div className="dash-content">
{/* ===== Statistics ===== */}
<h2 className="sec-title">Statistics</h2>
<div className="grid" style={{ gridTemplateColumns: "1.55fr 1fr", marginBottom: 16 }}>
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr" }}>
<BigStat icon={<FigIcon name="i_revenue" size={18} />} ic={C.orange} tag="GROSS PROFIT $114K" value="$626K" label="Revenue" spark={sparks.revenue} sc={C.orange} />
<BigStat icon={<FigIcon name="i_hotleads" size={18} />} ic={C.red} tag="REQUIRES ACTION" value="20k" label="Hot Leads" spark={sparks.leads} sc={C.purple} />
<BigStat icon={<FigIcon name="i_meets" size={18} />} ic={C.cyan} tag="NEXT 7 DAYS" value="70k" label="Upcoming Meets" spark={sparks.meets} sc={C.cyan} />
</div>
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
<MiniStat icon={<FigIcon name="i_collected" size={16} />} ic={C.cyan} value="$311.3k" label="Collected to Date" sub="32 payments" pill="pill-cyan" />
<MiniStat icon={<FigIcon name="i_ar" size={16} />} ic={C.orange} value="$325.9k" label="Outstanding AR" sub="25 pending" pill="pill-orange" />
<MiniStat icon={<FigIcon name="i_payouts" size={16} />} ic={C.purple} value="$203.7k" label="Pending Payouts" sub="20 pending" pill="pill-blue" />
<MiniStat icon={<FigIcon name="i_vendor" size={16} />} ic={C.blue} value="$432.4k" label="YTD Vendor Spend" sub="57 invoices" pill="pill-blue" />
</div>
</div>
{/* ===== Pipeline / Win / Storm ===== */}
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr", marginBottom: 16 }}>
<div className="dcard">
<div className="card-h"><h3>Open Pipeline</h3><span className="pill pill-orange">27 leads</span></div>
<div className="big-val" style={{ marginBottom: 14 }}>$396K</div>
{[["New", 6], ["Contacted", 7], ["Appointment", 7], ["Estimate", 3]].map(([l, n]) => (
<div className="pl-row" key={l as string}>
<span className="lab">{l}</span>
<HBar value={n as number} max={8} color={C.orange} />
<span className="n">{n}</span>
</div>
))}
</div>
<div className="dcard">
<div className="card-h"><h3>Win Rate</h3><TrendingUp size={16} color={C.green} /></div>
<div className="big-val" style={{ marginBottom: 14 }}>94%</div>
<Progress value={94} color={C.cyan} />
<div className="row" style={{ gap: 16, marginTop: 14, flexWrap: "wrap" }}>
<Legend dot={C.cyan} text="17 Won" />
<Legend dot="var(--muted)" text="18 Decided" />
<Legend dot={C.red} text="1 Lost" />
</div>
</div>
<div className="dcard">
<div className="card-h"><h3>Storm-Attributed Revenue</h3><span className="pill pill-blue">19 Storm Leads</span></div>
<div className="big-val" style={{ marginBottom: 14 }}>24%</div>
<Progress value={24} color={C.blue} />
<div className="lbl" style={{ marginTop: 12 }}>$56K of $626K</div>
</div>
</div>
{/* ===== Roof / Risk / Weather ===== */}
<div className="grid" style={{ gridTemplateColumns: "1fr 1.15fr 1.25fr", marginBottom: 16 }}>
<div className="dcard">
<div className="card-h"><div><h3>Roof Condition</h3><div className="card-sub">Territory health snapshot</div></div></div>
<div className="row" style={{ justifyContent: "center", margin: "6px 0 10px" }}>
<Donut segments={[{ value: 30, color: C.orange }, { value: 14, color: C.cyan }]} total="44" label="Total" />
</div>
<div className="row" style={{ gap: 18, justifyContent: "center" }}>
<Legend dot={C.orange} text="Good" />
<Legend dot={C.cyan} text="Bad" />
</div>
</div>
<div className="dcard">
<div className="card-h"><div><h3>Risk Index</h3><div className="card-sub">Storm Impact Probability</div></div></div>
<div className="row" style={{ justifyContent: "center", margin: "4px 0" }}>
<Gauge value={51} max={100} color={C.blue} size={170} label="51" sub="of 100" />
</div>
<div className="row between" style={{ marginTop: 6 }}>
<Readout big="10 mph" small="Wind Exp." />
<Readout big="52%" small="Vulnerable" color={C.orange} />
</div>
</div>
<div className="dcard">
<div className="row between">
<div>
<div className="row gap-2"><Cloud size={20} color={C.cyan} /><span className="big-val">80&deg;F</span></div>
<div className="lbl">Plano, US</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="card-sub">Updated: 02:48 PM</div>
<div className="row gap-3" style={{ marginTop: 8, justifyContent: "flex-end" }}>
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: "var(--muted)", display: "inline-flex" }}><FigIcon name="i_wind" size={14} /></span> 10 mph</span>
<span className="row gap-2" style={{ fontSize: 12 }}><span style={{ color: C.blue, display: "inline-flex" }}><FigIcon name="i_humidity" size={14} /></span> 82%</span>
</div>
</div>
</div>
<div className="wx-hours">
{[["12 PM", 94], ["5 PM", 79], ["8 PM", 82], ["10 PM", 90], ["1 AM", 92], ["5 AM", 79]].map(([t, te]) => (
<div className="wx-hr" key={t as string}>
<div className="t">{t}</div>
<Cloud size={16} color="var(--faint)" style={{ display: "block", margin: "6px auto 0" }} />
<div className="te">{te}&deg;</div>
</div>
))}
</div>
</div>
</div>
{/* ===== P&L Summary ===== */}
<h2 className="sec-title">P&amp;L Summary</h2>
<div className="grid" style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr", marginBottom: 16 }}>
<PnlCard title="Revenue" value="$626K" labels={days} yk={yk}
series={[{ color: C.orange, values: pnl.revenue }, { color: C.orange2, values: pnl.gross }]} />
<PnlCard title="Gross Profit" value="$114K" labels={days} yk={yk}
series={[{ color: C.cyan, values: pnl.gross }, { color: C.orange, values: pnl.revenue }]} />
<PnlCard title="Commissions" value="$63K" labels={days} yk={yk}
series={[{ color: C.blue, values: pnl.comm }, { color: "#6f9bff", values: pnl.net }]} />
<PnlCard title="Net Profit" value="$51K" labels={days} yk={yk}
series={[{ color: C.orange, values: pnl.net }, { color: C.blue, values: pnl.comm }]} />
</div>
{/* ===== Revenue Potential / Top Reps ===== */}
<div className="grid" style={{ gridTemplateColumns: "1.7fr 1fr" }}>
<div className="dcard">
<div className="card-h">
<div><h3>Revenue Potential</h3><div className="card-sub">By neighborhood rating</div></div>
<span className="pill pill-orange"><ArrowUpRight size={13} /> live</span>
</div>
<BarsChart
labels={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
series={[{ color: C.orange, values: rev }]}
max={18} highlight={6} height={210} barWidth={26}
yTicks={["$240k", "$180k", "$120k", "$60k", "0"]}
/>
</div>
<div className="dcard">
<div className="card-h"><h3>Top Sales Reps</h3><button className="link-btn">View All</button></div>
{[["Hannah Reyes", "hannah@lynkeduppro.com", C.orange], ["Cody Tatum", "cody@lynkeduppro.com", C.cyan], ["Travis Boone", "travis@lynkeduppro.com", C.blue]].map(([nm, em, col], i) => (
<div className="rep-row" key={nm as string}>
<span className="rk">0{i + 1}</span>
<span className="av" style={{ background: col as string, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{(nm as string).split(" ").map((x) => x[0]).join("")}</span>
<div style={{ flex: 1 }}>
<div className="nm">{nm}</div>
<div className="em">{em}</div>
</div>
<span className="amt">$112,500</span>
</div>
))}
</div>
</div>
<ToastProvider>
<NotificationCenter active={active} onNavigate={navigateToConversation} />
{active === "profile" ? <Profile />
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <MessengerSdk focusThreadId={deepLink?.surface === "messenger" ? deepLink.threadId : null} />
: active === "inbox" ? <InboxSdk focusThreadId={deepLink?.surface === "inbox" ? deepLink.threadId : null} />
: active === "settings" ? <Settings />
: active === "gallery" ? <SmartGallery theme={theme} />
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
</div>
</div>
</RealtimeProvider>
</div>
);
}
/* ---- sub components ---- */
function BigStat({ icon, ic, tag, value, label, spark, sc }: { icon: React.ReactNode; ic: string; tag: string; value: string; label: string; spark: number[]; sc: string }) {
// Placeholder for workspace modules that don't have a view yet — keeps the
// full navigation coherent and points users to the live sections.
function ComingSoon({ title, icon, onGo }: { title: string; icon: string; onGo: (k: string) => void }) {
return (
<div className="dcard">
<div className="row between" style={{ marginBottom: 14 }}>
<span className="stat-ic" style={{ background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
<span className="card-sub" style={{ fontWeight: 600, letterSpacing: "0.04em" }}>{tag}</span>
<div className="view">
<PageHead eyebrow="Workspace" title={title} subtitle="This module is part of the LynkedUp Pro workspace." icon={icon} />
<div className="card coming-soon">
<span className="coming-soon-ic"><Icon name={icon} size={34} /></span>
<h3>{title} is coming soon</h3>
<p>This area is reserved for the {title} module. In the meantime, the account experience is fully built out.</p>
<div className="coming-soon-actions">
<Btn icon="user" onClick={() => onGo("profile")}>Open Profile</Btn>
<Btn variant="outline" icon="chat" onClick={() => onGo("support")}>Support Center</Btn>
<Btn variant="ghost" icon="check-circle" onClick={() => onGo("rules")}>Rules</Btn>
</div>
<div className="row between" style={{ alignItems: "flex-end" }}>
<div>
<div className="big-val">{value}</div>
<div className="lbl">{label}</div>
</div>
<Spark values={spark} color={sc} />
</div>
</div>
);
}
function MiniStat({ icon, ic, value, label, sub, pill }: { icon: React.ReactNode; ic: string; value: string; label: string; sub: string; pill: string }) {
return (
<div className="mini-card">
<div className="top">
<span className="stat-ic" style={{ width: 30, height: 30, background: `color-mix(in srgb, ${ic} 18%, transparent)`, color: ic }}>{icon}</span>
<span className={`pill ${pill}`}>{sub}</span>
</div>
<div className="mid-val" style={{ marginTop: 12 }}>{value}</div>
<div className="lbl">{label}</div>
</div>
);
}
function PnlCard({ title, value, labels, yk, series }: { title: string; value: string; labels: string[]; yk: string[]; series: { color: string; values: number[] }[] }) {
return (
<div className="dcard">
<div className="card-h"><h3>{title}</h3><MoreHorizontal size={16} color="var(--faint)" /></div>
<div className="mid-val" style={{ marginBottom: 14 }}>{value}</div>
<BarsChart labels={labels} series={series} max={9} yTicks={yk} height={120} barWidth={5} />
</div>
);
}
function Legend({ dot, text }: { dot: string; text: string }) {
return <span className="row gap-2" style={{ fontSize: 12, color: "var(--muted)" }}><span className="ldot" style={{ background: dot }} /> {text}</span>;
}
function Readout({ big, small, color }: { big: string; small: string; color?: string }) {
return (
<div>
<div style={{ fontSize: 16, fontWeight: 800, color: color || "var(--text)" }}>{big}</div>
<div className="lbl">{small}</div>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { Sparkles } from "lucide-react";
import { figmaIcons } from "./figma-icons";
/** Renders a Figma-exported icon (recolored to currentColor) or a fallback. */
export function FigIcon({ name, size = 18 }: { name: string; size?: number }) {
const svg = figmaIcons[name];
if (!svg) return <Sparkles size={size} />;
return (
<span
className="fig-ic"
style={{ width: size, height: size }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
"use client";
// Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a
// hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread).
import { useEffect, useRef, useState } from "react";
import { Icon } from "./ui";
import { useGlobalSearch, type SearchResult } from "@/lib/search-api";
/** Escape HTML but keep the engine's <em> highlight tags — so a match snippet can't inject markup. */
function safeSnippet(s: string): string {
const esc = s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return esc.replace(/&lt;em&gt;/g, "<em>").replace(/&lt;\/em&gt;/g, "</em>");
}
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
const search = useGlobalSearch();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const term = q.trim();
if (!term) {
setResults([]);
return;
}
let alive = true;
setLoading(true);
const t = setTimeout(() => {
search(term)
.then((r) => { if (alive) setResults(r); })
.catch(() => { if (alive) setResults([]); })
.finally(() => { if (alive) setLoading(false); });
}, 220);
return () => { alive = false; clearTimeout(t); };
}, [q, search]);
useEffect(() => {
function onDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
function pick(r: SearchResult) {
onNavigate(r.surface, r.threadId);
setOpen(false);
setQ("");
}
return (
<div className="gs-wrap" ref={wrapRef}>
<div className="gs-field">
<Icon name="search" size={16} />
<input
className="gs-input"
placeholder="Search conversations…"
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
aria-label="Search conversations"
/>
</div>
{open && q.trim() ? (
<div className="gs-pop">
{loading && results.length === 0 ? <div className="gs-empty">Searching</div> : null}
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
{results.map((r) => (
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
<span className="gs-main">
<span className="gs-title">{r.title}</span>
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
</span>
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
</button>
))}
</div>
) : null}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
// The CRM Inbox, rendered by @insignia/iios-messaging-ui instead of the bespoke in-CRM inbox.
// Live = the be-crm data door (CrmInboxAdapter over crm.inbox.* + crm.mail.*); demo = the SDK's
// MockInboxAdapter.
import { useMemo } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { InboxProvider, Inbox as SdkInbox, type InboxAdapter } from "@insignia/iios-messaging-ui";
import { MockInboxAdapter } from "@insignia/iios-messaging-ui/adapters/mock-inbox";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
import { CrmInboxAdapter } from "@/lib/crm-inbox-adapter";
import type { DataDoor } from "@/lib/crm-messaging-adapter";
const SHELL = isShellConfigured();
export function InboxSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
return (
<div className="view">
{!SHELL && (
<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 the SDK&apos;s mock inbox adapter.
</div>
)}
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox focusThreadId={focusThreadId} /> : <DemoInbox focusThreadId={focusThreadId} />}</div>
</div>
);
}
function DemoInbox({ focusThreadId }: { focusThreadId?: string | null }) {
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
return (
<InboxProvider adapter={adapter}>
<SdkInbox focusThreadId={focusThreadId} />
</InboxProvider>
);
}
function LiveInbox({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell();
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
return (
<InboxProvider adapter={adapter}>
<SdkInbox focusThreadId={focusThreadId} />
</InboxProvider>
);
}
+220
View File
@@ -0,0 +1,220 @@
// ============================================================
// LynkedUp Pro — Leads mock data.
// A storm-restoration roofing pipeline: door-knocked leads in
// the Plano, TX hail zone. Each lead carries a rich detail
// record (contact, property, job, insurance, assignment) that
// powers the lead-detail popup. All client-side so the screen
// is fully interactive without a backend.
// ============================================================
export type LeadStatus = "new" | "contacted" | "appointed" | "closed";
export type LeadPriority = "high" | "medium" | "low";
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
export type Email = { address: string; type?: string; primary?: boolean };
export type Lead = {
id: string; // SAL-001
initials: string;
name: string;
gradient: string;
priority: LeadPriority;
status: LeadStatus;
tag: string; // "Storm Zone"
updated: string; // "3d ago"
setter: string; // compact chip name
// storm banner
storm: { zone: string; date: string; detail: string };
// contact
phones: Phone[];
emails: Email[];
// property
property: { address: string; city: string; state: string; zip: string; type: string };
// job details
job: {
source: string; leadType: string; workType: string; tradeType: string;
urgency: string; canvasser: string; notes: string;
};
// insurance
insurance: {
company: string; claimStatus: string; claimNumber: string;
policyNumber: string; adjusterName: string; adjusterPhone: string;
};
// assignment
assignment: {
assignedTo: string; priority: string; followUp: string;
createdBy: string; createdAt: string;
};
};
const G = {
orange: "linear-gradient(135deg,#fda913,#fd6d13)",
blue: "linear-gradient(135deg,#4f8cff,#2c5cff)",
purple: "linear-gradient(135deg,#b07bf2,#7b53e0)",
green: "linear-gradient(135deg,#33c98a,#1fa46c)",
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
};
export const LEADS: Lead[] = [
{
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
priority: "high", status: "contacted", tag: "Storm Zone", updated: "3d ago", setter: "Cody",
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 500-1000", type: "Mobile", primary: true },
{ number: "(214) 600-2000", type: "Home" },
],
emails: [{ address: "john.martinez@gmail.com", primary: true }],
property: { address: "4821 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Cody Tatum",
notes: "Homeowner showed significant granule loss on south-facing slopes; agreed to inspection.",
},
insurance: {
company: "State Farm", claimStatus: "Filed", claimNumber: "CLM-2026-1000",
policyNumber: "POL-080000", adjusterName: "Marcus Powell", adjusterPhone: "(972) 700-3000",
},
assignment: {
assignedTo: "Jesus Gonzales", priority: "High", followUp: "Jun 4, 2026",
createdBy: "Cody Tatum", createdAt: "May 28, 2026",
},
},
{
id: "SAL-002", initials: "SK", name: "Sarah Kim", gradient: G.purple,
priority: "high", status: "appointed", tag: "Storm Zone", updated: "2d ago", setter: "Shelby",
storm: { zone: "E Plano / Custer Rd", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(972) 501-1037", type: "Mobile", primary: true },
{ number: "(214) 601-2044", type: "Home" },
],
emails: [{ address: "sarah.kim@outlook.com", primary: true }],
property: { address: "4905 Custer Rd", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Hannah Reyes",
notes: "Visible mat exposure on rear elevation. Appointment set for adjuster meet.",
},
insurance: {
company: "Allstate", claimStatus: "Approved", claimNumber: "CLM-2026-1037",
policyNumber: "POL-081037", adjusterName: "Dana Whitfield", adjusterPhone: "(972) 700-3037",
},
assignment: {
assignedTo: "Hannah Reyes", priority: "High", followUp: "Jun 6, 2026",
createdBy: "Shelby Greer", createdAt: "May 29, 2026",
},
},
{
id: "SAL-003", initials: "RC", name: "Robert Chen", gradient: G.green,
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Dalton",
storm: { zone: "E Plano / Independence Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 502-1074", type: "Mobile", primary: true },
],
emails: [{ address: "robert.chen@gmail.com", primary: true }],
property: { address: "5012 Independence Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Travis Boone",
notes: "Full replacement approved and installed. Final invoice cleared.",
},
insurance: {
company: "Farmers", claimStatus: "Paid", claimNumber: "CLM-2026-1074",
policyNumber: "POL-081074", adjusterName: "Leah Ortiz", adjusterPhone: "(972) 700-3074",
},
assignment: {
assignedTo: "Travis Boone", priority: "High", followUp: "—",
createdBy: "Dalton Pruitt", createdAt: "May 20, 2026",
},
},
{
id: "SAL-004", initials: "MG", name: "Maria Garcia", gradient: G.cyan,
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Hannah",
storm: { zone: "E Plano / Alma Dr", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(972) 503-1111", type: "Mobile", primary: true },
],
emails: [{ address: "maria.garcia@gmail.com", primary: true }],
property: { address: "4720 Alma Dr", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Shelby Greer",
notes: "Signed contract; build complete. Awaiting review request.",
},
insurance: {
company: "USAA", claimStatus: "Paid", claimNumber: "CLM-2026-1111",
policyNumber: "POL-081111", adjusterName: "Grant Mueller", adjusterPhone: "(972) 700-3111",
},
assignment: {
assignedTo: "Shelby Greer", priority: "High", followUp: "—",
createdBy: "Hannah Reyes", createdAt: "May 18, 2026",
},
},
{
id: "SAL-005", initials: "DT", name: "David Thompson", gradient: G.blue,
priority: "high", status: "appointed", tag: "Storm Zone", updated: "1d ago", setter: "Travis",
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 504-1148", type: "Mobile", primary: true },
{ number: "(214) 604-2148", type: "Work" },
],
emails: [{ address: "david.thompson@gmail.com", primary: true }],
property: { address: "5130 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Dalton Pruitt",
notes: "Adjuster appointment confirmed for next week. Bring hail map + photos.",
},
insurance: {
company: "Liberty Mutual", claimStatus: "Filed", claimNumber: "CLM-2026-1148",
policyNumber: "POL-081148", adjusterName: "Priya Nair", adjusterPhone: "(972) 700-3148",
},
assignment: {
assignedTo: "Dalton Pruitt", priority: "High", followUp: "Jun 9, 2026",
createdBy: "Travis Boone", createdAt: "May 30, 2026",
},
},
];
// Header stat — the full book is larger than the loaded page.
export const TOTAL_LEADS = 35;
export const STATUS_META: Record<LeadStatus, { label: string; tone: string }> = {
new: { label: "New", tone: "blue" },
contacted: { label: "Contacted", tone: "orange" },
appointed: { label: "Appointed", tone: "purple" },
closed: { label: "Closed", tone: "green" },
};
export const PRIORITY_META: Record<LeadPriority, { label: string; tone: string }> = {
high: { label: "High", tone: "red" },
medium: { label: "Medium", tone: "orange" },
low: { label: "Low", tone: "muted" },
};
/* ---------------------------------------------------------- */
/* Reps + option lists — power the New Lead form */
/* ---------------------------------------------------------- */
export type Rep = { id: string; initials: string; name: string; email: string };
export const REPS: Rep[] = [
{ id: "LUP-1040", initials: "CT", name: "Cody Tatum", email: "cody.tatum@lynkeduppro.com" },
{ id: "LUP-1041", initials: "HR", name: "Hannah Reyes", email: "hannah.reyes@lynkeduppro.com" },
{ id: "LUP-1042", initials: "TB", name: "Travis Boone", email: "travis.boone@lynkeduppro.com" },
{ id: "LUP-1043", initials: "SG", name: "Shelby Greer", email: "shelby.greer@lynkeduppro.com" },
{ id: "LUP-1044", initials: "DP", name: "Dalton Pruitt", email: "dalton.pruitt@lynkeduppro.com" },
];
export const LEAD_SOURCES = ["Door Knock", "Referral", "Storm Chase", "Mailer / Postcard", "Sign Call", "Insurance Agent Referral", "Repeat Customer", "Social Media", "Other"];
export const LEAD_TYPES = ["Insurance", "Retail"];
export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"];
export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"];
export const PROPERTY_TYPES = ["Single Family", "Multi Family", "Commercial"];
export const CLAIM_STATUSES = ["Not Filed", "Filed", "Approved", "Paid", "Denied"];
+615
View File
@@ -0,0 +1,615 @@
"use client";
// ============================================================
// Leads — storm-restoration pipeline board.
// · Hero head : storm banner + headline stats (by status)
// · Toolbar : search + status filter tabs
// · Board : lead cards (avatar, priority ring, status,
// address, phone, source, rep, updated)
// · Detail : click a lead → rich popup with Contact,
// Property, Job Details, Insurance, Assignment
// Data comes from leads-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, type ReactNode } from "react";
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
import {
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
type Lead, type LeadStatus,
} from "./leads-data";
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
{ value: "contacted", label: "Contacted" },
{ value: "appointed", label: "Appointed" },
{ value: "closed", label: "Closed" },
];
export function Leads() {
const toast = useToast();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
const [selected, setSelected] = useState<Lead | null>(null);
const [newOpen, setNewOpen] = useState(false);
const countByStatus = useMemo(() => {
const m: Record<string, number> = {};
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
return m;
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return LEADS.filter((l) => {
const matchStatus = filter === "all" || l.status === filter;
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
return matchStatus && (!q || hay.includes(q));
});
}, [query, filter]);
return (
<div className="view leads">
<PageHead
eyebrow="Sales"
title="Leads"
subtitle={`${TOTAL_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
icon="leads"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
/>
{/* ---- stat strip ---------------------------------------- */}
<div className="leads-stats">
<StatCard label="Total leads" value={TOTAL_LEADS} icon="leads" tone="orange" />
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
</div>
{/* ---- toolbar ------------------------------------------- */}
<div className="leads-toolbar">
<div className="leads-search">
<Icon name="search" size={16} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, address, source…"
aria-label="Search leads"
/>
{query && <button className="leads-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="leads-tabs" role="tablist">
{STATUS_TABS.map((t) => (
<button
key={t.value}
role="tab"
aria-selected={filter === t.value}
className={`leads-tab ${filter === t.value ? "active" : ""}`}
onClick={() => setFilter(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
{/* ---- board --------------------------------------------- */}
{filtered.length === 0 ? (
<div className="card leads-empty">
<Icon name="search" size={30} />
<h3>No leads match</h3>
<p>Try a different search or clear the status filter.</p>
</div>
) : (
<div className="leads-grid">
{filtered.map((l) => (
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
))}
</div>
)}
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Stat card */
/* ---------------------------------------------------------- */
function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) {
return (
<div className={`leads-stat tone-${tone}`}>
<span className="leads-stat-ic"><Icon name={icon} size={17} /></span>
<div className="leads-stat-body">
<div className="leads-stat-val">{value}</div>
<div className="leads-stat-lbl">{label}</div>
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* Lead card */
/* ---------------------------------------------------------- */
function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
const status = STATUS_META[lead.status];
const priority = PRIORITY_META[lead.priority];
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
return (
<button className="lead-card" onClick={onOpen}>
<div className="lead-card-top">
<span className={`lead-ava prio-${lead.priority}`}>
<Avatar initials={lead.initials} gradient={lead.gradient} size={46} />
</span>
<div className="lead-card-id">
<div className="lead-card-name">{lead.name}</div>
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span> · <Icon name="storm" size={12} /> {lead.tag}</div>
</div>
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
</div>
<div className="lead-card-row"><Icon name="pin" size={14} /><span>{lead.property.address}, {lead.property.city}, {lead.property.state}</span></div>
<div className="lead-card-row"><Icon name="phone" size={14} /><span>{primaryPhone?.number}</span></div>
<div className="lead-card-foot">
<Pill tone={status.tone}>{status.label}</Pill>
<span className="lead-source"><Icon name="pin" size={12} /> {lead.job.source}</span>
<span className="lead-card-spacer" />
<span className="lead-rep" title={`Canvasser: ${lead.job.canvasser}`}>{lead.job.canvasser}</span>
<span className="lead-updated">{lead.updated}</span>
</div>
</button>
);
}
/* ---------------------------------------------------------- */
/* Lead detail popup */
/* ---------------------------------------------------------- */
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
if (!lead) return null;
const status = STATUS_META[lead.status];
const priority = PRIORITY_META[lead.priority];
return (
<Modal
open={!!lead}
onClose={onClose}
size="lg"
title={lead.name}
subtitle={`${lead.id} · ${lead.tag}`}
icon="leads"
footer={
<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn variant="outline" icon="mail">Email</Btn>
<Btn icon="check-circle">Update Status</Btn>
</>
}
>
<div className="lead-detail">
{/* identity strip */}
<div className="ld-identity">
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
<div className="ld-identity-body">
<div className="ld-identity-name">{lead.name}</div>
<div className="ld-identity-pills">
<Pill tone={status.tone}>{status.label}</Pill>
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
</div>
</div>
</div>
{/* storm banner */}
<div className="ld-storm">
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
<div>
<div className="ld-storm-zone">{lead.storm.zone}</div>
<div className="ld-storm-meta">{lead.storm.date} · {lead.storm.detail}</div>
</div>
</div>
<div className="ld-grid">
{/* Contact */}
<Section title="Contact" icon="user">
<div className="ld-sublabel">Phone Numbers</div>
{lead.phones.map((p, i) => (
<div className="ld-contact-row" key={i}>
<Icon name="phone" size={14} />
<span className="ld-contact-val">{p.number}</span>
<span className="ld-contact-tag">{p.type}</span>
{p.primary && <Pill tone="green">Primary</Pill>}
</div>
))}
<div className="ld-sublabel">Email Addresses</div>
{lead.emails.map((e, i) => (
<div className="ld-contact-row" key={i}>
<Icon name="mail" size={14} />
<span className="ld-contact-val">{e.address}</span>
{e.primary && <Pill tone="green">Primary</Pill>}
</div>
))}
</Section>
{/* Property */}
<Section title="Property" icon="owners">
<Dl label="Address" value={lead.property.address} />
<Dl label="City" value={lead.property.city} />
<Dl label="State" value={lead.property.state} />
<Dl label="ZIP" value={lead.property.zip} />
<Dl label="Property Type" value={lead.property.type} />
</Section>
{/* Job Details */}
<Section title="Job Details" icon="projects">
<Dl label="Lead Source" value={lead.job.source} />
<Dl label="Lead Type" value={lead.job.leadType} />
<Dl label="Work Type" value={lead.job.workType} />
<Dl label="Trade Type" value={lead.job.tradeType} />
<Dl label="Urgency" value={lead.job.urgency} />
<Dl label="Canvasser" value={lead.job.canvasser} />
<div className="ld-notes">
<div className="ld-sublabel">Field Notes</div>
<p>{lead.job.notes}</p>
</div>
</Section>
{/* Insurance */}
<Section title="Insurance" icon="shield">
<Dl label="Insurance Company" value={lead.insurance.company} />
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
</Section>
{/* Assignment */}
<Section title="Assignment" icon="team" wide>
<div className="ld-assign">
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
<Dl label="Priority" value={lead.assignment.priority} />
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
<Dl label="Created By" value={lead.assignment.createdBy} />
<Dl label="Created At" value={lead.assignment.createdAt} />
</div>
</Section>
</div>
</div>
</Modal>
);
}
function Section({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
return (
<div className={`ld-section ${wide ? "wide" : ""}`}>
<div className="ld-section-head"><Icon name={icon} size={15} /> {title}</div>
<div className="ld-section-body">{children}</div>
</div>
);
}
function Dl({ label, value }: { label: string; value: string }) {
return (
<div className="ld-dl">
<span className="ld-dl-k">{label}</span>
<span className="ld-dl-v">{value}</span>
</div>
);
}
/* ---------------------------------------------------------- */
/* New Lead — Quick / Full Form intake */
/* ---------------------------------------------------------- */
type Priority = "Low" | "Medium" | "High";
type Urgency = "Standard" | "High" | "Emergency";
type PhoneRow = { number: string; type: string };
type EmailRow = { address: string };
const FULL_STEPS = [
{ value: "contact", label: "Contact", icon: "user" },
{ value: "property", label: "Property", icon: "owners" },
{ value: "job", label: "Job Details", icon: "projects" },
{ value: "insurance", label: "Insurance", icon: "shield" },
{ value: "assignment", label: "Assignment", icon: "team" },
];
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
const BLANK = {
firstName: "", lastName: "",
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
emails: [] as EmailRow[],
address: "", city: "", state: "TX", zip: "", propertyType: "",
photos: [] as string[],
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
assignRep: "", priority: "Medium" as Priority, followUp: "",
};
function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
const toast = useToast();
const [mode, setMode] = useState<"quick" | "full">("quick");
const [section, setSection] = useState("contact");
const [f, setF] = useState({ ...BLANK });
const set = (k: string) => (e: { target: { value: string } }) =>
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
// multi-value handlers
const addPhone = () => setF((s) => ({ ...s, phones: [...s.phones, { number: "", type: "Mobile" }] }));
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => ({ ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) }));
const removePhone = (i: number) => setF((s) => ({ ...s, phones: s.phones.filter((_, j) => j !== i) }));
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
const addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
function close() { reset(); onClose(); }
function submit() {
const name = `${f.firstName} ${f.lastName}`.trim();
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
close();
}
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
const isFirstStep = stepIdx <= 0;
const isLastStep = stepIdx === FULL_STEPS.length - 1;
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
return (
<Modal
open={open}
onClose={close}
size="lg"
title="New Lead"
subtitle="Full lead profile with insurance and assignment details."
icon="plus"
footer={
mode === "full" ? (
<>
<Btn variant="ghost" onClick={close}>Cancel</Btn>
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
{isLastStep
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
</>
) : (
<>
<Btn variant="ghost" onClick={close}>Cancel</Btn>
<Btn icon="check" onClick={submit}>Create Lead</Btn>
</>
)
}
>
<div className="nl-form">
<Segmented
value={mode}
onChange={(v) => setMode(v as "quick" | "full")}
options={[{ value: "quick", label: "Quick", icon: "star" }, { value: "full", label: "Full Form", icon: "edit" }]}
/>
{mode === "quick" ? (
<div className="nl-grid">
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
{f.source === "Referral" && (
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
)}
{f.source === "Door Knock" && (
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={REPS} /></Field></div>
)}
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
</div>
) : (
<>
<SegTabs
value={section}
onChange={setSection}
tabs={FULL_STEPS}
/>
{section === "contact" && (
<div className="nl-grid">
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
<div className="nl-full">
<div className="ds-field-lbl">Phone Numbers</div>
{f.phones.map((p, i) => (
<div className="nl-multirow" key={i}>
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
</div>
))}
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
</div>
<div className="nl-full">
<div className="ds-field-lbl">Email Addresses</div>
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
{f.emails.map((em, i) => (
<div className="nl-multirow" key={i}>
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
</div>
))}
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
</div>
</div>
)}
{section === "property" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} placeholder="TX" /></Field>
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
<div className="nl-full">
<div className="ds-field-lbl">Site Photos</div>
<button type="button" className="nl-photos" onClick={addPhoto}>
<Icon name="camera" size={22} />
<span className="nl-photos-t">Tap to add photos</span>
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
</button>
{f.photos.length > 0 && (
<div className="nl-photo-chips">
{f.photos.map((p, i) => <span key={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
</div>
)}
</div>
</div>
)}
{section === "job" && (
<div className="nl-grid">
<Field label="Lead Source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
<Field label="Lead Type"><Select value={f.leadType} onChange={set("leadType")} options={LEAD_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
<Field label="Work Type"><Select value={f.workType} onChange={set("workType")} options={WORK_TYPES} placeholder="Roof Replacement, Repair…" /></Field>
<Field label="Trade Type"><Select value={f.tradeType} onChange={set("tradeType")} options={TRADE_TYPES} placeholder="Roofing, Gutter, Siding…" /></Field>
<div className="nl-full"><UrgencyPicker value={f.urgency} onChange={(u) => setF((s) => ({ ...s, urgency: u }))} /></div>
<div className="nl-full"><Field label="Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={set("notes")} placeholder="First impression, visible damage, special circumstances…" /></Field></div>
</div>
)}
{section === "insurance" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={set("insCompany")} placeholder="State Farm" /></Field></div>
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={set("claimNumber")} placeholder="e.g. CLM-2026-00482" /></Field>
<Field label="Claim Status"><Select value={f.claimStatus} onChange={set("claimStatus")} options={CLAIM_STATUSES} placeholder="Select status…" /></Field>
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={set("adjusterName")} placeholder="Full name" /></Field>
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={set("adjusterPhone")} placeholder="(555) 000-0000" /></Field>
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={set("policyNumber")} placeholder="e.g. POL-7734892-A" /></Field></div>
</div>
)}
{section === "assignment" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></Field></div>
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
</div>
)}
</>
)}
</div>
</Modal>
);
}
function Select({ value, onChange, options, placeholder }: { value: string; onChange: (e: { target: { value: string } }) => void; options: string[]; placeholder?: string }) {
return (
<select className={`ds-select ${!value && placeholder ? "is-placeholder" : ""}`} value={value} onChange={onChange}>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
function RepSelect({ value, onChange, options }: { value: string; onChange: (e: { target: { value: string } }) => void; options: { id: string; initials: string; name: string }[] }) {
return (
<select className="ds-select" value={value} onChange={onChange}>
{options.map((r) => <option key={r.id || "none"} value={r.id}>{r.id ? `${r.name} · ${r.id}` : "— Unassigned"}</option>)}
</select>
);
}
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.id === value);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
}, [query, options]);
if (selected) {
return (
<div className="nl-canvasser-chip">
<Avatar initials={selected.initials} size={28} />
<span className="nl-canvasser-name">{selected.name}</span>
<span className="nl-canvasser-email">{selected.email}</span>
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
</div>
);
}
return (
<div className="nl-canvasser">
<input
className="ds-input"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search canvasser by name or email"
/>
{open && matches.length > 0 && (
<div className="nl-canvasser-menu">
{matches.map((o) => (
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
<Avatar initials={o.initials} size={26} />
<span className="nl-canvasser-name">{o.name}</span>
<span className="nl-canvasser-email">{o.email}</span>
</button>
))}
</div>
)}
{open && matches.length === 0 && (
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
)}
</div>
);
}
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
return (
<div className="ds-field">
<span className="ds-field-lbl">Priority</span>
<div className="nl-prio">
{(["Low", "Medium", "High"] as Priority[]).map((p) => (
<button key={p} type="button" className={`nl-prio-btn ${value === p ? `active ${p.toLowerCase()}` : ""}`} onClick={() => onChange(p)}>{p}</button>
))}
</div>
</div>
);
}
function UrgencyPicker({ value, onChange }: { value: Urgency; onChange: (u: Urgency) => void }) {
return (
<div className="ds-field">
<span className="ds-field-lbl">Urgency</span>
<div className="nl-prio">
{(["Standard", "High", "Emergency"] as Urgency[]).map((u) => (
<button key={u} type="button" className={`nl-prio-btn urg ${value === u ? `active ${u.toLowerCase()}` : ""}`} onClick={() => onChange(u)}>{u}</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,68 @@
"use client";
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter.
import { useMemo } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
const SHELL = isShellConfigured();
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
return (
<div className="view">
{!SHELL && (
<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 the SDK&apos;s mock adapter.
</div>
)}
<div className="miu-host">{SHELL ? <LiveHost focusThreadId={focusThreadId} /> : <DemoHost focusThreadId={focusThreadId} />}</div>
</div>
);
}
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
// (Rules-of-Hooks safe — the other branch never renders).
function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger focusThreadId={focusThreadId} />
</MessagingProvider>
);
}
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell();
const { user } = useAuth();
const socket = useRealtime();
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
[sdk, user?.id, socket],
);
if (!adapter) return <div className="miu-empty">Loading</div>;
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger focusThreadId={focusThreadId} />
</MessagingProvider>
);
}
@@ -0,0 +1,74 @@
"use client";
// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't
// available (demo mode, or a browser without ServiceWorker/PushManager).
import { useState } from "react";
import { Icon } from "./ui";
import { usePushNotifications } from "@/lib/push-notifications";
export function NotificationBell() {
const push = usePushNotifications();
const [open, setOpen] = useState(false);
if (!push.supported) return null;
const denied = push.permission === "denied";
return (
<div style={{ position: "relative" }}>
<button
className="ic-btn"
aria-label="Notifications"
aria-haspopup="menu"
aria-expanded={open}
style={{ position: "relative" }}
onClick={() => setOpen((o) => !o)}
>
<Icon name="bell" size={18} />
<span
style={{
position: "absolute",
top: 9,
right: 10,
width: 7,
height: 7,
borderRadius: 99,
background: push.subscribed ? "var(--orange)" : "var(--border)",
border: "2px solid var(--panel)",
}}
/>
</button>
{open && (
<>
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
{push.subscribed
? "You'll get push notifications for new direct messages and mentions, even when this tab is closed."
: "Get notified about direct messages and mentions when the CRM isn't open."}
</p>
{denied ? (
<p style={{ fontSize: 12, color: "var(--danger, #c0392b)", margin: 0 }}>
Notifications are blocked in your browser settings. Allow them for this site, then try again.
</p>
) : push.subscribed ? (
<button className="ds-btn v-ghost full" disabled={push.busy} onClick={() => push.disable()}>
{push.busy ? "Turning off…" : "Turn off notifications"}
</button>
) : (
<button className="ds-btn v-primary full" disabled={push.busy} onClick={() => push.enable()}>
{push.busy ? "Enabling…" : "Enable notifications"}
</button>
)}
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
// deep-links to the conversation. Renders nothing.
import { useEffect, useMemo, useRef } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
import { useToast } from "./ui";
const SHELL = isShellConfigured();
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
const socket = useRealtime();
const { sdk } = useAppShell();
const { user } = useAuth();
const toast = useToast();
const me = user?.id;
// Keep the current tab readable inside the (stable) subscription callback.
const activeRef = useRef(active);
activeRef.current = active;
const adapter = useMemo<MessagingAdapter | null>(
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
[sdk, me, socket],
);
useEffect(() => {
if (!SHELL || !adapter?.subscribeActivity) return;
return adapter.subscribeActivity(({ threadId, message }) => {
if (message.actorId === me) return; // never notify me about my own message
if (activeRef.current === "messenger") return; // already watching chat live
toast.push({
tone: "info",
title: "New message",
desc: message.text?.slice(0, 90) || "You have a new message",
onClick: () => onNavigate("messenger", threadId),
});
});
}, [adapter, me, toast, onNavigate]);
return null;
}
+679
View File
@@ -0,0 +1,679 @@
"use client";
// ============================================================
// Profile — header card + 6 tabs:
// Personal Info · KYC · Security · Notifications · Privacy · Devices
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations,
contactTimezones, contactTimes, loginActivity, consentGroups,
passwordStrength, maskEmail, maskPhone,
type KycSlot, type NotifCategory, type ConsentGroup,
} from "./account-data";
import {
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
StatusDot, Toggle, useToast,
} from "./ui";
const TABS = [
{ value: "personal", label: "Personal Info", icon: "user" },
{ value: "kyc", label: "KYC", icon: "shield-check" },
{ value: "security", label: "Security", icon: "lock" },
{ value: "notifications", label: "Notifications", icon: "bell" },
{ value: "privacy", label: "Privacy & Consent", icon: "privacy" },
{ value: "devices", label: "Devices", icon: "devices" },
];
export function Profile() {
const [tab, setTab] = useState("personal");
return (
<div className="view">
<PageHead
eyebrow="My Account"
title="Profile"
subtitle="Manage your identity, verification, security and preferences."
icon="user"
actions={<Pill tone="green"><StatusDot status="online" /> Account active</Pill>}
/>
<ProfileHeaderCard />
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
<div className="view-body">
{tab === "personal" && <PersonalInfoTab />}
{tab === "kyc" && <KycTab />}
{tab === "security" && <SecurityTab />}
{tab === "notifications" && <NotificationsTab />}
{tab === "privacy" && <PrivacyTab />}
{tab === "devices" && <DevicesTab />}
</div>
</div>
);
}
/* ============================================================ */
/* Header card */
/* ============================================================ */
function ProfileHeaderCard() {
const { push } = useToast();
return (
<div className="card profile-hero">
<div className="profile-hero-bg" />
<div className="profile-hero-main">
<div className="profile-hero-avatar">
<Avatar initials={user.initials} gradient={user.avatarGradient} size={86} square />
<button className="profile-hero-cam" aria-label="Change photo" onClick={() => push({ tone: "info", title: "Photo upload", desc: "Choose a JPG or PNG up to 5 MB." })}>
<Icon name="camera" size={14} />
</button>
</div>
<div className="profile-hero-id">
<div className="profile-hero-name">
{user.name}
<Icon name="check-circle" size={18} className="verified-badge" />
</div>
<div className="profile-hero-role">{user.role} · Member since {user.memberSince}</div>
<div className="profile-hero-chips">
<span className="hero-chip"><Icon name="pin" size={13} /> {user.sector} · {user.pocket}</span>
<span className="hero-chip"><Icon name="owners" size={13} /> {user.category}</span>
<span className="hero-chip accent"><Icon name="check" size={13} /> House {user.plot}</span>
</div>
</div>
</div>
<div className="profile-hero-stats">
<HeroStat value={user.category} label="Category" />
<HeroStat value={user.size} label="House size" />
<HeroStat value="33%" label="KYC complete" accent="var(--orange)" />
</div>
</div>
);
}
function HeroStat({ value, label, accent }: { value: string; label: string; accent?: string }) {
return (
<div className="hero-stat">
<div className="hero-stat-v" style={accent ? { color: accent } : undefined}>{value}</div>
<div className="hero-stat-l">{label}</div>
</div>
);
}
/* ============================================================ */
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
/* ============================================================ */
type ContactKind = "email" | "mobile" | "whatsapp";
function PersonalInfoTab() {
const { push } = useToast();
const [verified, setVerified] = useState(false);
const [plot, setPlot] = useState("");
const [err, setErr] = useState("");
const [change, setChange] = useState<null | ContactKind>(null);
// editable contact values (live-updating after OTP confirmation)
const [contacts, setContacts] = useState({ email: user.email.full, mobile: user.mobile.full, whatsapp: user.whatsapp.full });
function verify() {
if (plot.trim() === user.plot) { setVerified(true); setErr(""); push({ tone: "success", title: "Verified", desc: "Your full details are now visible and editable." }); }
else setErr(`That doesn't match the house number on file (hint: it's ${user.plot}).`);
}
function mask(kind: ContactKind, full: string) {
return kind === "email" ? maskEmail(full) : maskPhone(full);
}
const contactRows: { key: ContactKind; label: string; full: string; masked: string }[] = [
{ key: "email", label: "Email", full: contacts.email, masked: mask("email", contacts.email) },
{ key: "mobile", label: "Mobile", full: contacts.mobile, masked: mask("mobile", contacts.mobile) },
{ key: "whatsapp", label: "WhatsApp", full: contacts.whatsapp, masked: mask("whatsapp", contacts.whatsapp) },
];
return (
<div className="grid-2">
<div className="card">
<div className="card-head">
<h3>Contact details</h3>
{verified
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
: <Pill tone="orange"><Icon name="eye-off" size={13} /> Masked</Pill>}
</div>
{!verified && (
<div className="reveal-gate">
<div className="reveal-gate-ic"><Icon name="lock" size={20} /></div>
<div className="reveal-gate-txt">
<strong>Verify to reveal &amp; edit</strong>
<span>Sensitive details are masked. Enter your house number to unmask and edit them.</span>
</div>
<div className="reveal-gate-form">
<input className="ds-input" placeholder="House number" value={plot} maxLength={6}
onChange={(e) => { setPlot(e.target.value.replace(/\D/g, "")); setErr(""); }}
onKeyDown={(e) => e.key === "Enter" && verify()} />
<Btn icon="check" onClick={verify}>Reveal</Btn>
</div>
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
</div>
)}
<dl className="kv-list">
{contactRows.map((f) => (
<div className="kv" key={f.key}>
<dt>{f.label}</dt>
<dd className={verified ? "kv-edit" : "masked"}>
{verified ? f.full : f.masked}
{verified && (
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
)}
</dd>
</div>
))}
</dl>
<div className="card-actions">
<Btn variant="outline" icon="mail" onClick={() => setChange("email")} disabled={!verified}>Change email</Btn>
<Btn variant="outline" icon="phone" onClick={() => setChange("mobile")} disabled={!verified}>Change mobile</Btn>
</div>
{!verified && <p className="muted-note"><Icon name="info" size={13} /> Reveal your details first to edit contact information.</p>}
</div>
<div className="card">
<div className="card-head">
<h3>Account &amp; identity</h3>
{verified
? <Pill tone="green"><Icon name="eye" size={13} /> Revealed</Pill>
: <Pill tone="muted"><Icon name="lock" size={12} /> Protected</Pill>}
</div>
<dl className="kv-list">
<div className="kv"><dt>Full name</dt><dd>{user.name}</dd></div>
<div className="kv"><dt>Role</dt><dd>{user.role}</dd></div>
<div className="kv"><dt>Member since</dt><dd>{user.memberSince}</dd></div>
<div className="kv"><dt>Account status</dt><dd><Pill tone="green"><StatusDot status="online" /> Active</Pill></dd></div>
<div className="kv">
<dt>PAN</dt>
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.pan.full : user.pan.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
</div>
<div className="kv">
<dt>Aadhaar</dt>
<dd className={verified ? "kv-edit" : "masked"}>{verified ? user.aadhaar.full : user.aadhaar.masked}{verified && <span className="kv-lock"><Icon name="lock" size={12} /> via KYC</span>}</dd>
</div>
</dl>
<p className="muted-note"><Icon name="info" size={13} /> PAN &amp; Aadhaar are managed under the KYC tab and can&apos;t be edited here.</p>
</div>
<ChangeContactModal
kind={change}
current={change ? contacts[change] : ""}
onClose={() => setChange(null)}
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
/>
</div>
);
}
const CONTACT_LABEL: Record<ContactKind, string> = { email: "email", mobile: "mobile", whatsapp: "WhatsApp number" };
// OTP-to-registered-mobile flow for changing a contact field. On confirm the
// new value is saved back so the Personal Info card updates live.
function ChangeContactModal({ kind, current, onClose, onSave }: {
kind: null | ContactKind; current: string; onClose: () => void; onSave: (kind: ContactKind, value: string) => void;
}) {
const { push } = useToast();
const [step, setStep] = useState<"enter" | "otp">("enter");
const [val, setVal] = useState("");
const [otp, setOtp] = useState("");
// prefill with the current value whenever a new field is opened
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (kind) { setStep("enter"); setVal(current); setOtp(""); }
}, [kind, current]);
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
const isEmail = kind === "email";
const label = kind ? CONTACT_LABEL[kind] : "";
return (
<Modal
open={kind != null} onClose={close}
icon={isEmail ? "mail" : kind === "whatsapp" ? "chat" : "phone"}
title={`Change ${label}`}
subtitle={step === "enter" ? "We'll send a code to your registered mobile to confirm." : `Enter the 6-digit code sent to ${user.mobile.masked}`}
footer={
step === "enter"
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="send" disabled={!val.trim() || val.trim() === current} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: `OTP sent to your registered mobile ${user.mobile.masked}.` }); }}>Send code</Btn></>
: <><Btn variant="ghost" onClick={() => setStep("enter")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={() => { if (kind) onSave(kind, val.trim()); push({ tone: "success", title: `${label[0].toUpperCase()}${label.slice(1)} updated`, desc: "Your change has been confirmed." }); close(); }}>Confirm</Btn></>
}
>
{step === "enter" ? (
<Field label={`New ${label}`} hint="A one-time code is always sent to your existing registered mobile — never to the new destination.">
<input className="ds-input" type={isEmail ? "email" : "tel"} placeholder={isEmail ? "you@example.com" : "+91 90000 00000"} value={val} onChange={(e) => setVal(e.target.value)} />
</Field>
) : (
<div className="otp-wrap">
<OtpField value={otp} onChange={setOtp} />
<button className="link-inline" onClick={() => push({ tone: "info", title: "Code resent" })}>Resend code</button>
</div>
)}
</Modal>
);
}
/* ============================================================ */
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
/* ============================================================ */
function KycTab() {
const { push } = useToast();
const [slots, setSlots] = useState<KycSlot[]>(kycSeed.slots);
const idDoc = slots.find((s) => s.key === "id")?.docId ?? null;
const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null;
const completion = useMemo(() => {
const done = slots.filter((s) => s.status === "verified" || s.status === "uploaded").length;
return Math.round((done / slots.length) * 100);
}, [slots]);
function options(key: KycSlot["key"]) {
const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : [];
return kycDocTypes.filter((d) => want.includes(d.use));
}
function pickDoc(key: KycSlot["key"], docId: string) {
// enforce two-different-documents rule between ID and address
if (key === "id" && docId === addrDoc) { push({ tone: "error", title: "Pick a different document", desc: "Identity and address proofs must be two different documents." }); return; }
if (key === "address" && docId === idDoc) { push({ tone: "error", title: "Pick a different document", desc: "Address and identity proofs must be two different documents." }); return; }
setSlots((s) => s.map((x) => x.key === key ? { ...x, docId } : x));
}
function upload(key: KycSlot["key"]) {
const slot = slots.find((s) => s.key === key)!;
if (key !== "photo" && !slot.docId) { push({ tone: "error", title: "Select a document type first" }); return; }
const name = key === "photo" ? "selfie.jpg" : `${slot.docId}_upload.pdf`;
setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: name, status: "uploaded", note: "Under review" } : x));
push({ tone: "success", title: "Uploaded", desc: "Your document is queued for review." });
}
function remove(key: KycSlot["key"]) {
setSlots((s) => s.map((x) => x.key === key ? { ...x, fileName: null, docId: key === "photo" ? null : x.docId, status: "missing", note: undefined } : x));
}
const photoDone = slots.find((s) => s.key === "photo")!.status !== "missing";
return (
<div className="grid-side">
<div className="card">
<div className="card-head">
<h3>Verification documents</h3>
<Pill tone="orange">{completion}% complete</Pill>
</div>
<div className="kyc-progress">
<div className="kyc-progress-bar"><span style={{ width: `${completion}%` }} /></div>
<div className="kyc-progress-legend">
{slots.map((s) => (
<span key={s.key} className={`kyc-leg ${s.status}`}>
<Icon name={s.status === "missing" ? "x" : s.status === "verified" ? "check-circle" : "clock"} size={13} /> {s.title}
</span>
))}
</div>
</div>
{!photoDone && <div className="callout tone-red"><Icon name="alert" size={18} /><div><strong>Live photo is mandatory.</strong><span>KYC cannot be completed without a verified photo.</span></div></div>}
<div className="kyc-slots">
{slots.map((slot) => (
<KycSlotRow key={slot.key} slot={slot} options={options(slot.key)} onPick={(d) => pickDoc(slot.key, d)} onUpload={() => upload(slot.key)} onRemove={() => remove(slot.key)} />
))}
</div>
</div>
<div className="card card-muted">
<div className="card-head"><h3>Rules</h3></div>
<ul className="rule-mini">
<li><Icon name="check" size={14} /> A live photo / selfie is <strong>mandatory</strong>.</li>
<li><Icon name="check" size={14} /> Provide <strong>one ID</strong> and <strong>one address</strong> proof.</li>
<li><Icon name="check" size={14} /> ID and address must be <strong>two different documents</strong>.</li>
<li><Icon name="check" size={14} /> Accepted: PDF, JPG, PNG · size limits per document.</li>
<li><Icon name="check" size={14} /> Review completes within ~4 business hours.</li>
</ul>
<div className="kyc-doc-pairs">
<div className="muted-note"><Icon name="info" size={13} /> Current selection</div>
<div className="pair-row"><span>Identity</span><Pill tone="blue">{kycDocTypes.find((d) => d.id === idDoc)?.label ?? "Not chosen"}</Pill></div>
<div className="pair-row"><span>Address</span><Pill tone="purple">{kycDocTypes.find((d) => d.id === addrDoc)?.label ?? "Not chosen"}</Pill></div>
</div>
</div>
</div>
);
}
function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
slot: KycSlot; options: typeof kycDocTypes; onPick: (d: string) => void; onUpload: () => void; onRemove: () => void;
}) {
const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted";
const docMeta = kycDocTypes.find((d) => d.id === slot.docId);
return (
<div className={`kyc-slot status-${slot.status}`}>
<div className="kyc-slot-ic"><Icon name={slot.key === "photo" ? "camera" : slot.key === "id" ? "user" : "pin"} size={18} /></div>
<div className="kyc-slot-main">
<div className="kyc-slot-top">
<span className="kyc-slot-title">{slot.title}{slot.required && <i className="req">*</i>}</span>
<Pill tone={tone}>{slot.status === "missing" ? "Missing" : slot.status === "uploaded" ? "Under review" : slot.status === "verified" ? "Verified" : "Rejected"}</Pill>
</div>
{slot.key !== "photo" && (
<select className="ds-select" value={slot.docId ?? ""} onChange={(e) => onPick(e.target.value)}>
<option value="" disabled>Select document type</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
)}
{docMeta && <div className="kyc-slot-hint">{docMeta.hint} · {docMeta.formats} · {docMeta.maxMb} MB</div>}
{slot.fileName ? (
<div className="kyc-file">
<Icon name="paperclip" size={14} />
<span className="kyc-file-name">{slot.fileName}</span>
{slot.note && <span className="kyc-file-note">{slot.note}</span>}
<button className="ds-iconbtn sm" aria-label="Remove" onClick={onRemove}><Icon name="trash" size={14} /></button>
</div>
) : (
<button className="kyc-drop" onClick={onUpload}>
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
</button>
)}
</div>
</div>
);
}
/* ============================================================ */
/* Tab: Security */
/* ============================================================ */
function SecurityTab() {
const { push } = useToast();
const [pw, setPw] = useState("");
const [twoFA, setTwoFA] = useState(false);
const [authApp, setAuthApp] = useState(false);
const [alerts, setAlerts] = useState(true);
const [twoFaModal, setTwoFaModal] = useState(false);
const strength = passwordStrength(pw);
return (
<div className="grid-2">
<div className="card">
<div className="card-head"><h3>Password</h3><Pill tone="muted">Updated 5 days ago</Pill></div>
<Field label="New password" hint="Min 8 chars · upper & lower · a number · a special character · no common patterns.">
<input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} />
</Field>
{pw && (
<div className="pw-meter">
<div className="pw-bars">{[0, 1, 2, 3].map((i) => <span key={i} className={i < strength.score ? `lvl lvl-${strength.label.toLowerCase()}` : "lvl"} />)}</div>
<div className="pw-label">{strength.label}</div>
</div>
)}
<ul className="pw-checks">
{strength.checks.map((c) => <li key={c.label} className={c.ok ? "ok" : ""}><Icon name={c.ok ? "check-circle" : "x"} size={14} /> {c.label}</li>)}
</ul>
<div className="card-actions"><Btn icon="key" disabled={strength.score < 3} onClick={() => { push({ tone: "success", title: "Password updated" }); setPw(""); }}>Update password</Btn></div>
</div>
<div className="card">
<div className="card-head"><h3>Two-factor &amp; sign-in</h3></div>
<SettingRow icon="shield-check" title="Two-factor authentication" desc="Requires your password and a one-time code to enable.">
<Toggle checked={twoFA} onChange={(v) => { if (v) setTwoFaModal(true); else { setTwoFA(false); setAuthApp(false); push({ tone: "info", title: "Two-factor disabled" }); } }} label="2FA" />
</SettingRow>
<SettingRow icon="key" title="Authenticator app" desc={authApp ? "Connected · codes from your app" : "Use an app like Google Authenticator"}>
<Toggle checked={authApp} disabled={!twoFA} onChange={(v) => { setAuthApp(v); push({ tone: v ? "success" : "info", title: v ? "Authenticator linked" : "Authenticator removed" }); }} label="Authenticator" />
</SettingRow>
<SettingRow icon="bell" title="Login alerts" desc="Notify me when a new device signs in.">
<Toggle checked={alerts} onChange={(v) => { setAlerts(v); push({ tone: "info", title: v ? "Login alerts on" : "Login alerts off" }); }} label="Login alerts" />
</SettingRow>
{!twoFA && <p className="muted-note"><Icon name="info" size={13} /> Enable two-factor authentication to link an authenticator app.</p>}
</div>
<TwoFaModal open={twoFaModal} onClose={() => setTwoFaModal(false)} onDone={() => { setTwoFA(true); setTwoFaModal(false); push({ tone: "success", title: "Two-factor enabled", desc: "Your account is now protected with 2FA." }); }} />
</div>
);
}
function TwoFaModal({ open, onClose, onDone }: { open: boolean; onClose: () => void; onDone: () => void }) {
const { push } = useToast();
const [step, setStep] = useState<"password" | "otp">("password");
const [pw, setPw] = useState("");
const [otp, setOtp] = useState("");
function close() { setStep("password"); setPw(""); setOtp(""); onClose(); }
return (
<Modal open={open} onClose={close} icon="shield-check" title="Enable two-factor authentication"
subtitle={step === "password" ? "Confirm your password to continue." : `Enter the code sent to ${user.mobile.masked}.`}
footer={step === "password"
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="arrow" iconRight="arrow" disabled={pw.length < 4} onClick={() => { setStep("otp"); push({ tone: "info", title: "Code sent", desc: "OTP sent to your registered mobile." }); }}>Continue</Btn></>
: <><Btn variant="ghost" onClick={() => setStep("password")}>Back</Btn><Btn icon="check" disabled={otp.length < 6} onClick={onDone}>Enable 2FA</Btn></>}
>
{step === "password" ? (
<Field label="Current password"><input className="ds-input" type="password" placeholder="••••••••" value={pw} onChange={(e) => setPw(e.target.value)} /></Field>
) : (
<div className="otp-wrap"><OtpField value={otp} onChange={setOtp} /><span className="muted-note">Both password and OTP are required to turn 2FA on.</span></div>
)}
</Modal>
);
}
function SettingRow({ icon, title, desc, children }: { icon: string; title: string; desc: string; children: React.ReactNode }) {
return (
<div className="setting-row">
<span className="setting-ic"><Icon name={icon} size={18} /></span>
<div className="setting-txt"><div className="setting-title">{title}</div><div className="setting-desc">{desc}</div></div>
<div className="setting-ctl">{children}</div>
</div>
);
}
/* ============================================================ */
/* Tab: Notifications */
/* ============================================================ */
function NotificationsTab() {
const { push } = useToast();
const [prefs, setPrefs] = useState<NotifCategory[]>(contactPrefs);
const [tz, setTz] = useState(contactTimezones[0]);
const [times, setTimes] = useState(contactTimes);
const channels: { key: keyof NotifCategory; label: string; icon: string }[] = [
{ key: "email", label: "Email", icon: "mail" },
{ key: "sms", label: "SMS", icon: "phone" },
{ key: "whatsapp", label: "WhatsApp", icon: "chat" },
{ key: "push", label: "Push", icon: "bell" },
];
function toggle(catId: string, ch: keyof NotifCategory) {
setPrefs((p) => p.map((c) => {
if (c.id !== catId) return c;
if (c.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return c; }
return { ...c, [ch]: !c[ch] } as NotifCategory;
}));
}
return (
<div className="grid-side">
<div className="card card-pad-0">
<div className="card-head pad"><h3>Per-channel matrix</h3><span className="muted-note">Choose how each category reaches you</span></div>
<div className="notif-table">
<div className="notif-row notif-head">
<span>Category</span>
{channels.map((c) => <span key={c.key} className="notif-ch"><Icon name={c.icon} size={14} /> {c.label}</span>)}
</div>
{prefs.map((cat) => (
<div className="notif-row" key={cat.id}>
<span className="notif-cat">
<span className="notif-cat-name">{cat.label}{cat.locked && <Icon name="lock" size={12} className="lock-ic" />}</span>
<span className="notif-cat-desc">{cat.desc}</span>
</span>
{channels.map((c) => (
<span key={c.key} className="notif-ch">
<Toggle checked={Boolean(cat[c.key])} disabled={cat.locked} onChange={() => toggle(cat.id, c.key)} label={`${cat.label} ${c.label}`} />
</span>
))}
</div>
))}
</div>
</div>
<div className="card-stack">
<div className="card">
<div className="card-head"><h3>Contact-time window</h3></div>
<Field label="Timezone">
<select className="ds-select" value={tz} onChange={(e) => setTz(e.target.value)}>
{contactTimezones.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</Field>
<div className="time-windows">
{times.map((t) => (
<button key={t.id} className={`time-win ${t.enabled ? "on" : ""}`} onClick={() => setTimes((s) => s.map((x) => x.id === t.id ? { ...x, enabled: !x.enabled } : x))}>
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
<span className="tw-label">{t.label}</span>
<span className="tw-range">{t.range}</span>
</button>
))}
</div>
<p className="muted-note"><Icon name="info" size={13} /> Callbacks &amp; proactive messages only arrive inside enabled windows, in your timezone.</p>
</div>
<div className="card">
<div className="card-head"><h3>Destinations</h3></div>
<div className="dest-list">
{contactDestinations.map((d) => (
<div className="dest-row" key={d.id}>
<span className="dest-ic"><Icon name={d.channel === "email" ? "mail" : d.channel === "sms" ? "phone" : "chat"} size={15} /></span>
<div className="dest-txt"><div className="dest-label">{d.label}{d.primary && <Pill tone="orange">Primary</Pill>}</div><div className="dest-val">{d.value}</div></div>
{d.verified ? <Pill tone="green"><Icon name="check" size={12} /> Verified</Pill> : <Btn variant="outline" size="sm" onClick={() => push({ tone: "info", title: "Verification sent" })}>Verify</Btn>}
</div>
))}
</div>
<Btn variant="ghost" icon="plus" onClick={() => push({ tone: "info", title: "Add destination" })}>Add destination</Btn>
</div>
</div>
</div>
);
}
/* ============================================================ */
/* Tab: Privacy & Consent */
/* ============================================================ */
function PrivacyTab() {
const { push } = useToast();
const [groups, setGroups] = useState<ConsentGroup[]>(consentGroups);
function toggle(gid: string, iid: string) {
setGroups((gs) => gs.map((g) => g.id !== gid ? g : {
...g,
items: g.items.map((it) => {
if (it.id !== iid) return it;
if (it.locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return it; }
return { ...it, granted: !it.granted };
}),
}));
}
return (
<div className="consent-cols">
{groups.map((g) => (
<div className="card" key={g.id}>
<div className="card-head">
<h3>{g.title}</h3>
{g.id === "marketing" && <Pill tone="muted">Optional</Pill>}
{g.id === "essential" && <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>}
</div>
<div className="consent-list">
{g.items.map((it) => (
<div className={`consent-item ${it.locked ? "locked" : ""}`} key={it.id}>
<div className="consent-txt">
<div className="consent-label">{it.label}{it.locked && <Icon name="lock" size={12} className="lock-ic" />}</div>
<div className="consent-desc">{it.desc}</div>
</div>
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id)} label={it.label} />
</div>
))}
</div>
{g.id === "marketing" && groups.find((x) => x.id === "marketing")!.items.find((i) => i.id === "promos")!.granted && (
<div className="consent-sub">
<div className="muted-note">Promotional sub-preferences</div>
{["Product offers", "Surveys & feedback", "Partner news"].map((s, i) => (
<label className="consent-sub-row" key={s}><span>{s}</span><Toggle checked={i === 0} onChange={() => {}} label={s} /></label>
))}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================ */
/* Tab: Devices */
/* ============================================================ */
function DevicesTab() {
const { push } = useToast();
const [devices, setDevices] = useState(loginActivity);
function signOut(id: string) {
setDevices((d) => d.filter((x) => x.id !== id));
push({ tone: "success", title: "Signed out", desc: "That device no longer has access." });
}
return (
<div className="grid-side">
<div className="card">
<div className="card-head">
<h3>Connected devices</h3>
<Btn variant="outline" size="sm" icon="logout" onClick={() => { setDevices((d) => d.filter((x) => x.current)); push({ tone: "success", title: "Other devices signed out" }); }}>Sign out all others</Btn>
</div>
<div className="device-list">
{devices.filter((d) => d.event !== "password-change").map((d) => (
<div className={`device-row ${d.current ? "current" : ""}`} key={d.id}>
<span className="device-ic"><Icon name={d.kind} size={20} /></span>
<div className="device-txt">
<div className="device-name">{d.device}{d.current && <Pill tone="green">This device</Pill>}{!d.trusted && <Pill tone="orange"><Icon name="alert" size={11} /> New</Pill>}</div>
<div className="device-meta">{d.browser} · {d.os}</div>
<div className="device-meta"><Icon name="pin" size={12} /> {d.location} · {d.ip} · {d.lastActive}</div>
</div>
{!d.current && <button className="ds-iconbtn" aria-label="Sign out" onClick={() => signOut(d.id)}><Icon name="logout" size={16} /></button>}
</div>
))}
</div>
</div>
<div className="card">
<div className="card-head"><h3>Recent activity</h3></div>
<div className="timeline">
{loginActivity.map((a) => (
<div className="tl-item" key={a.id}>
<span className={`tl-dot ev-${a.event}`}><Icon name={a.event === "password-change" ? "key" : a.event === "new-device" ? "alert" : a.event === "2fa-enabled" ? "shield-check" : "check"} size={12} /></span>
<div className="tl-body">
<div className="tl-title">{labelForEvent(a.event)}</div>
<div className="tl-meta">{a.device} · {a.location}</div>
<div className="tl-time">{a.lastActive}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
function labelForEvent(ev: string) {
switch (ev) {
case "password-change": return "Password changed";
case "new-device": return "New device signed in";
case "2fa-enabled": return "Two-factor enabled";
case "sign-out": return "Signed out";
default: return "Successful sign-in";
}
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
// ============================================================
// Rules Checklist — the 113 business rules in one place.
// A single filter bar + a uniform (brand-orange) card grid:
// each rule card has a ghost watermark number, an icon and an
// "enforced" footer. No per-category colours.
// (house 181 · OTP to registered mobile · KYC two-docs ·
// password policy · ticket lifecycle · …)
// ============================================================
import { useMemo, useState } from "react";
import { rules } from "./account-data";
import { Icon, PageHead, Pill, Segmented } from "./ui";
// Each rule category maps to a glyph so cards read at a glance.
const TAG_ICON: Record<string, string> = {
Profile: "user", Security: "shield", KYC: "verify", Support: "chat",
Notifications: "bell", Privacy: "privacy",
};
export function Rules() {
const tags = useMemo(() => ["All", ...Array.from(new Set(rules.map((r) => r.tag)))], []);
const [tag, setTag] = useState("All");
const list = tag === "All" ? rules : rules.filter((r) => r.tag === tag);
return (
<div className="view">
<PageHead
eyebrow="Reference"
title="Rules Checklist"
subtitle="The 13 business rules that govern Profile & Support — one source of truth."
icon="check-circle"
actions={<Pill tone="blue">{rules.length} rules</Pill>}
/>
<Segmented value={tag} onChange={setTag} options={tags.map((t) => ({ value: t, label: t }))} />
<div className="rules-grid view-body">
{list.map((r) => (
<article className="card rule-card" key={r.n}>
<span className="rule-watermark" aria-hidden>{String(r.n).padStart(2, "0")}</span>
<div className="rule-top">
<span className="rule-ic"><Icon name={TAG_ICON[r.tag] ?? "check-circle"} size={18} /></span>
<Pill tone="muted">{r.tag}</Pill>
</div>
<div className="rule-title">{r.title}</div>
<p className="rule-detail">{r.detail}</p>
<div className="rule-foot"><Icon name="shield-check" size={13} /> Enforced · Rule {r.n}</div>
</article>
))}
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
// ============================================================
// Org Settings → Integrations. Today: SMS (Twilio) — a tenant
// brings its OWN Twilio credentials, which be-crm seals in IIOS
// (per-scope) and resolves at send time. The auth token is
// write-only: sealed in IIOS, never read back, so status shows
// only masked hints (from-number + SID last-4). Email (SMTP) is
// the next provider on the same generic credential registry.
// ============================================================
import { useState } from "react";
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
import { useSmsSettings } from "@/lib/sms-settings-api";
import { useSmtpSettings } from "@/lib/smtp-settings-api";
const SID_RE = /^AC[0-9a-fA-F]{32}$/;
const E164_RE = /^\+[1-9]\d{6,14}$/;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
export function Settings() {
return (
<div className="view">
<PageHead
eyebrow="Configuration"
title="Org Settings"
subtitle="Integrations and workspace configuration"
icon="settings"
/>
<section className="settings-section">
<h3 className="settings-section-title">Integrations</h3>
<div className="settings-grid">
<TwilioCard />
<SmtpCard />
</div>
</section>
</div>
);
}
function TwilioCard() {
const toast = useToast();
const { status, loading, live, configure } = useSmsSettings();
const [editing, setEditing] = useState(false);
const [accountSid, setAccountSid] = useState("");
const [authToken, setAuthToken] = useState("");
const [fromNumber, setFromNumber] = useState("");
const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({});
const [saving, setSaving] = useState(false);
const showForm = editing || (!loading && !status.configured);
function validate(): boolean {
const e: typeof errors = {};
if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars).";
if (!authToken.trim()) e.authToken = "Auth token is required.";
if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567.";
setErrors(e);
return Object.keys(e).length === 0;
}
async function save() {
if (!validate()) return;
setSaving(true);
try {
await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() });
toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." });
setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false);
} catch (err) {
toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message });
} finally {
setSaving(false);
}
}
return (
<div className="settings-card">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="send" size={20} /></span>
<div className="settings-card-titles">
<div className="settings-card-name">
SMS <span className="settings-card-sub">· Twilio</span>
</div>
<div className="settings-card-desc">Send texts from your own Twilio number.</div>
</div>
{status.configured
? <Pill tone="green">Connected</Pill>
: <Pill tone="muted">Not connected</Pill>}
</div>
{status.configured && !editing ? (
<div className="settings-card-body">
<dl className="settings-kv">
<div><dt>From number</dt><dd>{status.fromNumber ?? "—"}</dd></div>
<div><dt>Account SID</dt><dd>{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}</dd></div>
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
</dl>
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
</div>
) : null}
{showForm ? (
<div className="settings-card-body">
<Field label="Account SID" required error={errors.accountSid} hint="Twilio Console → Account Info.">
<input className="ds-input" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" />
</Field>
<Field label="Auth Token" required error={errors.authToken} hint="Encrypted on save and never shown again.">
<input className="ds-input" type="password" value={authToken} onChange={(e) => setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" />
</Field>
<Field label="From number" required error={errors.fromNumber} hint="A Twilio number in E.164 format.">
<input className="ds-input" value={fromNumber} onChange={(e) => setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" />
</Field>
<div className="settings-card-actions">
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"}</Btn>
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
</div>
</div>
) : null}
{!live ? <div className="settings-card-note">Demo mode credentials are stored locally and not sent to Twilio.</div> : null}
</div>
);
}
function SmtpCard() {
const toast = useToast();
const { status, loading, live, configure } = useSmtpSettings();
const [editing, setEditing] = useState(false);
const [host, setHost] = useState("");
const [port, setPort] = useState("587");
const [secure, setSecure] = useState(false);
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [fromEmail, setFromEmail] = useState("");
const [fromName, setFromName] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const showForm = editing || (!loading && !status.configured);
function validate(): boolean {
const e: Record<string, string> = {};
if (!host.trim()) e.host = "SMTP host is required.";
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 165535.";
if (!user.trim()) e.user = "Username is required.";
if (!pass.trim()) e.pass = "Password is required.";
if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required.";
setErrors(e);
return Object.keys(e).length === 0;
}
async function save() {
if (!validate()) return;
setSaving(true);
try {
await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) });
toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." });
setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false);
} catch (err) {
toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message });
} finally {
setSaving(false);
}
}
return (
<div className="settings-card">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
<div className="settings-card-titles">
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
<div className="settings-card-desc">Send external email from your own mail server.</div>
</div>
{status.configured ? <Pill tone="green">Connected</Pill> : <Pill tone="muted">Not connected</Pill>}
</div>
{status.configured && !editing ? (
<div className="settings-card-body">
<dl className="settings-kv">
<div><dt>From</dt><dd>{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}</dd></div>
<div><dt>Server</dt><dd>{status.host ?? "—"}{status.port ? `:${status.port}` : ""}</dd></div>
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
</dl>
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
</div>
) : null}
{showForm ? (
<div className="settings-card-body">
<Field label="SMTP host" required error={errors.host} hint="e.g. smtp.sendgrid.net or your mail server.">
<input className="ds-input" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" />
</Field>
<Field label="Port" required error={errors.port} hint="587 (STARTTLS) or 465 (SSL).">
<input className="ds-input" value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" autoComplete="off" />
</Field>
<label className="settings-check">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> Use SSL/TLS (port 465)
</label>
<Field label="Username" required error={errors.user} hint="Often your email or an API key.">
<input className="ds-input" value={user} onChange={(e) => setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" />
</Field>
<Field label="Password" required error={errors.pass} hint="Encrypted on save and never shown again.">
<input className="ds-input" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" />
</Field>
<Field label="From address" required error={errors.fromEmail}>
<input className="ds-input" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" />
</Field>
<Field label="From name" hint="Optional display name on outgoing mail.">
<input className="ds-input" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" />
</Field>
<div className="settings-card-actions">
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"}</Btn>
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
</div>
</div>
) : null}
{!live ? <div className="settings-card-note">Demo mode credentials are stored locally and no email is sent.</div> : null}
</div>
);
}
+138 -46
View File
@@ -1,88 +1,180 @@
"use client";
import { ChevronsUpDown } from "lucide-react";
import { FigIcon } from "./figicon";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui";
import { user } from "./account-data";
import { useMyAccess } from "@/lib/access";
type Item = { key: string; label: string };
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
const GROUPS: { title: string; items: Item[] }[] = [
export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
export type NavGroup = { title: string; items: NavItem[] };
// Full workspace navigation. The Account group items (profile/support/rules)
// render real views; the rest are workspace modules shown via a placeholder.
export const NAV_GROUPS: NavGroup[] = [
{
title: "Workspace",
items: [
{ key: "dashboard", label: "Dashboard" },
{ key: "owners", label: "Owners Box" },
{ key: "projects", label: "Projects" },
{ key: "leads", label: "Leads" },
{ key: "verify", label: "Lead Verification" },
{ key: "pipeline", label: "Pipeline" },
{ key: "dashboard", label: "Dashboard", icon: "dashboard", subtitle: "Welcome back — your territory at a glance" },
{ key: "owners", label: "Owners Box", icon: "owners" },
{ key: "projects", label: "Projects", icon: "projects" },
{ key: "leads", label: "Leads", icon: "leads" },
{ key: "verify", label: "Lead Verification", icon: "verify" },
{ 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" },
{ key: "gallery", label: "Smart Gallery", icon: "gallery", subtitle: "Every photo and video for your jobs — searchable, editable and shareable" },
],
},
{
title: "Workspace",
items: [
{ key: "dispatch", label: "LynkDispatch" },
{ key: "storm", label: "Storm Intel" },
{ key: "territory", label: "Territory Map" },
{ key: "procanvas", label: "ProCanvas" },
{ key: "estimates", label: "Estimates" },
{ key: "dispatch", label: "LynkDispatch", icon: "dispatch" },
{ key: "storm", label: "Storm Intel", icon: "storm" },
{ key: "territory", label: "Territory Map", icon: "territory" },
{ key: "procanvas", label: "ProCanvas", icon: "procanvas" },
{ key: "estimates", label: "Estimates", icon: "estimates" },
],
},
{
title: "Team",
items: [
{ key: "schedule", label: "Team Schedule" },
{ key: "leaderboard", label: "Leaderboard" },
{ key: "subtasks", label: "Subcontractor Tasks" },
{ key: "people", label: "People" },
{ key: "team", label: "Team Management", icon: "team", subtitle: "Your crew, roles and permissions" },
{ key: "schedule", label: "Team Schedule", icon: "schedule" },
{ key: "leaderboard", label: "Leaderboard", icon: "leaderboard" },
{ key: "subtasks", label: "Subcontractor Tasks", icon: "subtasks" },
{ key: "people", label: "People", icon: "people" },
],
},
{
title: "Workspace AI",
items: [
{ key: "settings", label: "Org Settings", icon: "settings" },
{ key: "ai", label: "AI Assistant", icon: "ai", subtitle: "Lynk AI — your roofing copilot" },
],
},
{
title: "Profile & Documents",
items: [
{ key: "profile", label: "Profile", icon: "user", subtitle: "Your account, identity and preferences" },
{ key: "support", label: "Support Center", icon: "chat", subtitle: "Get help, track requests and find answers" },
{ key: "rules", label: "Rules Checklist", icon: "check-circle", subtitle: "The 13 rules behind Profile & Support" },
],
},
];
const EXTRA: Item[] = [
{ key: "settings", label: "Org Settings" },
{ key: "ai", label: "AI Assistant" },
{ key: "home", label: "Home" },
];
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.
//
// `gallery` DECISION: it stays in ALWAYS_VISIBLE (nav row always shown to members) rather than
// being gated here by `media.view`. The real access gate lives INSIDE the view (SmartGallery reads
// `useGalleryFeatures().canView`), which uses the permissive "member with zero media.* perms → all
// enabled" fallback. Gating the nav row here would use the stricter sidebar rule (member without the
// perm → hidden) and so would hide the gallery from freshly-seeded members before an admin has
// configured any Media perms — regressing the demo and the common member. So: always-visible row,
// real gating in-view. (be-crm enforces data access regardless of what the nav shows.)
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox", "gallery"]);
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) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
router.replace("/portal/login");
}
return (
<aside className="dash-sidebar">
<div className="dash-brand">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt="LynkedUp Pro" />
<span className="bk">LynkedUp Pro</span>
</div>
<nav className="dash-nav">
{GROUPS.map((g, gi) => (
<div key={gi}>
{visibleGroups.map((g, gi) => (
<div className="nav-section" key={gi}>
<div className="nav-group">{g.title}</div>
{g.items.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
{g.items.map((it) => (
<button key={it.key} className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
<Icon name={it.icon} size={18} />
<span className="nav-item-label">{it.label}</span>
</button>
))}
</div>
))}
<div style={{ height: 8 }} />
{EXTRA.map((it) => <NavBtn key={it.key} it={it} active={active} onSelect={onSelect} />)}
</nav>
<div className="sb-foot">
<div className="sb-user">
<span className="av" style={{ background: "linear-gradient(135deg,#285ef0,#09b9c6)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
<div style={{ flex: 1 }}>
<div className="nm">Justin Johnson</div>
<div className="rl">Owner</div>
<div className="sb-foot" style={{ position: "relative" }}>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="sb-user-menu" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
</div>
<ChevronsUpDown size={15} />
</div>
</button>
</div>
</aside>
);
}
function NavBtn({ it, active, onSelect }: { it: Item; active: string; onSelect: (k: string) => void }) {
return (
<button className={`nav-item ${active === it.key ? "active" : ""}`} onClick={() => onSelect(it.key)}>
<FigIcon name={it.key} size={18} />
{it.label}
</button>
);
}
@@ -0,0 +1,69 @@
"use client";
// ============================================================
// The actual @photo-gallery/sdk mount. Split out of smart-gallery.tsx so it can be
// loaded with next/dynamic({ ssr: false }) — the SDK is browser-only (matchMedia,
// IndexedDB, Leaflet, canvas) and pulls in heavy optional ML models on demand, so it
// must stay out of the dashboard's initial bundle and out of the server render.
// ============================================================
import { useMemo } from "react";
import { PhotoGallery, type ViewId } from "@photo-gallery/sdk";
import { createCrmAIProvider } from "@/lib/gallery-ai";
import {
GALLERY_THEME_TOKENS,
useGalleryFeatures,
useGalleryLockProvider,
useGalleryStorage,
useGalleryUser,
} from "@/lib/gallery-api";
import "@photo-gallery/sdk/styles.css";
import "leaflet/dist/leaflet.css";
export interface SmartGalleryMountProps {
/** The dashboard's current appearance — the gallery must never diverge from the host. */
theme: "dark" | "light";
}
export default function SmartGalleryMount({ theme }: SmartGalleryMountProps) {
const { adapter } = useGalleryStorage();
const currentUser = useGalleryUser();
// Server-backed Recently Deleted lock when the Shell is wired; `undefined` in demo mode, which
// leaves the SDK on its own device-local lock (see useGalleryLockProvider).
const lockProvider = useGalleryLockProvider();
// Feature toggles resolved from the caller's CRM permissions (Media group). Superadmins/owners and
// the demo see everything; see useGalleryFeatures for the safe permissive fallback.
const { features } = useGalleryFeatures();
// Sidebar rows + Collections sections the CRM never wants to surface. The SDK hides both the row and
// the matching Collections section for each id. Screenshots + Documents aren't part of a roofing CRM.
const hiddenViews: ViewId[] = ["screenshots", "sys:documents"];
// One provider per mount. Every model is dynamically imported inside it, so constructing
// it is cheap; the weight only arrives when a photo is actually analyzed.
const ai = useMemo(() => createCrmAIProvider(), []);
return (
<PhotoGallery
embedded
adapter={adapter}
ai={ai}
// The CRM owns light/dark, so the in-gallery Appearance switcher is suppressed and the
// theme is driven straight off the dashboard's own toggle.
theme={theme}
chrome={{ titlebar: false, themeSwitcher: false }}
themeTokens={GALLERY_THEME_TOKENS}
borderRadius={12}
currentUser={currentUser}
lockProvider={lockProvider}
title="Smart Gallery"
// The SDK's floating Info panel defaults to 64px from the top — the height of its
// OWN toolbar. Inside the dashboard it has to clear the 84px CRM topbar instead.
// `style` is applied after the token vars, so this wins over the SDK's inline default.
style={{ ["--apg-overlay-top" as string]: "96px" }}
hiddenViews={hiddenViews}
features={features}
/>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
// ============================================================
// Smart Gallery — the tenant's media library, powered by @photo-gallery/sdk embedded
// inside the dashboard shell. The SDK owns the gallery experience (grid, lightbox,
// photo + video editors, map, people, versions, comments, AI); this file owns the
// LynkedUp chrome around it: page head, demo-mode banner, sizing, and failure
// containment so a gallery fault can never take the dashboard down.
//
// Storage + identity come from `@/lib/gallery-api` (be-crm data door when the Shell is
// configured, device-local otherwise). Theming comes from GALLERY_THEME_TOKENS, which
// maps the SDK's tokens onto this dashboard's own CSS variables.
// ============================================================
import { Component, type ErrorInfo, type ReactNode } from "react";
import dynamic from "next/dynamic";
import { Icon } from "./ui";
import { useGalleryFeatures, useGalleryStorage } from "@/lib/gallery-api";
const SmartGalleryMount = dynamic(() => import("./smart-gallery-mount"), {
ssr: false,
loading: () => <GalleryPlaceholder label="Loading your library…" />,
});
export function SmartGallery({ theme }: { theme: "dark" | "light" }) {
const { live } = useGalleryStorage();
// Whole-view gate. `media.view` with the same permissive fallback the feature toggles use, so a
// superadmin/owner and the demo always pass, and a member is only blocked if they hold some Media
// perms but not `media.view`. The sidebar keeps the row visible (see sidebar.tsx §4) — the real
// gate is here.
const { canView } = useGalleryFeatures();
return (
<div className="view gal">
{/* Slim header the big PageHead ate vertical space the gallery needs. The CRM topbar already
shows the "Smart Gallery" title; this compact row (~40px) just adds context and the icon. */}
<div className="gal-head">
<span className="gal-head-ic">
<Icon name="gallery" size={16} />
</span>
<h1 className="gal-head-title">Smart Gallery</h1>
<span className="gal-head-sub">Every photo and video for your jobs searchable, editable and shareable.</span>
</div>
{!live && (
<div className="gal-banner">
<Icon name="info" size={14} />
<span>Demo mode stored on this device only. It goes live once the Shell + be-crm are connected.</span>
</div>
)}
{canView ? (
<div className="gal-shell">
<GalleryBoundary>
<SmartGalleryMount theme={theme} />
</GalleryBoundary>
</div>
) : (
<div className="gal-shell">
<div className="gal-placeholder">
<span className="gal-placeholder-ic">
<Icon name="lock" size={28} />
</span>
<h3>You don&apos;t have access to the gallery</h3>
<p>Ask a workspace admin to grant you the &ldquo;View Smart Gallery&rdquo; permission.</p>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
function GalleryPlaceholder({ label }: { label: string }) {
return (
<div className="gal-placeholder">
<span className="gal-placeholder-ic">
<Icon name="gallery" size={30} />
</span>
<p>{label}</p>
</div>
);
}
interface BoundaryState {
error: Error | null;
}
/**
* The gallery is a large third-party surface with canvas, workers and optional ML models. A render
* fault inside it must degrade to a message rather than blanking the whole dashboard, so it gets its
* own error boundary. (Error boundaries still require a class component in React 19.)
*/
class GalleryBoundary extends Component<{ children: ReactNode }, BoundaryState> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("[smart-gallery] render failed", error, info.componentStack);
}
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="gal-placeholder gal-placeholder-error">
<span className="gal-placeholder-ic">
<Icon name="alert" size={30} />
</span>
<h3>The gallery could not be displayed</h3>
<p>{error.message || "An unexpected error occurred."}</p>
<button className="ds-btn v-outline s-sm" onClick={() => this.setState({ error: null })}>
<Icon name="refresh" size={14} /> Try again
</button>
</div>
);
}
}
+609
View File
@@ -0,0 +1,609 @@
"use client";
// ============================================================
// Support Center
// Overview (5 channel cards + team) · Message Center ·
// New Ticket · My Tickets (+ timeline modal) · Help Center
// plus Live Chat handshake & Callback/Email modals.
// ============================================================
import { useEffect, useMemo, useRef, useState } from "react";
import {
supportChannels, supportTeam, ticketCategories, tickets as ticketSeed,
supportThreads as threadSeed, helpTopics, contactTimes, countryCodes,
type Ticket, type TicketStatus, type SupportThread, type ChatMsg,
} from "./account-data";
import {
Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, StatusDot, useToast,
} from "./ui";
const SECTIONS = [
{ value: "overview", label: "Overview", icon: "chat" },
{ value: "messages", label: "Messages", icon: "mail" },
{ value: "new", label: "New Ticket", icon: "ticket" },
{ value: "tickets", label: "My Tickets", icon: "book" },
{ value: "help", label: "Help Center", icon: "info" },
];
const STATUS_META: Record<TicketStatus | "created", { label: string; tone: string }> = {
created: { label: "Created", tone: "muted" },
open: { label: "Open", tone: "blue" },
"in-progress": { label: "In Progress", tone: "orange" },
"awaiting-customer": { label: "Awaiting You", tone: "purple" },
resolved: { label: "Resolved", tone: "green" },
closed: { label: "Closed", tone: "muted" },
};
export function Support() {
const [section, setSection] = useState("overview");
const [chatOpen, setChatOpen] = useState(false);
const [callbackOpen, setCallbackOpen] = useState(false);
const [emailOpen, setEmailOpen] = useState(false);
function onChannel(id: string) {
if (id === "chat") setChatOpen(true);
else if (id === "ticket") setSection("new");
else if (id === "callback") setCallbackOpen(true);
else if (id === "email") setEmailOpen(true);
else if (id === "help") setSection("help");
}
return (
<div className="view">
<PageHead
eyebrow="Help & Support"
title="Support Center"
subtitle="Reach a human, track a request, or find an answer fast."
icon="chat"
actions={<Btn icon="chat" onClick={() => setChatOpen(true)}>Start live chat</Btn>}
/>
<Segmented options={SECTIONS} value={section} onChange={setSection} />
<div className="view-body">
{section === "overview" && <Overview onChannel={onChannel} onSection={setSection} />}
{section === "messages" && <MessageCenter />}
{section === "new" && <NewTicket onDone={() => setSection("tickets")} />}
{section === "tickets" && <MyTickets />}
{section === "help" && <HelpCenter />}
</div>
<LiveChatModal open={chatOpen} onClose={() => setChatOpen(false)} />
<CallbackModal open={callbackOpen} onClose={() => setCallbackOpen(false)} />
<EmailModal open={emailOpen} onClose={() => setEmailOpen(false)} />
</div>
);
}
/* ============================================================ */
/* Overview — channel cards + support team */
/* ============================================================ */
const MINI_AREA: Record<string, string> = { ticket: "ticket", callback: "call", email: "email", help: "help" };
function Overview({ onChannel, onSection }: { onChannel: (id: string) => void; onSection: (s: string) => void }) {
const online = supportTeam.filter((a) => a.status === "online");
const mini = supportChannels.filter((c) => ["ticket", "callback", "email", "help"].includes(c.id));
const recent = ticketSeed.slice(0, 4);
// Derived "live" metrics so the command-center ribbon stays in sync with data.
const openCount = ticketSeed.filter((t) => !["resolved", "closed"].includes(t.status)).length;
const avgRating = (supportTeam.reduce((s, a) => s + a.rating, 0) / supportTeam.length).toFixed(1);
const pulse = [
{ icon: "people", value: String(online.length), label: "Agents online", live: true },
{ icon: "clock", value: "< 2m", label: "Avg wait time" },
{ icon: "ticket", value: String(openCount), label: "Open tickets" },
{ icon: "star", value: avgRating, label: "Avg CSAT rating" },
];
return (
<div className="support-ov">
{/* Live command-center ribbon */}
<div className="sup-status">
<span className="sup-status-l"><span className="sup-status-dot" /> All systems operational</span>
<span className="sup-status-r">Support · Live status</span>
</div>
<div className="sup-pulse">
{pulse.map((p) => (
<div className="sup-stat" key={p.label}>
<span className="sup-stat-ic"><Icon name={p.icon} size={17} /></span>
<div className="sup-stat-txt">
<b>{p.value}{p.icon === "star" && <Icon name="star" size={12} />}</b>
<span>{p.label}</span>
</div>
{p.live && <span className="sup-stat-live" />}
</div>
))}
</div>
<div className="support-bento">
{/* Hero — live chat */}
<button className="bento-tile bento-chat" onClick={() => onChannel("chat")}>
<span className="bento-chat-sig" aria-hidden><span /><span /><span /></span>
<div className="bento-chat-top">
<span className="bento-chat-badge"><StatusDot status="online" /> Agents online now</span>
<span className="bento-chat-wait"><Icon name="clock" size={13} /> Avg wait &lt; 2 min</span>
</div>
<div className="bento-chat-mid">
<h2>Need a hand?<br />Chat with us live.</h2>
<p>Instant help from a real support specialist no queues, no bots, no waiting on hold.</p>
</div>
<div className="bento-chat-foot">
<div className="bento-avstack">
{online.map((a) => <Avatar key={a.id} initials={a.initials} gradient={a.gradient} size={36} />)}
<span className="bento-av-more">+{online.length} online</span>
</div>
<span className="bento-chat-cta">Start chat <Icon name="arrow" size={16} /></span>
</div>
</button>
{/* Support team */}
<div className="bento-tile bento-team">
<div className="bento-tile-head"><h3>Your support team</h3><Pill tone="green">{online.length} online</Pill></div>
<div className="team-list">
{supportTeam.map((a) => (
<div className="team-row" key={a.id}>
<Avatar initials={a.initials} gradient={a.gradient} size={40} status={a.status} />
<div className="team-txt">
<div className="team-name">{a.name}</div>
<div className="team-role">{a.role}</div>
<div className="team-team"><Icon name="shield" size={11} /> {a.team}</div>
</div>
<div className="team-rating"><Icon name="star" size={13} /> {a.rating}</div>
</div>
))}
</div>
</div>
{/* Mini channel tiles */}
{mini.map((c) => (
<button key={c.id} className="bento-tile bento-mini" style={{ ["--accent" as string]: c.accent, gridArea: MINI_AREA[c.id] }} onClick={() => onChannel(c.id)}>
<span className="bento-mini-ic"><Icon name={c.icon} size={20} /></span>
<div className="bento-mini-title">{c.title}</div>
<div className="bento-mini-desc">{c.desc}</div>
<div className="bento-mini-meta"><span>{c.meta}</span> <Icon name="arrow" size={14} /></div>
</button>
))}
{/* Recent tickets */}
<div className="bento-tile bento-recent">
<div className="bento-tile-head"><h3>Recent tickets</h3><button className="link-inline" onClick={() => onSection("tickets")}>View all</button></div>
<div className="bento-ticket-row">
{recent.map((t) => (
<button key={t.id} className="bento-ticket" onClick={() => onSection("tickets")}>
<div className="bento-ticket-top"><span className="t-id">{t.id}</span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></div>
<div className="bento-ticket-subj">{t.subject}</div>
<div className="bento-ticket-meta"><Icon name="clock" size={12} /> {t.updatedAt} · {t.department}</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
/* ============================================================ */
/* Message Center — inbox + live thread + typing */
/* ============================================================ */
function MessageCenter() {
const [threads, setThreads] = useState<SupportThread[]>(threadSeed);
const [activeId, setActiveId] = useState(threadSeed[0].id);
const [draft, setDraft] = useState("");
const endRef = useRef<HTMLDivElement | null>(null);
const active = threads.find((t) => t.id === activeId)!;
function send() {
const text = draft.trim();
if (!text) return;
const msg: ChatMsg = { id: `m${Date.now()}`, from: "me", text, at: "Now" };
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, msg], typing: true, preview: text } : t));
setDraft("");
// simulate agent typing → reply
setTimeout(() => {
const reply: ChatMsg = { id: `m${Date.now() + 1}`, from: "agent", text: "Thanks — noted. I'll update the ticket and get back to you shortly.", at: "Now" };
setThreads((ts) => ts.map((t) => t.id === activeId ? { ...t, messages: [...t.messages, reply], typing: false } : t));
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, 1800);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
}
function openThread(id: string) {
setActiveId(id);
setThreads((ts) => ts.map((t) => t.id === id ? { ...t, unread: 0 } : t));
}
const agent = supportTeam.find((a) => a.id === active.agentId)!;
return (
<div className="card card-pad-0 msgctr">
<aside className="msg-inbox">
<div className="msg-inbox-head">Inbox <Pill tone="orange">{threads.reduce((n, t) => n + t.unread, 0)}</Pill></div>
<div className="msg-inbox-list">
{[...threads].sort((a, b) => Number(b.pinned) - Number(a.pinned)).map((t) => {
const ta = supportTeam.find((x) => x.id === t.agentId)!;
return (
<button key={t.id} className={`msg-thread ${t.id === activeId ? "active" : ""}`} onClick={() => openThread(t.id)}>
<Avatar initials={ta.initials} gradient={ta.gradient} size={38} status={ta.status} />
<div className="msg-thread-txt">
<div className="msg-thread-top"><span className="msg-thread-name">{ta.name}</span><span className="msg-thread-time">{t.updatedAt}</span></div>
<div className="msg-thread-sub">{t.subject}</div>
<div className="msg-thread-prev">{t.typing ? <em className="typing-now">typing</em> : t.preview}</div>
</div>
{t.unread > 0 && <span className="msg-unread">{t.unread}</span>}
{t.pinned && <Icon name="check" size={12} className="msg-pin" />}
</button>
);
})}
</div>
</aside>
<section className="msg-thread-pane">
<header className="msg-pane-head">
<Avatar initials={agent.initials} gradient={agent.gradient} size={40} status={agent.status} />
<div className="msg-pane-id">
<div className="msg-pane-name">{agent.name}</div>
<div className="msg-pane-sub">{active.typing ? <span className="typing-now">typing</span> : `${agent.role} · ${agent.team}`}</div>
</div>
<Pill tone="blue">{active.subject}</Pill>
</header>
<div className="msg-stream">
{active.messages.map((m) => (
<div key={m.id} className={`bubble-row ${m.from}`}>
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={28} />}
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
</div>
))}
{active.typing && (
<div className="bubble-row agent">
<Avatar initials={agent.initials} gradient={agent.gradient} size={28} />
<div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div>
</div>
)}
<div ref={endRef} />
</div>
<div className="msg-compose">
<button className="ds-iconbtn" aria-label="Attach"><Icon name="paperclip" size={18} /></button>
<input className="ds-input flush" placeholder="Write a reply…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
</div>
</section>
</div>
);
}
/* ============================================================ */
/* New Ticket — category → department routing + attachment rules */
/* ============================================================ */
function NewTicket({ onDone }: { onDone: () => void }) {
const { push } = useToast();
const [catId, setCatId] = useState("");
const [subject, setSubject] = useState("");
const [desc, setDesc] = useState("");
const [priority, setPriority] = useState("medium");
const [file, setFile] = useState<string | null>(null);
const cat = ticketCategories.find((c) => c.id === catId) ?? null;
const needAttachment = cat?.requiresAttachment && !file;
const canSubmit = catId && subject.trim() && desc.trim() && !needAttachment;
function submit() {
push({ tone: "success", title: "Ticket created", desc: `Routed to ${cat?.department}. We'll reply within ${cat?.sla}.` });
onDone();
}
return (
<div className="grid-side">
<div className="card">
<div className="card-head"><h3>Raise a new ticket</h3></div>
<Field label="What is this about?" required>
<select className="ds-select" value={catId} onChange={(e) => { setCatId(e.target.value); setFile(null); }}>
<option value="" disabled>Select a category</option>
{ticketCategories.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</Field>
{cat && (
<div className="route-banner">
<Icon name="arrow" size={15} />
<span>Routes to <strong>{cat.department}</strong></span>
<Pill tone="blue"><Icon name="clock" size={12} /> SLA {cat.sla}</Pill>
</div>
)}
<Field label="Subject" required><input className="ds-input" placeholder="Short summary" value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
<Field label="Describe the issue" required><textarea className="ds-input ds-textarea" rows={4} placeholder="Add as much detail as you can…" value={desc} onChange={(e) => setDesc(e.target.value)} /></Field>
<Field label="Priority">
<Segmented value={priority} onChange={setPriority} options={[
{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "urgent", label: "Urgent" },
]} />
</Field>
{cat && (
<Field label={`Attachment${cat.requiresAttachment ? "" : " (optional)"}`} required={cat.requiresAttachment} error={needAttachment ? "This category requires an attachment." : undefined} hint={cat.attachmentNote}>
{file ? (
<div className="kyc-file"><Icon name="paperclip" size={14} /><span className="kyc-file-name">{file}</span><button className="ds-iconbtn sm" aria-label="Remove" onClick={() => setFile(null)}><Icon name="trash" size={14} /></button></div>
) : (
<button className="kyc-drop" onClick={() => setFile("attachment.pdf")}><Icon name="upload" size={16} /> Attach a file</button>
)}
</Field>
)}
<div className="card-actions"><Btn icon="send" disabled={!canSubmit} onClick={submit}>Submit ticket</Btn></div>
</div>
<div className="card card-muted">
<div className="card-head"><h3>How routing works</h3></div>
<div className="route-list">
{ticketCategories.map((c) => (
<div className="route-item" key={c.id}>
<div className="route-cat">{c.label}</div>
<Icon name="arrow" size={13} />
<div className="route-dept">{c.department}</div>
{c.requiresAttachment && <Pill tone="orange"><Icon name="paperclip" size={11} /> Attach</Pill>}
</div>
))}
</div>
</div>
</div>
);
}
/* ============================================================ */
/* My Tickets — table + status timeline modal */
/* ============================================================ */
function MyTickets() {
const [filter, setFilter] = useState("all");
const [open, setOpen] = useState<Ticket | null>(null);
const list = useMemo(() => filter === "all" ? ticketSeed : ticketSeed.filter((t) => filter === "active" ? !["resolved", "closed"].includes(t.status) : ["resolved", "closed"].includes(t.status)), [filter]);
return (
<div className="card card-pad-0">
<div className="card-head pad">
<h3>My tickets</h3>
<Segmented value={filter} onChange={setFilter} options={[{ value: "all", label: "All" }, { value: "active", label: "Active" }, { value: "closed", label: "Closed" }]} />
</div>
<div className="ticket-table">
<div className="ticket-row ticket-head">
<span>Ticket</span><span>Department</span><span>Priority</span><span>Status</span><span>Updated</span>
</div>
{list.map((t) => (
<button key={t.id} className="ticket-row" onClick={() => setOpen(t)}>
<span className="t-subject"><span className="t-id">{t.id}</span>{t.subject}</span>
<span className="t-dept">{t.department}</span>
<span><Pill tone={t.priority === "urgent" ? "red" : t.priority === "high" ? "orange" : t.priority === "medium" ? "blue" : "muted"}>{t.priority}</Pill></span>
<span><Pill tone={STATUS_META[t.status].tone}>{STATUS_META[t.status].label}</Pill></span>
<span className="t-updated">{t.updatedAt} <Icon name="chevron-right" size={14} /></span>
</button>
))}
</div>
<Modal open={open != null} onClose={() => setOpen(null)} icon="ticket" size="md" title={open?.subject ?? ""} subtitle={open ? `${open.id} · ${open.department}` : ""}
footer={<><Btn variant="ghost" onClick={() => setOpen(null)}>Close</Btn><Btn icon="chat">Reply in chat</Btn></>}>
{open && (
<>
<div className="ticket-modal-meta">
<Pill tone={STATUS_META[open.status].tone}>{STATUS_META[open.status].label}</Pill>
<Pill tone="muted">Priority: {open.priority}</Pill>
<Pill tone="muted">Opened {open.createdAt}</Pill>
</div>
<div className="lifecycle">
{(["open", "in-progress", "awaiting-customer", "resolved", "closed"] as TicketStatus[]).map((s, i) => {
const reached = open.timeline.some((e) => e.status === s) || (s === "open");
return <span key={s} className={`lc-step ${reached ? "done" : ""} ${open.status === s ? "current" : ""}`}>{i > 0 && <span className="lc-line" />}<span className="lc-dot" />{STATUS_META[s].label}</span>;
})}
</div>
<div className="timeline">
{open.timeline.slice().reverse().map((e, i) => (
<div className="tl-item" key={i}>
<span className={`tl-dot ev-${e.status}`}><Icon name={e.status === "resolved" || e.status === "closed" ? "check" : e.status === "awaiting-customer" ? "alert" : "chat"} size={12} /></span>
<div className="tl-body"><div className="tl-title">{e.label}</div><div className="tl-meta">{e.by}</div><div className="tl-time">{e.at}</div></div>
</div>
))}
</div>
</>
)}
</Modal>
</div>
);
}
/* ============================================================ */
/* Help Center — search + browse accordion */
/* ============================================================ */
function HelpCenter() {
const [q, setQ] = useState("");
const [openId, setOpenId] = useState<string | null>(helpTopics[0].id);
const query = q.trim().toLowerCase();
const results = useMemo(() => {
if (!query) return null;
const hits: { topic: string; q: string; a: string }[] = [];
for (const t of helpTopics) for (const a of t.articles) if ((a.q + a.a).toLowerCase().includes(query)) hits.push({ topic: t.title, ...a });
return hits;
}, [query]);
return (
<div className="view">
<div className="help-search">
<Icon name="search" size={18} />
<input className="ds-input flush" placeholder="Search guides, FAQs and how-tos…" value={q} onChange={(e) => setQ(e.target.value)} />
{q && <button className="ds-iconbtn sm" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
</div>
{results ? (
<div className="card">
<div className="card-head"><h3>{results.length} result{results.length === 1 ? "" : "s"} for {q}</h3></div>
{results.length === 0 ? <p className="muted-note"><Icon name="info" size={14} /> Nothing matched. Try different keywords or raise a ticket.</p> : (
<div className="help-results">
{results.map((r, i) => (
<details key={i} className="help-acc">
<summary><span className="help-q">{r.q}</span><Pill tone="muted">{r.topic}</Pill><Icon name="chevron" size={16} className="acc-chev" /></summary>
<p>{r.a}</p>
</details>
))}
</div>
)}
</div>
) : (
<div className="help-topics">
{helpTopics.map((t) => (
<div className="card help-topic-card" key={t.id} style={{ ["--accent" as string]: t.accent }}>
<button className="help-topic-head" onClick={() => setOpenId(openId === t.id ? null : t.id)}>
<span className="help-topic-ic"><Icon name={t.icon} size={20} /></span>
<div className="help-topic-txt"><div className="help-topic-title">{t.title}</div><div className="help-topic-count">{t.count} articles</div></div>
<Icon name="chevron" size={18} className={`acc-chev ${openId === t.id ? "open" : ""}`} />
</button>
{openId === t.id && (
<div className="help-acc-list">
{t.articles.map((a, i) => (
<details key={i} className="help-acc">
<summary><span className="help-q">{a.q}</span><Icon name="chevron" size={15} className="acc-chev" /></summary>
<p>{a.a}</p>
</details>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
/* ============================================================ */
/* Live Chat — connecting → joined handshake */
/* ============================================================ */
function LiveChatModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [phase, setPhase] = useState<"connecting" | "joined">("connecting");
const [msgs, setMsgs] = useState<ChatMsg[]>([]);
const [draft, setDraft] = useState("");
const [agentTyping, setAgentTyping] = useState(false);
const agent = supportTeam[0];
const endRef = useRef<HTMLDivElement | null>(null);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
// run the connecting → joined handshake each time the modal opens.
// Resetting + timed phase changes is an animation driven by `open`, so the
// synchronous resets here are intentional.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!open) return;
setPhase("connecting");
setMsgs([]);
setDraft("");
setAgentTyping(false);
const t1 = setTimeout(() => {
setPhase("joined");
setAgentTyping(true);
const t2 = setTimeout(() => {
setAgentTyping(false);
setMsgs([{ id: "g1", from: "agent", text: `Hi! I'm ${agent.name} from ${agent.team}. How can I help you today?`, at: "Now" }]);
}, 1400);
timers.current.push(t2);
}, 2000);
timers.current.push(t1);
const timersRef = timers.current;
return () => { timersRef.forEach(clearTimeout); timers.current = []; };
}, [open, agent.name, agent.team]);
/* eslint-enable react-hooks/set-state-in-effect */
function send() {
const text = draft.trim();
if (!text) return;
setMsgs((m) => [...m, { id: `u${Date.now()}`, from: "me", text, at: "Now" }]);
setDraft("");
setAgentTyping(true);
setTimeout(() => {
setAgentTyping(false);
setMsgs((m) => [...m, { id: `a${Date.now()}`, from: "agent", text: "Got it — let me pull up your account and check that for you.", at: "Now" }]);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
}, 1600);
setTimeout(() => endRef.current?.scrollIntoView({ behavior: "smooth" }), 30);
}
return (
<Modal open={open} onClose={onClose} size="md" icon="chat" title="Live Chat"
subtitle={phase === "connecting" ? "Connecting you to the next available agent…" : `Connected · ${agent.name}`}>
{phase === "connecting" ? (
<div className="chat-connecting">
<div className="chat-radar"><Avatar initials={agent.initials} gradient={agent.gradient} size={64} /><span className="radar-ring" /><span className="radar-ring d2" /></div>
<div className="chat-connecting-txt">Finding an agent<span className="dots"><span>.</span><span>.</span><span>.</span></span></div>
<div className="muted-note">Average wait under 2 minutes</div>
</div>
) : (
<div className="livechat">
<div className="livechat-joined"><StatusDot status="online" /> {agent.name} joined the chat</div>
<div className="msg-stream sm">
{msgs.map((m) => (
<div key={m.id} className={`bubble-row ${m.from}`}>
{m.from === "agent" && <Avatar initials={agent.initials} gradient={agent.gradient} size={26} />}
<div className="bubble"><p>{m.text}</p><span className="bubble-time">{m.at}</span></div>
</div>
))}
{agentTyping && <div className="bubble-row agent"><Avatar initials={agent.initials} gradient={agent.gradient} size={26} /><div className="bubble typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div>}
<div ref={endRef} />
</div>
<div className="msg-compose">
<input className="ds-input flush" placeholder="Type a message…" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} />
<Btn icon="send" onClick={send} disabled={!draft.trim()}>Send</Btn>
</div>
</div>
)}
</Modal>
);
}
/* ============================================================ */
/* Callback & Email modals */
/* ============================================================ */
function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { push } = useToast();
const [cc, setCc] = useState("+91");
const [num, setNum] = useState("98765 43012");
const [slot, setSlot] = useState(contactTimes.find((t) => t.enabled)?.id ?? contactTimes[0].id);
return (
<Modal open={open} onClose={onClose} icon="phone" title="Request a callback" subtitle="We'll call within your chosen contact-time window."
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="phone" onClick={() => { push({ tone: "success", title: "Callback scheduled", desc: "An agent will call you in your selected window." }); onClose(); }}>Schedule callback</Btn></>}>
<Field label="Phone number">
<div className="phone-input">
<select className="ds-select cc" value={cc} onChange={(e) => setCc(e.target.value)}>{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}</select>
<input className="ds-input flush" value={num} onChange={(e) => setNum(e.target.value)} />
</div>
</Field>
<Field label="Preferred window" hint="Only windows enabled in your notification preferences are offered.">
<div className="cb-slots">
{contactTimes.filter((t) => t.enabled).map((t) => (
<button key={t.id} className={`cb-slot ${slot === t.id ? "active" : ""}`} onClick={() => setSlot(t.id)}>
<span className="cb-slot-l">{t.label}</span><span className="cb-slot-r">{t.range}</span>
</button>
))}
</div>
</Field>
</Modal>
);
}
function EmailModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { push } = useToast();
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
return (
<Modal open={open} onClose={onClose} icon="mail" title="Email support" subtitle="Sends to care@lynkeduppro.com · reply within 1 business day."
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="send" disabled={!subject.trim() || !body.trim()} onClick={() => { push({ tone: "success", title: "Email sent", desc: "We'll reply to your registered email." }); onClose(); }}>Send email</Btn></>}>
<Field label="Subject"><input className="ds-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="How can we help?" /></Field>
<Field label="Message"><textarea className="ds-input ds-textarea" rows={5} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" /></Field>
</Modal>
);
}
+282
View File
@@ -0,0 +1,282 @@
// ============================================================
// LynkedUp Pro — Team Management mock data.
// Members, roles and a permission matrix, modelled the way a
// CRM team module works: every member carries a role, and a
// role is a named bundle of permissions. All client-side so the
// screen is fully interactive without a backend.
// ============================================================
/* ---------------------------------------------------------- */
/* PERMISSIONS — grouped, the building blocks of a role */
/* ---------------------------------------------------------- */
export type Permission = { id: string; label: string; desc: string };
export type PermissionGroup = { id: string; title: string; icon: string; perms: Permission[] };
export const permissionGroups: PermissionGroup[] = [
{
id: "sales",
title: "Sales",
icon: "pipeline",
perms: [
{ id: "leads.manage", label: "Manage leads & contacts", desc: "Create, edit, assign and delete leads" },
{ id: "pipeline.manage", label: "Manage pipeline & deals", desc: "Move deals across stages and edit values" },
{ id: "estimates.create", label: "Create estimates & proposals", desc: "Build and send estimates to owners" },
],
},
{
id: "ops",
title: "Operations",
icon: "dispatch",
perms: [
{ id: "dispatch.manage", label: "Manage dispatch & schedule", desc: "Assign crews and schedule site visits" },
{ id: "billing.view", label: "View billing & invoices", desc: "See invoices, receipts and payment status" },
],
},
{
id: "insights",
title: "Insights",
icon: "leaderboard",
perms: [
{ id: "reports.view", label: "View reports & analytics", desc: "Access dashboards and export reports" },
],
},
{
id: "admin",
title: "Administration",
icon: "settings",
perms: [
{ id: "team.manage", label: "Manage team members", desc: "Invite, edit and deactivate members" },
{ id: "roles.manage", label: "Manage roles & permissions", desc: "Create roles and edit permission sets" },
{ id: "settings.manage", label: "Manage org settings", desc: "Billing plan, branding and integrations" },
],
},
];
export const allPermissionIds: string[] = permissionGroups.flatMap((g) => g.perms.map((p) => p.id));
/* ---------------------------------------------------------- */
/* ROLES — named permission bundles */
/* ---------------------------------------------------------- */
export type Role = {
id: string;
name: string;
color: string; // token, e.g. var(--orange)
description: string;
system: boolean; // system roles can't be deleted
perms: string[]; // granted permission ids
attire: string[]; // "attire & appearance" notes from the persona guide
overlap: string; // "common overlap" note — how the role blends with others
};
// Roles mirror the Construction Industry Personas & Attire reference guide:
// each role carries a plain-language description, the attire/appearance cues
// a person in that role typically shows, and the way the role commonly
// overlaps with others in a small contractor.
export const roles: Role[] = [
{
id: "owner",
name: "Owner",
color: "var(--orange)",
description: "Sets the company standard, drives the vision, builds relationships and stays close to sales, operations, production, client care and financial performance.",
system: true,
perms: [...allPermissionIds],
attire: [
"Tucked-in button-down or refined work shirt",
"Fitted jeans or chinos with a belt",
"Nice boots or leather shoes",
"Quality watch — Rolex-style or similar",
"Polished, confident, hands-on presence",
],
overlap: "In smaller companies, owners are often involved in a little bit of everything: sales, estimating, production, payroll, hiring and customer issues.",
},
{
id: "admin",
name: "Admin",
color: "var(--purple)",
description: "Keeps the office running — phones, calendars, customer updates, documents, onboarding paperwork, billing support and general organization.",
system: true,
perms: [...allPermissionIds],
attire: [
"Business casual blouse, sweater or cardigan",
"Clean, comfortable office attire",
"Simple jewelry and neat grooming",
"Organized desk presence",
"Approachable, calm and detail-oriented",
],
overlap: "In small teams, admins may also support HR, accounting, collections, dispatching, permit tracking and customer service.",
},
{
id: "sales-manager",
name: "Sales Manager",
color: "var(--blue)",
description: "Leads the sales team — manages pipeline, trains reps, reviews numbers, sets expectations, supports closes and holds the team accountable.",
system: false,
perms: ["leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage", "billing.view", "reports.view", "team.manage"],
attire: [
"Button-down shirt or polished business-casual top",
"Chinos or clean slacks",
"Leather shoes or clean boots",
"Folder, tablet or sales materials",
"More refined than a field rep, still jobsite-aware",
],
overlap: "Often supports recruiting, coaching, ride-alongs, lead routing, team meetings and performance reviews.",
},
{
id: "sales-rep",
name: "Sales Rep",
color: "var(--green)",
description: "Builds trust with homeowners, performs inspections, explains options, creates estimates, follows up and turns leads into signed projects.",
system: false,
perms: ["leads.manage", "pipeline.manage", "estimates.create"],
attire: [
"Branded polo or clean casual button-down",
"Well-fitted pants or jeans",
"Casual sneakers, boots or clean field shoes",
"Tablet, phone, folder or inspection tools",
"Polished, friendly and trustworthy look",
],
overlap: "In many smaller contractors, a sales rep may also act as project manager after the sale and stay responsible for the customer experience.",
},
{
id: "project-manager",
name: "Project Manager",
color: "var(--cyan)",
description: "Owns the job after approval — scope review, material coordination, scheduling, customer updates, trade coordination, documentation and closeout.",
system: false,
perms: ["leads.manage", "pipeline.manage", "dispatch.manage", "billing.view", "reports.view"],
attire: [
"Button-down, polo or clean field shirt",
"Jeans or durable pants",
"Work boots or field-ready shoes",
"Tablet, plans, folder or measuring tools",
"Professional but ready to walk a jobsite",
],
overlap: "Commonly overlaps with sales, estimating, scheduling, collections and quality control.",
},
{
id: "site-supervisor",
name: "Site Supervisor",
color: "var(--red)",
description: "Runs daily field execution — watches quality and safety, keeps crews moving, verifies details and helps projects stay on schedule.",
system: false,
perms: ["dispatch.manage", "reports.view"],
attire: [
"Company t-shirt or work shirt",
"Ball cap or jobsite hat",
"Durable jeans or work pants",
"Work boots and jobsite watch",
"Practical, experienced, hands-on look",
],
overlap: "May coordinate subs, verify deliveries, answer field questions, document progress and report back to the project manager or owner.",
},
{
id: "canvasser",
name: "Canvasser",
color: "var(--yellow)",
description: "Front-line lead generator — walks neighborhoods, knocks doors, qualifies homes, starts conversations and books inspections for sales reps.",
system: false,
perms: ["leads.manage"],
attire: [
"Young, clean, energetic appearance",
"Branded polo shirt",
"Lanyard ID badge",
"Comfortable pants or khakis",
"Clean sneakers, phone and shoulder bag",
],
overlap: "A strong canvasser may grow into a sales rep or team lead once they learn scripts, objections, territory discipline and appointment quality.",
},
{
id: "team-lead",
name: "Team Lead",
color: "#ec4899",
description: "Leads a small group of reps or canvassers — a sales team lead or a canvassing team lead depending on the company structure.",
system: false,
perms: ["leads.manage", "pipeline.manage", "reports.view"],
attire: [
"Branded polo or clean field-casual shirt",
"Well-fitted pants with belt",
"Boots or clean sneakers",
"Tablet, phone, clipboard or route list",
"Confident, coachable, team-first presence",
],
overlap: "May run morning huddles, assign territory, coach scripts, inspect appointment quality and help new people ramp faster.",
},
{
id: "subcontractor",
name: "Subcontractor",
color: "var(--orange-2)",
description: "Specialized trade partner performing the actual skilled work — roofing, framing, electrical, HVAC, masonry, gutters, painting, drywall or flooring.",
system: false,
perms: ["dispatch.manage", "billing.view"],
attire: [
"Trade-specific workwear",
"Hard hat, high-vis vest, gloves and PPE when needed",
"Durable pants and work boots",
"Tools, belt or equipment as required",
"Rugged, skilled and dependable presence",
],
overlap: "Subs may have their own crews, foremen, schedules, invoices, insurance documents and safety requirements.",
},
{
id: "other",
name: "Other",
color: "var(--muted)",
description: "Utility roles — estimator, dispatcher, bookkeeper, HR coordinator, accounting support, warehouse support, permit coordinator, marketing assistant or specialist.",
system: false,
perms: ["reports.view"],
attire: [
"Dress for the function of the day",
"Office casual for desk work",
"Polo, jeans, boots or safety gear for field visits",
"Clean, reliable, company-aligned appearance",
"Flexible enough to support multiple departments",
],
overlap: "In smaller companies, other roles often become utility players covering gaps across operations, accounting, HR, dispatch, estimating and customer service.",
},
];
/* ---------------------------------------------------------- */
/* MEMBERS */
/* ---------------------------------------------------------- */
export type MemberStatus = "active" | "away" | "offline";
export type Member = {
id: string;
name: string;
initials: string;
email: string;
title: string; // job title, free text
roleId: string;
gradient: string;
status: MemberStatus;
lastActive: string;
deals: number; // open deals owned
joined: string;
};
export const members: Member[] = [
{ id: "u1", name: "James Carter", initials: "JC", email: "james.carter@lynkeduppro.com", title: "Founder & CEO", roleId: "owner", gradient: "linear-gradient(135deg,#fda913,#fd6d13)", status: "active", lastActive: "Active now", deals: 12, joined: "Mar 2021" },
{ id: "u2", name: "Emma Wilson", initials: "EW", email: "emma.wilson@lynkeduppro.com", title: "Head of Sales", roleId: "sales-manager", gradient: "linear-gradient(135deg,#285ef0,#09b9c6)", status: "active", lastActive: "Active now", deals: 21, joined: "Aug 2021" },
{ id: "u3", name: "Liam Foster", initials: "LF", email: "liam.foster@lynkeduppro.com", title: "Senior Sales Rep", roleId: "sales-rep", gradient: "linear-gradient(135deg,#9036e9,#285ef0)", status: "active", lastActive: "5 min ago", deals: 17, joined: "Jan 2022" },
{ id: "u4", name: "Sophie Turner", initials: "ST", email: "sophie.turner@lynkeduppro.com", title: "Account Executive", roleId: "sales-rep", gradient: "linear-gradient(135deg,#14bc83,#09b9c6)", status: "away", lastActive: "32 min ago", deals: 9, joined: "May 2022" },
{ id: "u5", name: "Noah Mitchell", initials: "NM", email: "noah.mitchell@lynkeduppro.com", title: "Site Supervisor", roleId: "site-supervisor", gradient: "linear-gradient(135deg,#f0563f,#fda913)", status: "active", lastActive: "Active now", deals: 0, joined: "Sep 2022" },
{ id: "u6", name: "Olivia Bennett", initials: "OB", email: "olivia.bennett@lynkeduppro.com", title: "Office Admin", roleId: "admin", gradient: "linear-gradient(135deg,#09b9c6,#285ef0)", status: "away", lastActive: "1 hour ago", deals: 3, joined: "Nov 2021" },
{ id: "u7", name: "Ethan Brooks", initials: "EB", email: "ethan.brooks@lynkeduppro.com", title: "Project Manager", roleId: "project-manager", gradient: "linear-gradient(135deg,#ffd60a,#fda913)", status: "offline", lastActive: "Yesterday", deals: 6, joined: "Feb 2023" },
{ id: "u8", name: "Ava Reynolds", initials: "AR", email: "ava.reynolds@lynkeduppro.com", title: "Bookkeeper", roleId: "other", gradient: "linear-gradient(135deg,#9036e9,#f0563f)", status: "offline", lastActive: "2 days ago", deals: 0, joined: "Apr 2023" },
{ id: "u9", name: "Mason Reed", initials: "MR", email: "mason.reed@lynkeduppro.com", title: "Field Canvasser", roleId: "canvasser", gradient: "linear-gradient(135deg,#ffd60a,#14bc83)", status: "active", lastActive: "Active now", deals: 4, joined: "Jun 2023" },
{ id: "u10", name: "Chloe Adams", initials: "CA", email: "chloe.adams@lynkeduppro.com", title: "Sales Team Lead", roleId: "team-lead", gradient: "linear-gradient(135deg,#ec4899,#9036e9)", status: "away", lastActive: "18 min ago", deals: 11, joined: "Oct 2022" },
{ id: "u11", name: "Diego Ramirez", initials: "DR", email: "diego.ramirez@lynkeduppro.com", title: "Roofing Subcontractor", roleId: "subcontractor", gradient: "linear-gradient(135deg,#fd6d13,#f0563f)", status: "offline", lastActive: "3 days ago", deals: 0, joined: "Jan 2023" },
];
/* ---------------------------------------------------------- */
/* PENDING INVITES */
/* ---------------------------------------------------------- */
export type Invite = { id: string; email: string; roleId: string; invitedBy: string; sentAt: string };
export const pendingInvites: Invite[] = [
{ id: "inv1", email: "daniel.hughes@gmail.com", roleId: "sales-rep", invitedBy: "Emma Wilson", sentAt: "2 days ago" },
{ id: "inv2", email: "grace.murphy@outlook.com", roleId: "canvasser", invitedBy: "James Carter", sentAt: "5 days ago" },
];
@@ -0,0 +1,525 @@
"use client";
// ============================================================
// Team Management — a CRM people + roles module, redesigned as
// a creative "crew" experience.
// · Crew hero : avatar stack, live presence, headline stats
// · Members : people gallery (cards) ⇄ compact list, with
// role filter chips, search, MULTI-role chips,
// deal-load meters and row actions
// · Roles : permission-coverage rings + live toggle matrix
// · Invites : envelope timeline with resend / revoke
// Data comes from useTeamData(): the local mock when the Shell
// isn't configured, or the live be-crm data door (crm.team.*)
// when it is. A member can hold MANY roles.
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast,
} from "./ui";
import { permissionGroups, allPermissionIds } from "./team-data";
import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api";
const STATUS_LABEL: Record<UiStatus, string> = { active: "Active", away: "Away", offline: "Offline" };
const toDot = (s: UiStatus) => (s === "offline" ? "offline" : s === "away" ? "away" : "online");
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
export function TeamManagement() {
const toast = useToast();
const team = useTeamData();
const { members, roles, invites, live } = team;
const [tab, setTab] = useState("members");
const [view, setView] = useState<"grid" | "list">("grid");
const [query, setQuery] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [inviteOpen, setInviteOpen] = useState(false);
const [editing, setEditing] = useState<UiMember | null>(null);
const [confirmRemove, setConfirmRemove] = useState<UiMember | null>(null);
const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]);
const assignableRoles = useMemo(() => roles.filter((r) => !r.isOwner), [roles]);
const countByRole = useMemo(() => {
const m: Record<string, number> = {};
for (const mem of members) for (const rid of mem.roleIds) m[rid] = (m[rid] ?? 0) + 1;
return m;
}, [members]);
const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return members.filter((m) => {
const matchRole = roleFilter === "all" || m.roleIds.includes(roleFilter);
const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q);
return matchRole && matchQ;
});
}, [members, query, roleFilter]);
const activeCount = members.filter((m) => m.status === "active").length;
/* ---- mutations (async; live → data door, mock → local) ---- */
async function saveRoles(m: UiMember, roleIds: string[]) {
try {
await team.setMemberRoles(m.id, roleIds);
toast.push({ tone: "success", title: "Roles updated", desc: `${m.name} now holds ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update roles", desc: (e as Error).message }); }
}
async function removeMember(m: UiMember) {
setConfirmRemove(null);
try { await team.removeMember(m.id); toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't remove member", desc: (e as Error).message }); }
}
async function togglePerm(roleId: string, permId: string, granted: boolean) {
try { await team.setPermission(roleId, permId, granted); }
catch (e) { toast.push({ tone: "error", title: "Couldn't update permission", desc: (e as Error).message }); }
}
// Crew stack — show online folks first, capped to 6 with a +N bubble.
const stack = [...members].sort((a, b) => Number(b.status === "active") - Number(a.status === "active"));
const stackShown = stack.slice(0, 6);
const stackRest = members.length - stackShown.length;
return (
<div className="view tm">
{/* ---- Crew hero ---------------------------------------- */}
<header className="tm-hero">
<div className="tm-hero-orbs" aria-hidden />
<div className="tm-hero-grid" aria-hidden />
<div className="tm-hero-main">
<div className="tm-stack" aria-hidden>
{stackShown.map((m, i) => (
<span key={m.id} className="tm-stack-av" style={{ zIndex: stackShown.length - i }}>
<Avatar initials={m.initials} gradient={m.gradient} size={46} status={toDot(m.status)} />
</span>
))}
{stackRest > 0 && <span className="tm-stack-more">+{stackRest}</span>}
</div>
<div className="tm-hero-copy">
<div className="tm-hero-eyebrow">
<Icon name="team" size={13} /> Your crew · LynkedUp Pro
{live && <Pill tone="green" style={{ marginLeft: 8 }}>Live</Pill>}
</div>
<h1>Team Management</h1>
<p>Manage your crew, assign roles and control exactly what each person can do.</p>
<div className="tm-hero-live">
<span className="tm-pulse" /><b>{activeCount}</b> online now
<span className="tm-hero-dot" /> {members.length} members
<span className="tm-hero-dot" /> {invites.length} invites pending
</div>
</div>
</div>
<div className="tm-hero-actions">
<Btn icon="plus" onClick={() => setInviteOpen(true)}>Invite member</Btn>
</div>
</header>
{/* ---- Tabs --------------------------------------------- */}
<div className="tm-tabs" role="tablist">
{[
{ value: "members", label: "Members", icon: "people", badge: members.length },
{ value: "roles", label: "Roles & Permissions", icon: "shield", badge: roles.length },
{ value: "invites", label: "Invites", icon: "mail", badge: invites.length },
].map((t) => (
<button key={t.value} role="tab" aria-selected={tab === t.value} className={`tm-tab ${tab === t.value ? "active" : ""}`} onClick={() => setTab(t.value)}>
<Icon name={t.icon} size={16} /> <span>{t.label}</span>
<span className="tm-tab-badge">{t.badge}</span>
</button>
))}
</div>
{team.error && <div className="card tm-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn&apos;t load team data: {team.error}</p></div>}
{/* ---- MEMBERS ------------------------------------------ */}
{tab === "members" && (
<div className="view-body">
<div className="tm-toolbar">
<div className="tm-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search by name, email or title…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="tm-viewtoggle" role="group" aria-label="Layout">
<button className={view === "grid" ? "on" : ""} aria-label="Gallery view" onClick={() => setView("grid")}><Icon name="subtasks" size={15} /></button>
<button className={view === "list" ? "on" : ""} aria-label="List view" onClick={() => setView("list")}><Icon name="leaderboard" size={15} /></button>
</div>
</div>
{/* Role filter chips */}
<div className="tm-chips">
<button className={`tm-chip ${roleFilter === "all" ? "on" : ""}`} onClick={() => setRoleFilter("all")}>
<span className="tm-chip-dot" style={{ background: "var(--text-2)" }} /> All <i>{members.length}</i>
</button>
{roles.filter((r) => (countByRole[r.id] ?? 0) > 0).map((r) => (
<button key={r.id} className={`tm-chip ${roleFilter === r.id ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => setRoleFilter(r.id)}>
<span className="tm-chip-dot" style={{ background: r.color }} /> {r.name} <i>{countByRole[r.id] ?? 0}</i>
</button>
))}
</div>
{team.live && team.loading && members.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="people" size={24} /></span><p>Loading your crew</p></div>
) : filtered.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="search" size={24} /></span><p>No members match your search.</p><Btn variant="soft" size="sm" onClick={() => { setQuery(""); setRoleFilter("all"); }}>Clear filters</Btn></div>
) : view === "grid" ? (
<div className="tm-gallery">
{filtered.map((m) => (
<article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: roleById[m.roleIds[0]]?.color }}>
<span className="tm-pcard-bg" aria-hidden />
<div className="tm-pcard-top">
<span className="tm-pcard-av"><Avatar initials={m.initials} gradient={m.gradient} size={58} status={toDot(m.status)} /></span>
<RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} />
</div>
<div className="tm-pcard-name">{m.name}{m.isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
<div className="tm-pcard-title">{m.title || <span className="tm-dash">No title</span>}</div>
<div className="tm-pcard-email"><Icon name="mail" size={12} /> {m.email}</div>
<div className="tm-pcard-role">
<RoleChips roleIds={m.roleIds} roleById={roleById} />
</div>
<div className="tm-meter" title={`${m.deals} open deals`}>
<div className="tm-meter-h"><span>Deal load</span><b>{m.deals}</b></div>
<span className="tm-meter-track"><span className="tm-meter-fill" style={{ width: `${Math.round((m.deals / maxDeals) * 100)}%` }} /></span>
</div>
<div className="tm-pcard-foot">
<span className="tm-statuscell"><StatusDot status={toDot(m.status)} /> {STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
</article>
))}
</div>
) : (
<div className="card card-pad-0 tm-table">
<div className="tm-row tm-head">
<span>Member</span><span>Roles</span><span>Status</span><span className="tm-c-num">Open deals</span><span className="tm-c-act" />
</div>
{filtered.map((m) => (
<div className="tm-row" key={m.id}>
<div className="tm-member">
<Avatar initials={m.initials} gradient={m.gradient} size={40} status={toDot(m.status)} />
<div className="tm-member-meta">
<div className="tm-name">{m.name}{m.isYou && <Pill tone="orange" style={{ marginLeft: 8 }}>You{m.isOwner ? " · Owner" : ""}</Pill>}</div>
<div className="tm-sub">{m.title ? `${m.title} · ` : ""}{m.email}</div>
</div>
</div>
<div className="tm-rolecell"><RoleChips roleIds={m.roleIds} roleById={roleById} /></div>
<div className="tm-statuscell">
<StatusDot status={toDot(m.status)} /><span>{STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
<div className="tm-c-num">{m.deals > 0 ? <b>{m.deals}</b> : <span className="tm-dash"></span>}</div>
<div className="tm-c-act"><RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
</div>
))}
</div>
)}
</div>
)}
{/* ---- ROLES -------------------------------------------- */}
{tab === "roles" && (
<div className="view-body tm-roles">
{roles.map((r) => {
const pct = Math.round((r.perms.length / allPermissionIds.length) * 100);
return (
<div className="card tm-rolecard" key={r.id} style={{ ["--rc" as string]: r.color }}>
<div className="tm-rolecard-head">
<span className="tm-ring" style={{ ["--pct" as string]: pct }}>
<span className="tm-ring-in"><Icon name={r.isOwner ? "star" : r.system ? "shield-check" : "shield"} size={17} /></span>
</span>
<div className="tm-rolecard-title">
<div className="tm-rolecard-name">{r.name}{r.system && <Pill tone="muted" style={{ marginLeft: 8 }}>System</Pill>}</div>
<div className="tm-rolecard-sub">{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions</div>
</div>
</div>
{r.description && <p className="tm-rolecard-desc">{r.description}</p>}
{r.attire.length > 0 && (
<div className="tm-attire">
<div className="tm-attire-h"><Icon name="user" size={13} /> Attire &amp; appearance</div>
<ul className="tm-attire-list">
{r.attire.map((a) => (<li key={a}><span className="tm-attire-dot" />{a}</li>))}
</ul>
</div>
)}
{r.overlap && (
<div className="tm-overlap">
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
<div><b>Common overlap</b><p>{r.overlap}</p></div>
</div>
)}
<div className="tm-permlist">
{permissionGroups.map((g) => (
<div className="tm-permgroup" key={g.id}>
<div className="tm-permgroup-h"><Icon name={g.icon} size={13} /> {g.title}</div>
{g.perms.map((p) => {
const granted = r.perms.includes(p.id);
const locked = r.isOwner;
return (
<div className={`tm-perm ${granted ? "on" : ""}`} key={p.id}>
<div className="tm-perm-meta">
<span className="tm-perm-label">{p.label}</span>
<span className="tm-perm-desc">{p.desc}</span>
</div>
<Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id, !granted)} label={p.label} />
</div>
);
})}
</div>
))}
</div>
</div>
);
})}
</div>
)}
{/* ---- INVITES ------------------------------------------ */}
{tab === "invites" && (
<div className="view-body card card-pad-0">
<div className="tm-invite-head">
<div>
<h3>Pending invitations</h3>
<p>People you&apos;ve invited who haven&apos;t joined yet.</p>
</div>
<Btn icon="plus" size="sm" onClick={() => setInviteOpen(true)}>New invite</Btn>
</div>
{invites.length === 0 ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="mail" size={24} /></span><p>No pending invites. Everyone&apos;s on board.</p></div>
) : (
<div className="tm-invites">
{invites.map((inv) => (
<div className="tm-inviterow" key={inv.id}>
<span className="tm-invite-ic"><Icon name="mail" size={16} /></span>
<div className="tm-invite-meta">
<div className="tm-name">{inv.email}</div>
<div className="tm-sub">Invited by {inv.invitedBy} · {inv.sentAt}</div>
</div>
<RoleChips roleIds={inv.roleIds} roleById={roleById} />
<div className="tm-invite-actions">
{inv.token && (
<Btn variant="soft" size="sm" icon="copy" onClick={async () => {
const link = `${window.location.origin}/portal/invite?token=${inv.token}`;
try { await navigator.clipboard.writeText(link); toast.push({ tone: "success", title: "Invite link copied", desc: `Send it to ${inv.email} to join.` }); }
catch { toast.push({ tone: "info", title: "Invite link", desc: link }); }
}}>Copy link</Btn>
)}
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${inv.email}.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
}}>Resend</Btn>
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't revoke", desc: (e as Error).message }); }
}}>Revoke</Btn>
</div>
</div>
))}
</div>
)}
</div>
)}
<InviteModal
open={inviteOpen}
roles={assignableRoles}
onClose={() => setInviteOpen(false)}
onInvite={async (email, roleIds) => {
setInviteOpen(false);
try {
await team.invite(email, roleIds);
setTab("invites");
toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
}}
/>
<EditMemberModal
member={editing}
roles={assignableRoles}
onClose={() => setEditing(null)}
onSave={async (id, title, roleIds) => {
const m = editing;
setEditing(null);
if (!m) return;
try {
await team.updateMember(id, { title, roleIds });
toast.push({ tone: "success", title: "Member updated" });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update member", desc: (e as Error).message }); }
}}
/>
<Modal
open={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
title="Remove member"
subtitle={confirmRemove ? `${confirmRemove.name} will lose access immediately.` : ""}
icon="alert"
size="sm"
footer={
<>
<Btn variant="ghost" onClick={() => setConfirmRemove(null)}>Cancel</Btn>
<Btn variant="danger" icon="trash" onClick={() => confirmRemove && removeMember(confirmRemove)}>Remove member</Btn>
</>
}
>
<p className="tm-confirm-text">Their open deals and assignments stay in the workspace and can be reassigned afterwards. This action can be undone by re-inviting them.</p>
</Modal>
</div>
);
}
/* ---------------------------------------------------------- */
/* Role chips — show every role a member/invite holds */
/* ---------------------------------------------------------- */
function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record<string, UiRole> }) {
if (roleIds.length === 0) return <span className="tm-dash">No role</span>;
return (
<span className="tm-rolechips">
{roleIds.map((rid) => {
const role = roleById[rid];
return (
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }} key={rid}>
<Icon name={role?.isOwner ? "star" : "shield"} size={12} /> {role?.name ?? rid}
</span>
);
})}
</span>
);
}
/* ---------------------------------------------------------- */
/* Row actions menu */
/* ---------------------------------------------------------- */
function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove: () => void; disabled?: boolean }) {
const [open, setOpen] = useState(false);
if (disabled) return <span className="tm-menu-lock" title="The owner can't be edited"><Icon name="lock" size={15} /></span>;
return (
<div className="tm-menu">
<button className="ds-iconbtn" aria-label="Member actions" onClick={() => setOpen((o) => !o)}><Icon name="dots" size={18} /></button>
{open && (
<>
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="edit" size={15} /> Edit member</button>
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change roles</button>
<div className="tm-menu-sep" />
<button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button>
</div>
</>
)}
</div>
);
}
/* ---------------------------------------------------------- */
/* Multi-role picker — checkbox chips, ≥1 required */
/* ---------------------------------------------------------- */
function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) {
return (
<div className="tm-rolepicker">
{roles.map((r) => {
const on = selected.includes(r.id);
return (
<button type="button" key={r.id} className={`tm-rolepick ${on ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => onToggle(r.id)} aria-pressed={on}>
<span className="tm-rolepick-check">{on && <Icon name="check" size={12} />}</span>
<span className="tm-roledot" /> {r.name}
</button>
);
})}
</div>
);
}
/* ---------------------------------------------------------- */
/* Invite modal */
/* ---------------------------------------------------------- */
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) {
const [email, setEmail] = useState("");
const [roleIds, setRoleIds] = useState<string[]>([]);
const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0;
function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
function submit() { if (!valid) return; onInvite(email.trim(), roleIds); setEmail(""); setRoleIds([]); }
return (
<Modal
open={open}
onClose={onClose}
title="Invite a team member"
subtitle="They'll get an email with a secure link to set up their account."
icon="people"
footer={
<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" disabled={!valid} onClick={submit}>Send invitation</Btn>
</>
}
>
<div className="tm-form">
<Field label="Work email" required hint={email && !EMAIL_RE.test(email.trim()) ? undefined : "We'll send the invite here."} error={email && !EMAIL_RE.test(email.trim()) ? "Enter a valid email address." : undefined}>
<input className="ds-input" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Field label="Assign roles" required hint="Pick one or more. Roles decide what this person can see and do.">
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
</Field>
</div>
</Modal>
);
}
/* ---------------------------------------------------------- */
/* Edit member modal — job title + multi-role */
/* ---------------------------------------------------------- */
function EditMemberModal({ member, roles, onClose, onSave }: { member: UiMember | null; roles: UiRole[]; onClose: () => void; onSave: (id: string, title: string, roleIds: string[]) => void }) {
const [title, setTitle] = useState("");
const [roleIds, setRoleIds] = useState<string[]>([]);
const key = member?.id ?? "";
useEffect(() => { if (member) { setTitle(member.title); setRoleIds(member.roleIds); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
if (!member) return null;
const valid = roleIds.length > 0;
function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
return (
<Modal
open={!!member}
onClose={onClose}
title={`Edit ${member.name}`}
subtitle={member.email}
icon="edit"
footer={
<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="check" disabled={!valid} onClick={() => onSave(member.id, title, roleIds)}>Save changes</Btn>
</>
}
>
<div className="tm-form">
<div className="tm-edit-hero">
<Avatar initials={member.initials} gradient={member.gradient} size={52} />
<div>
<div className="tm-name">{member.name}</div>
<div className="tm-sub">Joined {member.joined} · {member.deals} open deals</div>
</div>
</div>
<Field label="Job title">
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" />
</Field>
<Field label="Roles" required hint="A member can hold several roles — permissions are the union of all of them.">
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
</Field>
</div>
</Modal>
);
}
+48 -16
View File
@@ -1,32 +1,64 @@
"use client";
import { Sun, ChevronDown } from "lucide-react";
import { FigIcon } from "./figicon";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { GlobalSearch } from "./global-search";
import { NotificationBell } from "./notification-bell";
import { user } from "./account-data";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
// When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user.
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore */ }
router.replace("/portal/login");
}
export function Topbar({ theme, onToggle }: { theme: "dark" | "light"; onToggle: () => void }) {
return (
<header className="dash-topbar">
<div className="dash-title">
<h1>Dashboard Overview</h1>
<p>Welcome back, here&apos;s what&apos;s happening with your territory</p>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
<div className="top-actions">
<button className="ic-btn" aria-label="Search"><FigIcon name="i_search" size={18} /></button>
<GlobalSearch onNavigate={onNavigate} />
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
{theme === "dark" ? <FigIcon name="i_theme" size={18} /> : <Sun size={18} />}
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button>
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
<FigIcon name="i_bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button>
<button className="top-user">
<span className="av" style={{ background: "linear-gradient(135deg,#fda913,#fd6d13)", display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>JJ</span>
<div style={{ textAlign: "left" }}>
<div className="nm">Justin Johnson</div>
<div className="rl">Owner</div>
<NotificationBell />
<div className="top-user-wrap" style={{ position: "relative" }}>
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
</div>
<ChevronDown size={16} />
</button>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
</div>
</div>
</header>
);
+324
View File
@@ -0,0 +1,324 @@
"use client";
// ============================================================
// LynkedUp Pro — shared design-system primitives.
// Tokens live in dashboard.css (.dash-root). These components
// only consume CSS variables so they theme automatically.
//
// Exports: Icon, Avatar, PageHead, Pill, Toggle, OtpField,
// Modal, ToastProvider/useToast, Field, SegTabs,
// StatusDot, Segmented.
// ============================================================
import {
createContext, useCallback, useContext, useEffect, useId,
useRef, useState, type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
MessageCircle, Ticket, Phone, Mail, BookOpen, Rocket, Shield, ShieldCheck,
Lock, CreditCard, User, Bell, Eye, EyeOff, Camera, Upload, Plus, Star, Send,
Paperclip, Clock, MapPin, Monitor, Smartphone, Tablet, LogOut, ArrowRight,
Info, Check, X, Search, ChevronDown, ChevronRight, Trash2, Globe,
CheckCircle2, KeyRound, Pencil, Copy, RefreshCw, AlertTriangle,
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, Image as ImageIcon, Images, File, FolderOpen, Download,
Video, Play, LayoutGrid, ZoomIn, type LucideIcon,
} from "lucide-react";
/* ---------------------------------------------------------- */
/* Icon — single named entry point used across the module */
/* ---------------------------------------------------------- */
const ICONS: Record<string, LucideIcon> = {
chat: MessageCircle, ticket: Ticket, phone: Phone, mail: Mail, book: BookOpen,
rocket: Rocket, shield: Shield, "shield-check": ShieldCheck, lock: Lock,
card: CreditCard, user: User, bell: Bell, eye: Eye, "eye-off": EyeOff,
camera: Camera, upload: Upload, plus: Plus, star: Star, send: Send,
paperclip: Paperclip, clock: Clock, pin: MapPin, monitor: Monitor,
mobile: Smartphone, tablet: Tablet, logout: LogOut, arrow: ArrowRight,
info: Info, check: Check, x: X, search: Search, chevron: ChevronDown,
"chevron-right": ChevronRight, trash: Trash2, globe: Globe,
"check-circle": CheckCircle2, key: KeyRound, edit: Pencil, copy: Copy,
refresh: RefreshCw, alert: AlertTriangle, privacy: ShieldCheck, devices: Monitor,
desktop: Monitor,
// sidebar / workspace nav
dashboard: LayoutDashboard, owners: Building2, projects: FolderKanban,
leads: UserPlus, verify: BadgeCheck, pipeline: Filter, dispatch: Truck,
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
team: UsersRound, dots: MoreHorizontal,
// media / gallery (also used by messenger.tsx, which already asks for image/file)
image: ImageIcon, gallery: Images, file: File, folder: FolderOpen,
download: Download, video: Video, play: Play, grid: LayoutGrid,
filter: Filter, zoom: ZoomIn, sparkle: Sparkles, "map-pin": MapPin,
};
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
const C = ICONS[name] ?? Info;
return <C size={size} className={className} strokeWidth={strokeWidth} />;
}
/* ---------------------------------------------------------- */
/* Avatar */
/* ---------------------------------------------------------- */
export function Avatar({
initials, gradient = "linear-gradient(135deg,#fda913,#fd6d13)", size = 40, status, square = false,
}: { initials: string; gradient?: string; size?: number; status?: "online" | "away" | "offline" | "busy"; square?: boolean }) {
return (
<span className="ds-avatar" style={{ width: size, height: size, borderRadius: square ? size * 0.28 : "50%", fontSize: size * 0.36 }}>
<span className="ds-avatar-bg" style={{ background: gradient, borderRadius: "inherit" }}>{initials}</span>
{status && <span className={`ds-avatar-dot status-${status}`} style={{ width: size * 0.28, height: size * 0.28 }} />}
</span>
);
}
/* ---------------------------------------------------------- */
/* PageHead — section title block used at the top of each view */
/* ---------------------------------------------------------- */
export function PageHead({ eyebrow, title, subtitle, icon, actions }: { eyebrow?: string; title: string; subtitle?: string; icon?: string; actions?: ReactNode }) {
return (
<div className="ds-pagehead">
<div className="ds-pagehead-l">
{icon && <span className="ds-pagehead-ic"><Icon name={icon} size={22} /></span>}
<div>
{eyebrow && <div className="ds-eyebrow">{eyebrow}</div>}
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
{actions && <div className="ds-pagehead-actions">{actions}</div>}
</div>
);
}
/* ---------------------------------------------------------- */
/* Pill / StatusDot */
/* ---------------------------------------------------------- */
export function Pill({ children, tone = "muted", style }: { children: ReactNode; tone?: string; style?: React.CSSProperties }) {
return <span className={`ds-pill tone-${tone}`} style={style}>{children}</span>;
}
export function StatusDot({ status }: { status: "online" | "away" | "offline" | "busy" }) {
return <span className={`ds-statusdot status-${status}`} />;
}
/* ---------------------------------------------------------- */
/* Toggle */
/* ---------------------------------------------------------- */
export function Toggle({ checked, onChange, disabled, label }: { checked: boolean; onChange?: (v: boolean) => void; disabled?: boolean; label?: string }) {
return (
<button
type="button" role="switch" aria-checked={checked} aria-label={label}
className={`ds-toggle ${checked ? "on" : ""} ${disabled ? "disabled" : ""}`}
onClick={() => !disabled && onChange?.(!checked)}
disabled={disabled}
>
<span className="knob" />
</button>
);
}
/* ---------------------------------------------------------- */
/* Field — labelled input wrapper */
/* ---------------------------------------------------------- */
export function Field({ label, hint, error, children, required }: { label: string; hint?: string; error?: string; children: ReactNode; required?: boolean }) {
return (
<label className="ds-field">
<span className="ds-field-lbl">{label}{required && <i className="req">*</i>}</span>
{children}
{error ? <span className="ds-field-err"><Icon name="alert" size={12} /> {error}</span> : hint ? <span className="ds-field-hint">{hint}</span> : null}
</label>
);
}
/* ---------------------------------------------------------- */
/* Segmented / SegTabs */
/* ---------------------------------------------------------- */
export function Segmented({ options, value, onChange }: { options: { value: string; label: string; icon?: string }[]; value: string; onChange: (v: string) => void }) {
return (
<div className="ds-segmented" role="tablist">
{options.map((o) => (
<button key={o.value} role="tab" aria-selected={value === o.value} className={`seg ${value === o.value ? "active" : ""}`} onClick={() => onChange(o.value)}>
{o.icon && <Icon name={o.icon} size={15} />} {o.label}
</button>
))}
</div>
);
}
export function SegTabs({ tabs, value, onChange }: { tabs: { value: string; label: string; icon?: string; badge?: number }[]; value: string; onChange: (v: string) => void }) {
return (
<div className="ds-tabs" role="tablist">
{tabs.map((t) => (
<button key={t.value} role="tab" aria-selected={value === t.value} className={`ds-tab ${value === t.value ? "active" : ""}`} onClick={() => onChange(t.value)}>
{t.icon && <Icon name={t.icon} size={16} />}
<span>{t.label}</span>
{t.badge != null && t.badge > 0 && <span className="ds-tab-badge">{t.badge}</span>}
</button>
))}
</div>
);
}
/* ---------------------------------------------------------- */
/* OtpField — N-digit one-time-code input */
/* ---------------------------------------------------------- */
export function OtpField({ length = 6, value, onChange, autoFocus = true }: { length?: number; value: string; onChange: (v: string) => void; autoFocus?: boolean }) {
const refs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => { if (autoFocus) refs.current[0]?.focus(); }, [autoFocus]);
function setAt(i: number, char: string) {
const next = value.split("");
next[i] = char;
const joined = next.join("").slice(0, length);
onChange(joined);
}
return (
<div className="ds-otp">
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => { refs.current[i] = el; }}
inputMode="numeric"
maxLength={1}
className="ds-otp-box"
value={value[i] ?? ""}
onChange={(e) => {
const d = e.target.value.replace(/\D/g, "");
if (!d) { setAt(i, ""); return; }
// support paste of full code
if (d.length > 1) {
onChange((value.slice(0, i) + d).slice(0, length));
refs.current[Math.min(i + d.length, length - 1)]?.focus();
return;
}
setAt(i, d);
refs.current[Math.min(i + 1, length - 1)]?.focus();
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !value[i] && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowLeft" && i > 0) refs.current[i - 1]?.focus();
if (e.key === "ArrowRight" && i < length - 1) refs.current[i + 1]?.focus();
}}
/>
))}
</div>
);
}
/* ---------------------------------------------------------- */
/* Modal */
/* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
}) {
const titleId = useId();
// 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(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
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">
<div className="ds-modal-head-l">
{icon && <span className="ds-modal-ic"><Icon name={icon} size={18} /></span>}
<div>
<h3 id={titleId}>{title}</h3>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
<div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>}
</div>
</div>
);
return host ? createPortal(overlay, host) : overlay;
}
/* ---------------------------------------------------------- */
/* Toast */
/* ---------------------------------------------------------- */
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string; onClick?: () => void };
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
const ToastContext = createContext<ToastCtx | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) return { push: () => {} };
return ctx;
}
let toastSeq = 1;
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Toast[]>([]);
const push = useCallback((t: Omit<Toast, "id">) => {
const id = toastSeq++;
setItems((s) => [...s, { ...t, id }]);
setTimeout(() => setItems((s) => s.filter((x) => x.id !== id)), 3800);
}, []);
return (
<ToastContext.Provider value={{ push }}>
{children}
<div className="ds-toasts">
{items.map((t) => (
<div key={t.id} className={`ds-toast tone-${t.tone}${t.onClick ? " is-clickable" : ""}`}>
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
<div
className="ds-toast-body"
{...(t.onClick ? { role: "button", tabIndex: 0, onClick: () => { t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
>
<div className="ds-toast-title">{t.title}</div>
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
</div>
<button className="ds-toast-x" aria-label="Dismiss" onClick={() => setItems((s) => s.filter((x) => x.id !== t.id))}><Icon name="x" size={14} /></button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ---------------------------------------------------------- */
/* Button — light helper so call sites stay terse */
/* ---------------------------------------------------------- */
export function Btn({ children, variant = "primary", icon, iconRight, onClick, disabled, type = "button", full, size = "md" }: {
children: ReactNode; variant?: "primary" | "ghost" | "soft" | "danger" | "outline";
icon?: string; iconRight?: string; onClick?: () => void; disabled?: boolean;
type?: "button" | "submit"; full?: boolean; size?: "sm" | "md";
}) {
return (
<button type={type} className={`ds-btn v-${variant} s-${size} ${full ? "full" : ""}`} onClick={onClick} disabled={disabled}>
{icon && <Icon name={icon} size={size === "sm" ? 14 : 16} />}
{children}
{iconRight && <Icon name={iconRight} size={size === "sm" ? 14 : 16} />}
</button>
);
}
+72
View File
@@ -0,0 +1,72 @@
// ============================================================
// LynkedUp Pro — Lead Verification mock data.
// The verification desk: door-knocked / web leads move through
// an identity + insurance verification workflow before they
// become working deals. All client-side.
// ============================================================
export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unverified";
export type VActivity = { text: string; time: string; who: string };
export type VLead = {
id: string;
initials: string;
name: string;
address: string;
phone: string;
source: string;
assignee: { initials: string; name: string } | null;
status: VStatus;
verification: string; // sub-status text
created: string;
// detail (optional — derived when absent)
email?: string;
createdAt?: string; // date + time
verifiedAt?: string;
notes?: string;
activity?: VActivity[];
};
export const V_STATUS_META: Record<VStatus, { label: string; tone: string }> = {
verified: { label: "Verified", tone: "green" },
in_progress: { label: "In Progress", tone: "orange" },
assigned: { label: "Assigned", tone: "blue" },
pending: { label: "Pending", tone: "purple" },
unverified: { label: "Unverified", tone: "red" },
};
const WADE = { initials: "WH", name: "Wade Hollis" };
const DARLENE = { initials: "DB", name: "Darlene Brooks" };
const ROY = { initials: "RS", name: "Roy Schaefer" };
export const V_LEADS: VLead[] = [
{
id: "LD-V-001", initials: "K", name: "Kevin Hartley", address: "2814 Ravenswood Dr, Plano, TX 75023", phone: "(972) 413-8902", source: "Door Knock", assignee: WADE, status: "verified", verification: "Verified", created: "May 14, 2026",
email: "kevin.hartley@gmail.com", createdAt: "May 14, 2026, 2:40 PM", verifiedAt: "May 16, 2026, 7:55 PM",
notes: "Ownership confirmed via county records. Hail damage from April 28 storm event verified.",
activity: [
{ text: "Lead submitted via door knock intake.", time: "May 14, 2026, 2:40 PM", who: "System" },
{ text: "Assigned to Wade Hollis.", time: "May 14, 2026, 3:10 PM", who: "Wade Hollis" },
{ text: "First contact made — homeowner confirmed damage.", time: "May 15, 2026, 2:00 PM", who: "Wade Hollis" },
{ text: "Verified and pushed to New Leads.", time: "May 16, 2026, 7:55 PM", who: "Wade Hollis" },
],
},
{ id: "LD-V-002", initials: "S", name: "Sandra Nguyen", address: "4817 Shady Brook Ln, Plano, TX 75093", phone: "(469) 551-7034", source: "Web Form", assignee: DARLENE, status: "verified", verification: "Verified", created: "May 17, 2026" },
{ id: "LD-V-003", initials: "M", name: "Marcus Trevino", address: "1234 Oak Creek Blvd, Plano, TX 75075", phone: "(214) 837-4561", source: "Storm Canvass", assignee: WADE, status: "in_progress", verification: "Verifying Identity", created: "May 22, 2026" },
{ id: "LD-V-004", initials: "B", name: "Brenda Kowalski", address: "890 Custer Rd, Plano, TX 75075", phone: "(972) 604-2817", source: "Referral", assignee: ROY, status: "in_progress", verification: "Reviewing Insurance", created: "May 20, 2026" },
{ id: "LD-V-005", initials: "J", name: "James Whitaker", address: "3320 Parkhaven Dr, Plano, TX 75075", phone: "(469) 720-1188", source: "Door Knock", assignee: WADE, status: "in_progress", verification: "Confirming Ownership", created: "May 23, 2026" },
{ id: "LD-V-006", initials: "P", name: "Priya Sharma", address: "5102 Mapleshade Ln, Plano, TX 75093", phone: "(972) 415-6620", source: "Web Form", assignee: DARLENE, status: "in_progress", verification: "Reviewing Insurance", created: "May 24, 2026" },
{ id: "LD-V-007", initials: "C", name: "Carlos Mendez", address: "1470 Coit Rd, Plano, TX 75075", phone: "(214) 902-5533", source: "Storm Canvass", assignee: ROY, status: "in_progress", verification: "Confirming Damage", created: "May 25, 2026" },
{ id: "LD-V-008", initials: "E", name: "Emily Carter", address: "2609 Rivercrest Dr, Plano, TX 75023", phone: "(469) 338-4471", source: "Door Knock", assignee: DARLENE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
{ id: "LD-V-009", initials: "T", name: "Tyrone Jackson", address: "744 Legacy Dr, Plano, TX 75023", phone: "(972) 551-9042", source: "Referral", assignee: WADE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
{ id: "LD-V-010", initials: "N", name: "Nicole Foster", address: "3901 Preston Meadow Dr, Plano, TX 75093", phone: "(214) 660-7719", source: "Web Form", assignee: ROY, status: "assigned", verification: "Assigned", created: "May 27, 2026" },
{ id: "LD-V-011", initials: "A", name: "Aaron Blake", address: "1188 Alma Dr, Plano, TX 75075", phone: "(469) 471-3350", source: "Door Knock", assignee: null, status: "pending", verification: "Pending Review", created: "May 27, 2026" },
{ id: "LD-V-012", initials: "G", name: "Grace Liu", address: "5540 Communications Pkwy, Plano, TX 75093", phone: "(972) 883-2201", source: "Web Form", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
{ id: "LD-V-013", initials: "D", name: "Derek Olsen", address: "902 Independence Pkwy, Plano, TX 75075", phone: "(214) 774-6690", source: "Call-In", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
{ id: "LD-V-014", initials: "M", name: "Monica Reyes", address: "3115 Rasor Blvd, Plano, TX 75093", phone: "(469) 205-8814", source: "Storm Canvass", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
{ id: "LD-V-015", initials: "S", name: "Sam Patterson", address: "677 Spring Creek Pkwy, Plano, TX 75023", phone: "(972) 330-1247", source: "Call-In", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
];
export const V_SOURCES = ["Door Knock", "Web Form", "Storm Canvass", "Referral", "Call-In"];
export const V_ASSIGNEES = ["Wade Hollis", "Darlene Brooks", "Roy Schaefer"];
+306
View File
@@ -0,0 +1,306 @@
"use client";
// ============================================================
// Lead Verification — the verification desk.
// · Stat tiles : Verified / In Progress / Assigned / Pending
// / Unverified counts (click to filter)
// · Toolbar : search + status / source / assignee filters
// · Table : Lead ID · Customer · Phone · Source ·
// Assigned To · Status · Verification · Created
// · Actions (view / verify / ⋯ menu)
// · Detail : view → popup with Contact, Assignment,
// Verification Notes and an Activity timeline
// Data comes from verify-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
const STAT_ICON: Record<VStatus, string> = {
verified: "check-circle", in_progress: "refresh", assigned: "user", pending: "clock", unverified: "alert",
};
/* ---- derive detail when the row doesn't carry it ---------- */
function deriveEmail(l: VLead) {
if (l.email) return l.email;
const [first, ...rest] = l.name.toLowerCase().split(" ");
return `${first}.${rest.join("")}@gmail.com`;
}
function deriveCreatedAt(l: VLead) { return l.createdAt ?? `${l.created}, 10:00 AM`; }
function buildActivity(l: VLead): VActivity[] {
if (l.activity) return l.activity;
const who = l.assignee?.name ?? "System";
const a: VActivity[] = [{ text: `Lead submitted via ${l.source.toLowerCase()} intake.`, time: deriveCreatedAt(l), who: "System" }];
if (l.assignee) a.push({ text: `Assigned to ${l.assignee.name}.`, time: l.created, who: l.assignee.name });
if (l.status === "in_progress") a.push({ text: `Verification in progress — ${l.verification.toLowerCase()}.`, time: l.created, who });
if (l.status === "verified") a.push({ text: "Verified and pushed to New Leads.", time: l.verifiedAt ?? l.created, who });
return a;
}
type MenuState = { lead: VLead; x: number; y: number } | null;
export function Verify() {
const toast = useToast();
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [source, setSource] = useState("all");
const [assignee, setAssignee] = useState("all");
const [selected, setSelected] = useState<VLead | null>(null);
const [menu, setMenu] = useState<MenuState>(null);
const counts = useMemo(() => {
const m: Record<string, number> = {};
for (const l of V_LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
return m;
}, []);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return V_LEADS.filter((l) => {
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
const matchStatus = status === "all" || l.status === status;
const matchSource = source === "all" || l.source === source;
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
return matchQ && matchStatus && matchSource && matchAssignee;
});
}, [query, status, source, assignee]);
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
setMenu(null);
toast.push({ tone, title, desc });
}
return (
<div className="view lv">
<PageHead
eyebrow="Sales"
title="Lead Verification"
subtitle="Identity & insurance checks before a lead becomes a working deal."
icon="verify"
actions={<Btn variant="outline" icon="refresh" onClick={() => toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." })}>Refresh</Btn>}
/>
{/* ---- stat tiles ---- */}
<div className="lv-stats">
{STAT_ORDER.map((s) => {
const meta = V_STATUS_META[s];
return (
<button key={s} className={`lv-stat tone-${meta.tone} ${status === s ? "active" : ""}`} onClick={() => setStatus(status === s ? "all" : s)}>
<span className="lv-stat-ic"><Icon name={STAT_ICON[s]} size={16} /></span>
<span className="lv-stat-val">{counts[s] ?? 0}</span>
<span className="lv-stat-lbl">{meta.label}</span>
</button>
);
})}
</div>
{/* ---- toolbar ---- */}
<div className="lv-toolbar">
<div className="lv-search">
<Icon name="search" size={16} />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name, lead ID, phone, source…" aria-label="Search verification leads" />
{query && <button className="lv-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<select className="ds-select lv-filter" value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Filter by status">
<option value="all">All statuses</option>
{STAT_ORDER.map((s) => <option key={s} value={s}>{V_STATUS_META[s].label}</option>)}
</select>
<select className="ds-select lv-filter" value={source} onChange={(e) => setSource(e.target.value)} aria-label="Filter by source">
<option value="all">All sources</option>
{V_SOURCES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
<option value="all">All assignees</option>
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
{/* ---- table ---- */}
<div className="lv-tablewrap">
<table className="lv-table">
<thead>
<tr>
<th>Lead ID</th><th>Customer</th><th>Phone</th><th>Source</th>
<th>Assigned To</th><th>Status</th><th>Verification</th><th>Created</th>
<th className="lv-actions-h">Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
) : rows.map((l) => {
const meta = V_STATUS_META[l.status];
return (
<tr key={l.id}>
<td><div className="lv-id">{l.id}</div><div className="lv-id-date">{l.created}</div></td>
<td>
<div className="lv-cust">
<Avatar initials={l.initials} size={34} />
<div className="lv-cust-body">
<div className="lv-cust-name">{l.name}</div>
<div className="lv-cust-addr">{l.address}</div>
</div>
</div>
</td>
<td className="lv-phone">{l.phone}</td>
<td><span className="lv-source">{l.source}</span></td>
<td>
{l.assignee ? (
<div className="lv-assignee">
<Avatar initials={l.assignee.initials} size={26} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
<span>{l.assignee.name}</span>
</div>
) : <span className="lv-unassigned"> Unassigned</span>}
</td>
<td><Pill tone={meta.tone}>{meta.label}</Pill></td>
<td><span className={`lv-verif v-${l.status}`}><Icon name={STAT_ICON[l.status]} size={13} /> {l.verification}</span></td>
<td className="lv-created">{l.created}</td>
<td>
<div className="lv-rowacts">
<button className="lv-act" aria-label="View details" title="View details" onClick={() => setSelected(l)}><Icon name="eye" size={15} /></button>
<button className="lv-act primary" aria-label="Verify" title="Verify lead" onClick={() => act(l, "Marked verified", `${l.name} moved to Verified.`, "success")}><Icon name="check-circle" size={15} /></button>
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Row actions dropdown (portalled, fixed-positioned) */
/* ---------------------------------------------------------- */
function ActionsMenu({ menu, onClose, onAct, onView }: {
menu: MenuState; onClose: () => void;
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => void;
onView: (l: VLead) => void;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
if (!menu) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [menu, onClose]);
if (!menu || !mounted) return null;
const l = menu.lead;
const left = Math.max(8, menu.x - 196);
const top = Math.min(menu.y + 8, window.innerHeight - 260);
const items = [
{ icon: "eye", label: "View Details", run: () => onView(l) },
{ icon: "check-circle", label: "Verify Lead", run: () => onAct(l, "Verified", `${l.name} marked as verified.`, "success") },
{ icon: "alert", label: "Mark Unverified", run: () => onAct(l, "Marked unverified", `${l.name} moved to Unverified.`) },
{ icon: "user", label: "Change Assignee", run: () => onAct(l, "Change assignee", `Pick a new rep for ${l.name}.`) },
{ icon: "refresh", label: "Reassign (In Progress)", run: () => onAct(l, "Reassigned", `${l.name} set to In Progress.`) },
{ icon: "clock", label: "Move to Pending", run: () => onAct(l, "Moved to pending", `${l.name} is now Pending review.`) },
];
return createPortal(
<>
<div className="lv-menu-scrim" onClick={onClose} />
<div className="lv-menu" style={{ left, top }} role="menu">
{items.map((it) => (
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run}>
<Icon name={it.icon} size={14} /> {it.label}
</button>
))}
</div>
</>,
document.body,
);
}
/* ---------------------------------------------------------- */
/* Verification detail popup */
/* ---------------------------------------------------------- */
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
if (!lead) return null;
const meta = V_STATUS_META[lead.status];
const activity = buildActivity(lead);
return (
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
footer={<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn icon="check-circle">Verify Lead</Btn>
</>}
>
<div className="lv-detail">
<div className="lv-d-identity">
<Avatar initials={lead.initials} size={50} />
<div>
<div className="lv-d-name">{lead.name}</div>
<div className="lv-d-pills">
<Pill tone={meta.tone}>{meta.label}</Pill>
<span className={`lv-verif v-${lead.status}`}><Icon name={STAT_ICON[lead.status]} size={13} /> {lead.verification}</span>
</div>
</div>
</div>
<div className="lv-d-grid">
<div className="lv-d-section">
<div className="lv-d-head"><Icon name="user" size={15} /> Contact</div>
<Row label="Phone" value={lead.phone} />
<Row label="Email" value={deriveEmail(lead)} />
<Row label="Address" value={lead.address} />
<Row label="Source" value={lead.source} />
</div>
<div className="lv-d-section">
<div className="lv-d-head"><Icon name="team" size={15} /> Assignment</div>
<Row label="Assigned To" value={lead.assignee?.name ?? "Unassigned"} />
<Row label="Created" value={deriveCreatedAt(lead)} />
{lead.verifiedAt && <Row label="Verified At" value={lead.verifiedAt} />}
</div>
</div>
{lead.notes && (
<div className="lv-d-notes">
<div className="lv-d-head"><Icon name="edit" size={15} /> Verification Notes</div>
<p>{lead.notes}</p>
</div>
)}
<div className="lv-d-activity">
<div className="lv-d-head"><Icon name="clock" size={15} /> Activity</div>
<ul className="lv-timeline">
{activity.map((a, i) => (
<li key={i} className="lv-tl-item">
<span className="lv-tl-dot" />
<div className="lv-tl-body">
<div className="lv-tl-text">{a.text}</div>
<div className="lv-tl-meta">{a.time} · {a.who}</div>
</div>
</li>
))}
</ul>
</div>
</div>
</Modal>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="lv-d-row">
<span className="lv-d-k">{label}</span>
<span className="lv-d-v">{value}</span>
</div>
);
}
+2 -7
View File
@@ -145,18 +145,13 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
if (url) { window.location.href = url; return true; }
return false;
}
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) {
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
// Only Google is offered (Microsoft/Apple intentionally hidden).
return (
<div className="col gap-3">
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
<GoogleMark /> {verb} with Google
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
<MicrosoftMark /> {verb} with Microsoft / Outlook
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
<AppleMark /> {verb} with Apple
</button>
</div>
);
}
+10 -9
View File
@@ -27,10 +27,10 @@ export const MASKED_WA = "+91 ••••• ••012";
/* ---- demo account "on file" ---- */
export const DEMO_USER = {
name: "Rajesh Kumar Sharma",
firstName: "Rajesh",
lastName: "Sharma",
initials: "RS",
name: "James Carter",
firstName: "James",
lastName: "Carter",
initials: "JC",
allotmentNo: "YEA-654321",
sector: "Sector 18",
pocket: "Pocket B",
@@ -38,7 +38,7 @@ export const DEMO_USER = {
category: "Residential",
size: "300 sq.m",
maskedMobile: MASKED_PHONE,
email: "rajesh.sharma@example.com",
email: "james.carter@example.com",
relationship: "Self",
isAllottee: true,
};
@@ -75,11 +75,12 @@ export const countryCodes: CountryCode[] = [
];
export const relationshipOptions = [
"Homeowner",
"Customer",
"Employee",
"Owner",
"Contractor",
"Property Manager",
"Tenant",
"Authorized Representative",
"Sub-Con",
"Vendor",
];
export const sectors = [
+211 -40
View File
@@ -8,7 +8,13 @@ import {
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
PasswordStrength,
} from "./bits";
import { lookupAccount, type Account } from "./data";
import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP } from "@/lib/otp";
// Map the portal's social button ids to Supabase OAuth provider ids.
const OAUTH_PROVIDER: Record<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
type Step =
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
@@ -19,12 +25,45 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() {
const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null);
const [provider, setProvider] = useState<string>("");
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 {
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
}
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
push("otp"); // push (not replace) so Back returns to the identify screen
}
/* ---- history hash sync ---- */
function push(s: Step) {
@@ -47,6 +86,37 @@ export function LoginFlow() {
return () => window.removeEventListener("popstate", onPop);
}, []);
/* ---- OAuth return: finish the redirect started by loginWithOAuth ---- */
useEffect(() => {
if (!isShellConfigured()) return;
const code = new URLSearchParams(window.location.search).get("code");
if (!code) return;
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
if (!ready) { setStep("connecting"); return; }
setStep("connecting");
completeOAuthLogin(code)
.then(async (ace) => {
if (!ace) { replace("identify"); return; }
// First-time Google user (no CRM profile yet) → complete onboarding; an
// existing profile → straight to the dashboard.
let registered = false;
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
if (registered) { await acceptPendingInviteThenDashboard(); return; }
// Capture the verified email now (session is fresh) so onboarding prefills it.
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
})
.catch(() => {
setFlash("Google sign-in didn't complete. Please try again.");
replace("identify");
});
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
/* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
if (factor === "password") { setFlash(""); push("otp"); return; }
@@ -56,6 +126,14 @@ export function LoginFlow() {
}
function onSocial(p: "google" | "microsoft" | "apple") {
if (isShellConfigured()) {
// Real OAuth: appshell redirects to the provider; we finish on return (effect above).
setProvider(p);
push("connecting");
loginWithOAuth(OAUTH_PROVIDER[p] ?? p, `${window.location.origin}/portal/login`)
.catch(() => { setFlash("Couldn't start sign-in with that provider."); replace("identify"); });
return;
}
const real = startSocial(p);
if (real) return; // redirected away
setProvider(p);
@@ -65,6 +143,20 @@ export function LoginFlow() {
function identifyEmail() {
if (!EMAIL_RE.test(email)) return;
if (isShellConfigured()) {
// No mock account directory when the Shell is live: any valid email goes to the
// password screen; the BFF/Supabase decides if the account exists.
const name = email.split("@")[0];
setAccount({
firstName: name.charAt(0).toUpperCase() + name.slice(1),
name, initials: name.slice(0, 2).toUpperCase(),
hasPasskey: false, hasTotp: false, hasPush: false,
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
setOtpChannel("email");
push("password"); // push (not replace) so Back returns to the email screen, not off-page
return;
}
push("connecting");
setProvider("");
setTimeout(() => {
@@ -78,14 +170,18 @@ export function LoginFlow() {
/* =================================================================== */
return (
<div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />}
{step === "identify" && (
<>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} invited={invited} />
</>
)}
{step === "connecting" && (
<div className="interstitial">
<Spinner lg />
<div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div>
</div>
)}
@@ -124,11 +220,11 @@ export function LoginFlow() {
)}
{step === "password" && account && (
<Password account={account} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
<Password account={account} email={email} login={login} onAuthenticated={acceptPendingInviteThenDashboard} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)}
{step === "otp" && account && (
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} />
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} />
)}
{step === "another" && account && (
@@ -192,17 +288,17 @@ export function LoginFlow() {
/* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: {
email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void;
onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; invited?: boolean;
}) {
const [showEmail, setShowEmail] = useState(false);
const [showEmail, setShowEmail] = useState(!!invited);
const valid = EMAIL_RE.test(email);
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} />
@@ -225,6 +321,12 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
</button>
</form>
)}
{/* Phone (SMS) sign-in needs a deliverable one-time code hidden while SMS is
mocked, since a faked code can't mint a real session. Email + password + Google
all work without SMS. */}
{isShellConfigured() && !MOCK_OTP && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
@@ -233,7 +335,6 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span>
</div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div>
);
}
@@ -268,22 +369,32 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
<button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button>
</div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} />
</div>
);
}
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
account: Account; email: string; login: ReturnType<typeof useAuth>["login"]; onAuthenticated: () => void;
flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
}) {
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [err, setErr] = useState("");
function submit(e: React.FormEvent) {
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!pw || busy) return;
if (isShellConfigured()) {
// Real sign-in through appshell → BFF. A resolved ACE means fully authenticated
// (Supabase handles any MFA), so go straight to the dashboard.
setErr(""); setBusy(true);
try { await login({ email, password: pw }); onAuthenticated(); }
catch { setErr("locked"); onLocked(); }
finally { setBusy(false); }
return;
}
if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; }
if (!pw) return;
onOk();
}
return (
@@ -304,46 +415,109 @@ function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
</button>
</div>
</div>
<button className="btn btn-primary" style={{ marginTop: 14 }}>Sign in <Icon name="arrowR" size={16} /></button>
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={busy}>{busy ? "Signing in…" : <>Sign in <Icon name="arrowR" size={16} /></>}</button>
</form>
<div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
<button className="link" onClick={onOtp}>Sign in with a one-time code</button>
</div>
<p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>
</div>
);
}
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: {
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void;
function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
}) {
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email");
const [error, setError] = useState(false);
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa;
function complete(code: string) {
if (code === "000000") { setError(true); return; }
setError(false); onVerified();
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
const shell = isShellConfigured();
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
const [phone, setPhone] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>("");
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa;
const PHONE_RE = /^\+[1-9]\d{6,14}$/;
// Real Supabase OTP: auto-send the email code when this screen opens.
useEffect(() => {
if (shell && channel === "email" && !sent) void send();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shell, channel]);
async function send() {
setError("");
try {
if (channel === "email") { await sendEmailOtp(email); setSent(true); }
else if (channel === "sms") {
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
await sendPhoneOtp(phone); setSent(true);
}
} catch (e) {
// gotrue can throw an error whose message is an unhelpful "{}"/JSON blob (e.g. a
// 500 "Error sending magic link email" when SMTP isn't set up). Show something
// readable and actionable instead of the raw payload.
const raw = (e as { message?: unknown })?.message;
const readable = typeof raw === "string" && raw.trim() && !raw.trim().startsWith("{") ? raw.trim() : "";
setError(readable || "We couldn't send your sign-in code right now. Please try again shortly, or sign in with your password.");
}
}
async function complete(code: string) {
if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; }
setBusy(true); setError("");
try {
if (channel === "email") await verifyEmailOtp(email, code);
else await verifyPhoneOtp(phone, code);
onAuthenticated();
} catch { setError("Incorrect or expired code. Please try again."); }
finally { setBusy(false); }
}
const showBoxes = !shell || sent;
return (
<div>
<StepBack onClick={onBack} />
<h1>2-step verification</h1>
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p>
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<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(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
</div>
)}
{shell && channel === "sms" && !sent ? (
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
<div className="field">
<label className="label" htmlFor="otpphone">Phone number</label>
<input id="otpphone" className="input" type="tel" autoFocus placeholder="+1 415 555 0100" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={busy}>Send code <Icon name="arrowR" size={16} /></button>
</form>
) : (
<>
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
<OtpBoxes onComplete={complete} error={error} />
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></div>}
{showBoxes && <OtpBoxes onComplete={(c) => void complete(c)} error={!!error} />}
</>
)}
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">{error === "bad" ? "Incorrect code. Please try again." : error}</FlashNote></div>}
<div className="row between" style={{ margin: "14px 0" }}>
<ResendLink seconds={30} />
{shell ? <button className="link" onClick={() => void send()} disabled={busy}>Resend code</button> : <ResendLink seconds={30} />}
<span />
</div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
<p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>
</div>
);
}
@@ -398,7 +572,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button>
<RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div>
);
}
@@ -417,7 +590,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div>
);
}
@@ -501,7 +673,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div>
);
}
+5 -1
View File
@@ -508,8 +508,12 @@ select.input option { background: var(--ink); color: var(--text); }
/* ---- country code select ---- */
.phone-row { display: flex; gap: 8px; }
.cc-field { position: relative; flex: 0 0 118px; width: 118px; }
.cc-field .cc-select { width: 100%; flex: none; padding-left: 40px; }
.cc-flag-img { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 22px; height: 16px; border-radius: 3px; object-fit: cover; pointer-events: none; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
.cc-flag-img.static { position: static; transform: none; }
.cc-select { width: 110px; flex: 0 0 110px; }
@media (max-width: 420px) { .cc-select { width: 96px; flex-basis: 96px; } }
@media (max-width: 420px) { .cc-field { flex-basis: 104px; width: 104px; } .cc-select { width: 96px; flex-basis: 96px; } }
/* ---- misc login/register bits ---- */
.tips { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 8px; }
+233 -218
View File
@@ -5,19 +5,40 @@ import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons";
import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal,
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";
const STEPS = ["Account", "Property", "Verify", "Address"];
type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
const STEPS = ["Account", "Verify", "Address"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function RegisterFlow() {
export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
// profile: email is skipped (Google-verified), the auth account already exists so
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter();
const { 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>({});
const [mailAddr, setMailAddr] = useState<Addr>({});
const [mailingSame, setMailingSame] = useState(true);
// shared form state
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
@@ -27,84 +48,145 @@ export function RegisterFlow() {
const [cc, setCc] = useState("+1");
const [phone, setPhone] = useState("");
const [pw, setPw] = useState("");
const [relationship, setRelationship] = useState("Homeowner");
const [alloeNo, setAlloeNo] = useState("");
const [alloeFirst, setAlloeFirst] = useState("");
const [alloeLast, setAlloeLast] = useState("");
const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false);
// step 1
const [allotment, setAllotment] = useState("");
const [plotVerified, setPlotVerified] = useState(false);
// step 2
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.
useEffect(() => {
if (!onboard) return;
try { const cached = sessionStorage.getItem("onboard_email"); if (cached) setEmail(cached); } catch { /* ignore */ }
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS;
const isSelf = relationship === "Homeowner";
const country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(alloeNo);
const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && alloeFirst.trim() && alloeLast.trim()));
termsOk && privacyOk;
const step1Valid = /^LUP-\d{6}$/.test(allotment) || plotVerified;
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;
function finish() {
// Registration: create the auth account when leaving the Account step, so the phone
// can be attached + verified against a live session at the Verify step. Onboarding
// is already authenticated, so it just advances.
async function leaveAccountStep() {
if (onboard || !isShellConfigured() || sso) { setStep(1); return; }
setSubmitErr("");
setCreating(true);
try {
await register(email, pw);
setStep(1);
} catch {
setSubmitErr("We couldn't create that account. The email may already be registered.");
} finally {
setCreating(false);
}
}
async function finish() {
const finalEmail = sso?.email || email;
const profile = {
name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: sso?.email || email,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
email: finalEmail,
};
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
if (isShellConfigured()) {
setSubmitErr("");
// 1) Auth account already exists at this point — registration created it when
// leaving the Account step; onboarding users signed in via Google. Onboarding
// additionally sets a password so email+password login works too (the phone was
// just verified against the same live session).
if (onboard && pw) {
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
}
// 2) Persist the CRM registration payload to be-crm (name, phone, addresses,
// consent). No role/persona — a new user has no permissions until invited.
// Non-fatal: the auth account exists either way.
try {
const mailing = mailingSame ? regAddr : mailAddr;
await sdk.command("crm.account.register", {
email: finalEmail,
firstName: first, lastName: last,
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
registeredAddress: regAddr,
mailingAddress: mailing,
mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk,
});
} catch {
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
}
// 3) If the user arrived from a team invitation link, redeem it now — this
// creates their membership with the invited role(s). Non-fatal.
try {
const inviteToken = sessionStorage.getItem("invite_token");
if (inviteToken) {
await sdk.command("crm.team.invitation.accept", { token: inviteToken });
sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
}
} catch { /* invitation expired/used — they can still be invited again */ }
}
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
router.push("/dashboard");
}
return (
<div className="card anim-fade-up">
<Stepper current={step} />
<Stepper current={step} labels={STEP_LABELS} />
{step === 0 && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<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}
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")}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/>
</>
)}
{step === 1 && (
<StepAllotment
allotment={allotment} setAllotment={setAllotment}
plotVerified={plotVerified} setPlotVerified={setPlotVerified}
valid={step1Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
<StepVerify
cc={cc} phone={phone} country={country}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
{step === 2 && (
<StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(1)} onContinue={() => setStep(3)}
/>
)}
{step === 3 && (
<StepAddress onBack={() => setStep(2)} onFinish={finish} />
<>
{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>
);
@@ -117,59 +199,53 @@ 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;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; invited?: boolean;
}) {
const [phase, setPhase] = useState<"sso" | "profile">("sso");
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") {
const valid = EMAIL_RE.test(p.email);
return (
<div>
<h1>Create your account</h1>
<p className="sub">Sign up with a provider or 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 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<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>
<div style={{ marginTop: 16 }}>
{p.sso ? (
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
) : (
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
)}
<div className="field" style={{ marginTop: 16 }}>
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} disabled readOnly aria-label="Email address" />
</div>
<span className="faint" style={{ fontSize: 12 }}>{p.onboard ? "Verified with Google — this can't be changed." : "The email you're registering with."}</span>
</div>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
@@ -191,9 +267,13 @@ function StepAccount(p: {
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Mobile number</label>
<div className="phone-row">
<div className="cc-field">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img" src={flagUrl(p.country.flag)} alt={p.country.name} width={22} height={16} />
<select className="input cc-select" value={p.cc} onChange={(e) => p.setCc(e.target.value)}>
{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.flag} {c.code}</option>)}
{countryCodes.map((c) => <option key={c.code} value={c.code}>{c.code}</option>)}
</select>
</div>
<input className="input grow" inputMode="numeric" value={p.phone} onChange={(e) => p.setPhone(e.target.value.replace(/\D/g, ""))} placeholder={p.country.example} />
</div>
{p.phone && !p.phoneOk && <span style={{ fontSize: 12, color: "#fca5a5" }}>Enter a valid {p.country.digits}-digit number.</span>}
@@ -209,35 +289,6 @@ function StepAccount(p: {
</div>
)}
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Your role</label>
<select className="input" value={p.relationship} onChange={(e) => p.setRelationship(e.target.value)}>
{relationshipOptions.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
{p.isSelf ? (
<div style={{ marginTop: 12 }}><FlashNote tone="success">Homeowner account you own this property.</FlashNote></div>
) : (
<div style={{ marginTop: 12 }}>
<FlashNote tone="info">Registering on behalf of the property owner.</FlashNote>
<div className="dashed-block" style={{ marginTop: 12 }}>
<div className="row between" style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 13.5 }}>Property Owner Details</strong>
{p.alloeNo && (p.alloeIdOk ? <Badge tone="green"><Icon name="check" size={12} /> Valid</Badge> : <Badge tone="gray">Checking</Badge>)}
</div>
<div className="field">
<label className="label">Owner / Property ID</label>
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder="Alphanumeric ID" />
</div>
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">Owner first name</label><input className="input" value={p.alloeFirst} onChange={(e) => p.setAlloeFirst(e.target.value)} /></div>
<div className="field grow"><label className="label">Owner last name</label><input className="input" value={p.alloeLast} onChange={(e) => p.setAlloeLast(e.target.value)} /></div>
</div>
</div>
</div>
)}
<div className="col gap-2" style={{ marginTop: 16 }}>
<label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
@@ -251,7 +302,9 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account &amp; Verify <Icon name="arrowR" size={16} /></button>
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid || p.creating} onClick={p.onContinue}>
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <Icon name="arrowR" size={16} />}
</button>
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -261,74 +314,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — ALLOTMENT ====================== */
function StepAllotment(p: {
allotment: string; setAllotment: (v: string) => void;
plotVerified: boolean; setPlotVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
const [mode, setMode] = useState<"have" | "forgot">("have");
const [houseNo, setHouseNo] = useState("");
const [street, setStreet] = useState("");
const [city, setCity] = useState("");
const [busy, setBusy] = useState(false);
const fmtOk = /^LUP-\d{6}$/.test(p.allotment);
function findPlot() {
if (!houseNo || !street || !city) return;
setBusy(true);
setTimeout(() => { setBusy(false); p.setPlotVerified(true); }, 800);
}
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Identify the property</h1>
<p className="sub">Find the property by its job ID, or look it up by address.</p>
<div className="seg" style={{ margin: "16px 0" }}>
<button className={mode === "have" ? "on" : ""} onClick={() => setMode("have")}>I have a job ID</button>
<button className={mode === "forgot" ? "on" : ""} onClick={() => setMode("forgot")}>Find by address</button>
</div>
{mode === "have" ? (
<div className="field">
<label className="label">Property / Job ID</label>
<input className="input" value={p.allotment} onChange={(e) => p.setAllotment(e.target.value.toUpperCase())} placeholder="LUP-123456" />
<span className="faint" style={{ fontSize: 12 }}>Format: LUP- followed by 6 digits.</span>
</div>
) : (
<div>
<div className="row gap-3">
<div className="field" style={{ width: 120 }}><label className="label">House no.</label><input className="input" value={houseNo} onChange={(e) => setHouseNo(e.target.value)} placeholder="123" /></div>
<div className="field grow"><label className="label">Street</label><input className="input" value={street} onChange={(e) => setStreet(e.target.value)} placeholder="Street name" /></div>
</div>
<div className="field" style={{ marginTop: 12 }}><label className="label">City / ZIP</label><input className="input" value={city} onChange={(e) => setCity(e.target.value)} placeholder="City or ZIP code" /></div>
{!p.plotVerified ? (
<button className="btn" style={{ marginTop: 12 }} disabled={busy || !(houseNo && street && city)} onClick={findPlot}>
{busy ? "Searching…" : "Find my property"}
</button>
) : (
<div className="conn-banner conn-green" style={{ marginTop: 12 }}>
<Icon name="check" size={16} />
<span style={{ letterSpacing: 1 }}>LUP-****** · Verified · {houseNo} {street}, {city}</span>
</div>
)}
<p className="demo-hint">Sensitive details stay masked until verified (verify-to-reveal).</p>
</div>
)}
{mode === "have" && p.allotment && !fmtOk && <p className="hint-line">Enter a valid ID like LUP-123456.</p>}
<button className="btn btn-primary" style={{ marginTop: 16 }} disabled={!p.valid} onClick={p.onContinue}>Continue <Icon name="arrowR" size={16} /></button>
</div>
);
}
/* ====================== STEP 2 — VERIFY ====================== */
/* ====================== 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;
}) {
@@ -336,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>
@@ -350,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");
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.");
}
}, 700);
}
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>
);
}
@@ -391,49 +395,42 @@ 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" }}>{country.flag} {cc}</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
<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>
)}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div>
);
}
/* ====================== STEP 3 — ADDRESS ====================== */
function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) {
const [sameAs, setSameAs] = useState(true);
function StepAddress({ sameAs, setSameAs, onRegAddr, onMailAddr, onBack, onFinish }: { sameAs: boolean; setSameAs: (v: boolean) => void; onRegAddr: (a: Addr) => void; onMailAddr: (a: Addr) => void; onBack: () => void; onFinish: () => void }) {
return (
<div>
<StepBack onClick={onBack} />
@@ -441,7 +438,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
<p className="sub">Add your registered address and where we should send mail.</p>
<AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" />
<AddressBlock idPrefix="reg" />
<AddressBlock idPrefix="reg" onChange={onRegAddr} />
<label className="check-row" style={{ marginTop: 16 }}>
<input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} />
@@ -451,7 +448,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
{!sameAs && (
<>
<AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" />
<AddressBlock idPrefix="mail" />
<AddressBlock idPrefix="mail" onChange={onMailAddr} />
</>
)}
@@ -476,7 +473,7 @@ function AddrSectionHead({ icon, title, sub, tone }: { icon: string; title: stri
);
}
function AddressBlock({ idPrefix }: { idPrefix: string }) {
function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a: Addr) => void }) {
const [country, setCountry] = useState<AddrCountry>(addressCountries[0]);
const [pin, setPin] = useState("");
const [city, setCity] = useState("");
@@ -489,6 +486,13 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Report the composed address up so the parent can persist it (postalCode is the
// PIN for India, the postal field elsewhere).
useEffect(() => {
onChange?.({ line1, line2, city, state: stateName, postalCode: postal || pin, country: country.code, locality });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [line1, line2, city, stateName, postal, pin, country, locality]);
// India: PIN auto-fill
useEffect(() => {
if (country.lookup !== "pincode" || pin.length !== 6) return;
@@ -598,10 +602,10 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
}
/* ====================== stepper ====================== */
function Stepper({ current }: { current: number }) {
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
return (
<div className="steps">
{STEPS.map((label, i) => {
{labels.map((label, i) => {
const done = i < current, on = i === current;
return (
<div key={label} style={{ display: "contents" }}>
@@ -618,3 +622,14 @@ function Stepper({ current }: { current: number }) {
}
function cap(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); }
// Flag emojis don't render on Windows, so map the emoji's regional-indicator
// code points to an ISO-2 code and use a flag image instead.
function flagUrl(flag: string): string {
const iso = Array.from(flag)
.map((ch) => ch.codePointAt(0))
.filter((cp): cp is number => cp != null && cp >= 0x1f1e6 && cp <= 0x1f1ff)
.map((cp) => String.fromCharCode(cp - 0x1f1e6 + 97))
.join("");
return `https://flagcdn.com/w40/${iso}.png`;
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
/**
* The signed-in user's effective CRM access, from crm.account.me. Drives navigation
* gating: a user with no membership/permissions sees only Dashboard + Profile; members
* see the areas their permissions allow.
*
* Every CRM permission id, granted to superadmins / owners. Used as the mock default
* when the Shell isn't configured (local demo shows everything).
*/
export const ALL_CRM_PERMISSIONS = [
"leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage",
"reports.view", "billing.view", "team.manage", "roles.manage", "settings.manage",
] as const;
export interface MyAccess {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
/** True while the access query is still resolving (nav stays minimal until then). */
loading: boolean;
can: (permission: string) => boolean;
}
interface MeDTO {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
}
function useLiveAccess(): MyAccess {
const { data, loading } = useQuery<MeDTO>("crm.account.me");
const permissions = data?.permissions ?? [];
return {
registered: data?.registered ?? false,
isMember: data?.isMember ?? false,
roleSlugs: data?.roleSlugs ?? [],
permissions,
isSuperadmin: data?.isSuperadmin ?? false,
loading,
can: (p: string) => permissions.includes(p),
};
}
function useMockAccess(): MyAccess {
// No Shell (local demo): show everything so the mock UI is fully browsable.
const permissions = [...ALL_CRM_PERMISSIONS];
return {
registered: true,
isMember: true,
roleSlugs: ["superadmin"],
permissions,
isSuperadmin: true,
loading: false,
can: () => true,
};
}
export const useMyAccess: () => MyAccess = isShellConfigured() ? useLiveAccess : useMockAccess;
+11
View File
@@ -0,0 +1,11 @@
// Integration helpers for appshell-sdk. NEXT_PUBLIC_* vars are inlined at build
// time, so this reflects deploy-time configuration.
/**
* True when the Shell (Supabase BFF) is configured. When false, the app falls
* back to the existing mock portal / static user so the demo keeps working before
* the backend is deployed. Gate real-vs-mock auth on this.
*/
export function isShellConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
}
+121
View File
@@ -0,0 +1,121 @@
// The CRM's InboxAdapter — the SDK <Inbox> rendered over the be-crm data door
// (crm.inbox.* + crm.mail.*). Folds mail threads into the unified inbox exactly as the old
// inbox-api did; the CRM keeps auth/tenancy server-side.
import type {
InboxAdapter,
InboxItem,
InboxState,
MailAttachment,
MailMessage,
MailPerson,
} from "@insignia/iios-messaging-ui";
import type { DataDoor } from "./crm-messaging-adapter";
const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
const EXT_MIME: Record<string, string> = {
md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html", txt: "text/plain", csv: "text/csv",
};
function mimeForFile(file: File): string {
if (file.type) return file.type;
const ext = file.name.toLowerCase().split(".").pop() ?? "";
return EXT_MIME[ext] ?? "application/octet-stream";
}
interface InboxItemDTO {
id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string;
}
interface MailThreadDTO { threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string }
interface MailMessageDTO {
interactionId: string; actorId: string | null; kind: string; occurredAt: string;
html: string | null; text: string | null;
attachment: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null;
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
const escapeHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
export class CrmInboxAdapter implements InboxAdapter {
constructor(private readonly sdk: DataDoor) {}
async listInbox(state?: InboxState): Promise<InboxItem[]> {
const showMail = !state || state === "OPEN";
const [items, mail] = await Promise.all([
this.sdk.query<InboxItemDTO[]>("crm.inbox.list", state ? { state } : {}),
showMail ? this.sdk.query<MailThreadDTO[]>("crm.mail.list", {}) : Promise.resolve([] as MailThreadDTO[]),
]);
const mailItems: InboxItem[] = mail.map((t) => ({
id: `mail:${t.threadId}`,
kind: "MAIL",
state: "OPEN",
title: t.subject || "(no subject)",
...(t.lastMessage ? { summary: t.lastMessage } : {}),
priority: t.unread > 0 ? "HIGH" : "LOW",
threadId: t.threadId,
createdAt: t.lastAt ?? "",
}));
return [...mailItems, ...items].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
}
async transition(id: string, state: InboxState): Promise<void> {
await this.sdk.command("crm.inbox.transition", { id, state });
}
async mailHistory(threadId: string): Promise<MailMessage[]> {
const rows = await this.sdk.query<MailMessageDTO[]>("crm.mail.history", { threadId });
return rows.map((m) => ({
id: m.interactionId,
actorId: m.actorId,
kind: m.kind,
at: m.occurredAt,
html: m.html,
text: m.text,
attachment: m.attachment,
}));
}
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
await this.sdk.command("crm.mail.reply", {
threadId,
content,
...(attachment ? { attachment: { filename: attachment.filename ?? "attachment", contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes } } : {}),
});
}
async uploadAttachment(file: File): Promise<MailAttachment> {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = mimeForFile(file);
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
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 };
}
async downloadAttachment(attachment: MailAttachment): Promise<string> {
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", {
contentRef: attachment.contentRef,
...(attachment.mimeType ? { mime: attachment.mimeType } : {}),
});
return url;
}
async directory(): Promise<MailPerson[]> {
const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
}
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
}
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
await this.sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
}
}
function attachmentsVar(attachments?: MailAttachment[]): { attachments?: Array<{ filename: string; contentRef: string; mimeType: string; sizeBytes: number }> } {
if (!attachments || attachments.length === 0) return {};
return { attachments: attachments.map((a) => ({ filename: a.filename ?? "attachment", contentRef: a.contentRef, mimeType: a.mimeType, sizeBytes: a.sizeBytes })) };
}
+392
View File
@@ -0,0 +1,392 @@
// The CRM's implementation of the SDK's MessagingAdapter. HYBRID transport:
// • BFF (appshell crm.messenger.*) for the conversation list, thread creation, and directory
// — these need server-side tenancy/auth.
// • IIOS MessageSocket (delegated token from crm.messenger.realtime) for everything live:
// history+join, send, typing, read receipts, reactions.
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
import type {
Attachment,
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
MessagingAdapter,
Person,
Reaction,
SendOpts,
Unsubscribe,
} from "@insignia/iios-messaging-ui";
import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client";
/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */
export interface DataDoor {
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
}
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; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null }
const POLL_MS = 4000;
const REACTION = "reaction";
interface Poll { seen: Set<string>; primed: boolean; timer: ReturnType<typeof setInterval> | null }
export class CrmMessagingAdapter implements MessagingAdapter {
private names: Map<string, string> | null = null;
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
private readonly polls = new Map<string, Poll>();
private readonly joined = new Set<string>();
/** Cross-thread activity listeners (live unread + in-app notifications). */
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
private readonly reactions = new Map<string, Map<string, Set<string>>>();
/** Only present with a socket — the UI hides the reaction affordance without it. */
react?: (threadId: string, messageId: string, emoji: string) => Promise<void>;
constructor(
private readonly sdk: DataDoor,
private readonly me: string,
private readonly socket?: MessageSocket,
) {
if (socket) {
socket.on("message", (m) => {
this.ingestReactions(m);
void this.toKernelMessage(m).then((message) => {
this.emit(m.threadId, { kind: "message", message });
for (const cb of this.activity) cb({ threadId: m.threadId, message });
});
});
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
socket.on("receipt", (e) => this.broadcast({ kind: "receipt", messageId: e.interactionId, actorId: e.actorId }));
socket.on("annotation", (e) => {
if (e.type !== REACTION) return;
this.setReactionUsers(e.interactionId, e.value, e.users);
this.emit(e.threadId, { kind: "reaction", messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
});
this.react = async (threadId, messageId, emoji) => {
await socket.react(threadId, messageId, emoji);
};
}
}
currentActorId(): string {
return this.me;
}
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
setFocus(threadId: string | null): void {
this.socket?.focus(threadId);
}
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
this.activity.add(cb);
void this.joinAllThreads();
return () => {
this.activity.delete(cb);
};
}
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
* open one. Idempotent (the `joined` set guards re-joins). */
private async joinAllThreads(): Promise<void> {
if (!this.socket) return;
try {
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
for (const c of convs) {
if (!this.joined.has(c.threadId)) {
this.joined.add(c.threadId);
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
}
}
} catch {
/* best-effort — activity just won't cover un-joined threads */
}
}
async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
this.directoryMap(),
]);
return convs.map((c) => this.toConversation(c, names));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
participantIds: p.participantIds,
...(p.membership ? { membership: p.membership } : {}),
...(p.subject ? { subject: p.subject } : {}),
});
return { threadId: res.threadId };
}
async history(threadId: string): Promise<Message[]> {
if (this.socket) {
const res = await this.socket.openThread(threadId); // joins so live events flow
this.joined.add(threadId);
return Promise.all(
res.history.map((m) => {
this.ingestReactions(m);
return this.toKernelMessage(m);
}),
);
}
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const att = opts?.attachment;
if (this.socket) {
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}),
};
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
const msg = this.fromKernel(m);
// Reuse the staged attachment (already carries a display URL from upload) for instant render.
return att ? { ...msg, attachment: att } : msg;
}
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
const msg = this.fromDto(m);
this.polls.get(threadId)?.seen.add(msg.id);
return att ? { ...msg, attachment: att } : msg;
}
async upload(file: File): Promise<Attachment> {
const mime = file.type || "application/octet-stream";
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
const url = await this.downloadUrl(objectKey, mime);
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
if (this.socket) {
if (!this.joined.has(threadId)) {
this.joined.add(threadId);
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
}
} else {
this.startPoll(threadId);
}
return () => {
const set = this.listeners.get(threadId);
set?.delete(cb);
if (set && set.size === 0) {
this.listeners.delete(threadId);
const poll = this.polls.get(threadId);
if (poll?.timer) clearInterval(poll.timer);
this.polls.delete(threadId);
}
};
}
sendTyping(threadId: string): void {
this.socket?.typing(threadId);
}
async markRead(threadId: string, messageId: string): Promise<void> {
if (this.socket) await this.socket.markRead(threadId, messageId);
}
// ── channels + members (BFF, except join which is a governed socket self-join) ──
async browseChannels(): Promise<ChannelSummary[]> {
const rows = await this.sdk.query<Array<{ threadId: string; name: string; topic: string | null; visibility: string; memberCount: number; joined: boolean }>>(
"crm.messenger.channel.browse",
{},
);
return rows.map((c) => ({
threadId: c.threadId,
name: c.name,
topic: c.topic,
visibility: (c.visibility === "private" ? "private" : "public") as ChannelVisibility,
memberCount: c.memberCount,
joined: c.joined,
}));
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
return this.sdk.command<{ threadId: string }>("crm.messenger.channel.create", {
name: input.name,
...(input.topic ? { topic: input.topic } : {}),
visibility: input.visibility,
});
}
async joinChannel(threadId: string): Promise<void> {
// Governed public self-join over the socket (the BFF has no join verb; OPA enforces it).
if (!this.socket) throw new Error("joining a channel needs a live connection");
await this.socket.openThread(threadId);
this.joined.add(threadId);
}
async leaveChannel(threadId: string): Promise<void> {
await this.sdk.command("crm.messenger.channel.leave", { threadId });
}
async addMember(threadId: string, userId: string): Promise<void> {
await this.sdk.command("crm.messenger.participant.add", { threadId, userId });
}
async removeMember(threadId: string, userId: string): Promise<void> {
await this.sdk.command("crm.messenger.participant.remove", { threadId, userId });
}
async renameConversation(threadId: string, subject: string): Promise<void> {
await this.sdk.command("crm.messenger.group.rename", { threadId, subject });
}
async listMembers(threadId: string): Promise<Person[]> {
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: r.role === "CUSTOMER" ? "customer" : "staff" }));
}
// ── polling fallback (no socket) ───────────────────────────────
private startPoll(threadId: string): void {
if (this.polls.has(threadId)) return;
const poll: Poll = { seen: new Set(), primed: false, timer: null };
this.polls.set(threadId, poll);
const tick = async (): Promise<void> => {
if (!this.polls.has(threadId)) return;
try {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
for (const m of msgs) {
if (poll.seen.has(m.interactionId)) continue;
poll.seen.add(m.interactionId);
if (poll.primed) this.emit(threadId, { kind: "message", message: this.fromDto(m) });
}
poll.primed = true;
} catch {
/* transient — retry next tick */
}
};
void tick();
poll.timer = setInterval(tick, POLL_MS);
}
/** The org directory — people you can start a DM/group with. Drives the "New message" picker. */
async directory(): Promise<Person[]> {
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
}
// ── mapping ────────────────────────────────────────────────────
private async directoryMap(): Promise<Map<string, string>> {
if (!this.names) {
this.names = new Map((await this.directory()).map((p) => [p.id, p.name]));
}
return this.names;
}
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
const others = c.participants.filter((p) => p !== this.me);
const title = c.subject?.trim() || others.map((id) => names.get(id) ?? id).join(", ") || "Conversation";
return {
threadId: c.threadId,
title,
subject: c.subject,
membership: c.membership,
participants: c.participants,
unread: c.unread,
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
...(c.lastAt ? { lastAt: c.lastAt } : {}),
};
}
/** Kernel Message (socket) → SDK Message. actorId = senderId (userId space), matching currentActorId. */
private fromKernel(m: KernelMessage): Message {
return {
id: m.id,
actorId: m.senderId ?? null,
text: m.content ?? "",
at: m.createdAt,
parentInteractionId: m.parentInteractionId ?? null,
reactions: this.reactionsOf(m.id),
};
}
/** BFF DTO (poll fallback) → SDK Message. Note: actorId is IIOS actor-id space here. */
private fromDto(m: MessageDTO): Message {
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
}
// ── attachments ────────────────────────────────────────────────
/** A short-lived signed URL to display/download a stored object. */
private async downloadUrl(contentRef: string, mime?: string): Promise<string> {
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) });
return url;
}
private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise<Attachment | undefined> {
if (!a?.contentRef) return undefined;
const url = await this.downloadUrl(a.contentRef, a.mimeType);
return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes };
}
private async toKernelMessage(m: KernelMessage): Promise<Message> {
const base = this.fromKernel(m);
const att = await this.resolveAttachment(m.attachment ?? null);
return att ? { ...base, attachment: att } : base;
}
private async toDtoMessage(m: MessageDTO): Promise<Message> {
const base = this.fromDto(m);
const att = await this.resolveAttachment(m.attachment ?? null);
return att ? { ...base, attachment: att } : base;
}
// ── reaction state ─────────────────────────────────────────────
private ingestReactions(m: KernelMessage): void {
for (const a of m.annotations ?? []) {
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
}
}
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
let byEmoji = this.reactions.get(messageId);
if (!byEmoji) {
byEmoji = new Map();
this.reactions.set(messageId, byEmoji);
}
if (users.length === 0) byEmoji.delete(emoji);
else byEmoji.set(emoji, new Set(users));
}
private reactionsOf(messageId: string): Reaction[] {
const byEmoji = this.reactions.get(messageId);
if (!byEmoji) return [];
const out: Reaction[] = [];
for (const [emoji, users] of byEmoji) {
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
}
return out;
}
// ── event fan-out ──────────────────────────────────────────────
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
private broadcast(e: MessageEvent): void {
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
}
}
+734
View File
@@ -0,0 +1,734 @@
"use client";
/**
* The CRM's AIProvider for the Smart Gallery.
*
* PROVENANCE: a port of the SDK demo's `createDemoAIProvider()` plus its
* clip/face/ocr/tensorflow/runpod-yolo providers and `imageEncode.ts` helpers
* (advance-photo-gallery-web-sdk/apps/web/src/lib/ai/*), collapsed into one
* module and retargeted from `/api/ai/*` to `/api/gallery/ai/*`.
*
* Capability split:
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key), or the
* RunPod YOLO classifier via /api/gallery/ai/classify when
* NEXT_PUBLIC_APG_RUNPOD_DETECT=true (COCO-SSD is the automatic fallback)
* - face detection + recognition: face-api.js in-browser clustered into People
* - OCR: tesseract.js in-browser searchable text + the Documents album
* - semantic search: CLIP via transformers.js in-browser
* - background removal: @imgly in-browser WASM, or the RunPod U²-Net endpoint
* when NEXT_PUBLIC_APG_RUNPOD_BG=true (in-browser is the fallback)
* - other generative edits / transcription / denoise / tilt: proxied through the
* server routes so the RunPod key never reaches the browser
*
* EVERY heavy model is behind `await import(...)` so none of it lands in the
* initial bundle, and every capability degrades to []/''/null with a
* console.warn rather than throwing a failed model must never break the
* gallery UI.
*
* The in-browser models fetch weights from public CDNs (jsdelivr, huggingface,
* storage.googleapis.com, staticimgly.com). See docs/SMART_GALLERY.md for the
* list that would need CSP allow-listing.
*/
import type { AIProvider, GenerativeEditOp, MediaItem } from "@photo-gallery/sdk";
// Derived from the provider interface so we import only the three public types.
type DetectedObject = Awaited<ReturnType<NonNullable<AIProvider["detectObjects"]>>>[number];
type DetectedFace = Awaited<ReturnType<NonNullable<AIProvider["detectFaces"]>>>[number];
type ImageSource = ImageBitmap | HTMLImageElement;
const API = "/api/gallery/ai";
// ---------------------------------------------------------------------------
// imageEncode helpers (ported from apps/web/src/lib/ai/imageEncode.ts)
// ---------------------------------------------------------------------------
export interface EncodedImage {
/** base64 JPEG (no data: prefix). */
data: string;
mimeType: string;
/** Actual pixel dims of the encoded image (after downscale). */
width: number;
height: number;
}
/** Draw an image to a downscaled canvas and return base64 JPEG + its dims. */
export function imageToBase64(image: ImageSource, maxDim: number): EncodedImage {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported.");
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
return { data: dataUrl.split(",")[1] ?? "", mimeType: "image/jpeg", width: cw, height: ch };
}
/**
* Rasterize a mask (ImageData, white = region to regenerate) to a PNG base64
* scaled to targetW×targetH so it matches the encoded image exactly SD 3.5
* requires image and mask to be identical pixel sizes.
*/
export function maskToBase64(mask: ImageData, targetW: number, targetH: number): string {
const tmp = document.createElement("canvas");
tmp.width = mask.width;
tmp.height = mask.height;
const tctx = tmp.getContext("2d");
if (!tctx) throw new Error("Canvas not supported.");
tctx.putImageData(mask, 0, 0);
const out = document.createElement("canvas");
out.width = targetW;
out.height = targetH;
const octx = out.getContext("2d");
if (!octx) throw new Error("Canvas not supported.");
// Nearest-neighbour, not bilinear — keep the mask strictly binary so SD gets
// crisp white(regenerate)/black(keep) edges instead of an anti-aliased grey halo.
octx.imageSmoothingEnabled = false;
octx.drawImage(tmp, 0, 0, targetW, targetH);
return out.toDataURL("image/png").split(",")[1] ?? "";
}
export function base64ToBlob(base64: string, mime: string): Blob {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Blob([bytes], { type: mime });
}
/**
* Pad an image with a neutral border for outpaint and return {imageBase64, maskBase64}
* as base64 PNG the border is WHITE in the mask (regenerate), the original image
* area BLACK (keep). Capped at 1280px on the long side.
*/
export function padForOutpaint(
image: ImageSource,
factor: number,
): { imageBase64: string; maskBase64: string } {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const f = Math.max(1.1, Math.min(2, factor));
const maxDim = 1280;
let pw = Math.round(w * f);
let ph = Math.round(h * f);
const scale = Math.min(1, maxDim / Math.max(pw, ph));
pw = Math.max(16, Math.round(pw * scale));
ph = Math.max(16, Math.round(ph * scale));
const iw = Math.max(1, Math.round(w * scale));
const ih = Math.max(1, Math.round(h * scale));
const ox = Math.floor((pw - iw) / 2);
const oy = Math.floor((ph - ih) / 2);
const imgCanvas = document.createElement("canvas");
imgCanvas.width = pw;
imgCanvas.height = ph;
const ictx = imgCanvas.getContext("2d");
if (!ictx) throw new Error("Canvas not supported.");
// Fill the new border with a blurred, stretched copy of the photo so the model
// has real color/context to continue from — flat gray gives it nothing.
ictx.filter = "blur(28px)";
ictx.drawImage(image as CanvasImageSource, 0, 0, pw, ph);
ictx.filter = "none";
ictx.drawImage(image as CanvasImageSource, ox, oy, iw, ih);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = pw;
maskCanvas.height = ph;
const mctx = maskCanvas.getContext("2d");
if (!mctx) throw new Error("Canvas not supported.");
mctx.fillStyle = "#ffffff";
mctx.fillRect(0, 0, pw, ph);
mctx.fillStyle = "#000000";
mctx.fillRect(ox, oy, iw, ih);
return {
imageBase64: imgCanvas.toDataURL("image/png").split(",")[1] ?? "",
maskBase64: maskCanvas.toDataURL("image/png").split(",")[1] ?? "",
};
}
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
function canvasBlob(image: ImageSource, maxDim: number): Promise<Blob> {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const cw = Math.max(1, Math.round(w * scale));
const ch = Math.max(1, Math.round(h * scale));
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
if (!ctx) return Promise.reject(new Error("Canvas not supported."));
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
return new Promise((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92),
);
}
function clamp01(n: number): number {
return n < 0 ? 0 : n > 1 ? 1 : n;
}
// ---------------------------------------------------------------------------
// Object detection — TensorFlow.js COCO-SSD, in-browser
// ---------------------------------------------------------------------------
interface CocoPrediction {
bbox: [number, number, number, number];
class: string;
score: number;
}
interface CocoModel {
detect(
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
maxNumBoxes?: number,
minScore?: number,
): Promise<CocoPrediction[]>;
}
let cocoPromise: Promise<CocoModel | null> | null = null;
/** Load tfjs + COCO-SSD exactly once; resolves to null if anything fails. */
function ensureCoco(): Promise<CocoModel | null> {
cocoPromise ??= (async () => {
try {
const tf = await import("@tensorflow/tfjs");
try {
await tf.setBackend("webgl");
} catch {
// Fall back to the default backend if WebGL is unavailable.
}
await tf.ready();
const cocoSsd = await import("@tensorflow-models/coco-ssd");
return (await cocoSsd.load({ base: "lite_mobilenet_v2" })) as unknown as CocoModel;
} catch (err) {
console.warn("[gallery-ai] COCO-SSD load failed; object detection disabled.", err);
return null;
}
})();
return cocoPromise;
}
async function detectObjectsInBrowser(
item: MediaItem,
image: ImageSource,
): Promise<DetectedObject[]> {
const model = await ensureCoco();
if (!model) return [];
try {
const el = image as HTMLImageElement;
const w = el.naturalWidth || el.width || item.width || 1;
const h = el.naturalHeight || el.height || item.height || 1;
const predictions = await model.detect(el, 20, 0.4);
return predictions.map((p) => ({
label: p.class,
confidence: p.score,
box: { x: p.bbox[0] / w, y: p.bbox[1] / h, width: p.bbox[2] / w, height: p.bbox[3] / h },
})) as DetectedObject[];
} catch (err) {
console.warn("[gallery-ai] object detection failed.", err);
return [];
}
}
/**
* Server-side YOLO detection via /api/gallery/ai/classify, with the in-browser
* COCO-SSD as the automatic fallback so detection never hard-fails.
*/
async function detectObjectsViaRunpod(
item: MediaItem,
image: ImageSource,
): Promise<DetectedObject[]> {
try {
const { data, mimeType, width, height } = imageToBase64(image, 1280);
const res = await fetch(`${API}/classify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ imageBase64: data, mimeType, width, height }),
});
if (!res.ok) throw new Error(`classify failed (${res.status})`);
const { objects } = (await res.json()) as { objects?: DetectedObject[] };
if (Array.isArray(objects)) return objects;
throw new Error("classify returned no objects");
} catch (err) {
console.warn("[gallery-ai] RunPod detection failed; falling back to COCO-SSD.", err);
return detectObjectsInBrowser(item, image);
}
}
// ---------------------------------------------------------------------------
// Faces — @vladmandic/face-api, in-browser (128-D descriptors → People)
// ---------------------------------------------------------------------------
const FACE_MODEL_URL = "https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model";
type FaceApi = typeof import("@vladmandic/face-api");
let facePromise: Promise<FaceApi | null> | null = null;
function ensureFaceModels(): Promise<FaceApi | null> {
facePromise ??= (async () => {
try {
const faceapi = await import("@vladmandic/face-api");
// The bundled tf re-export is typed narrowly; backend control lives on the
// runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully.
const tf = faceapi.tf as unknown as {
setBackend: (b: string) => Promise<boolean>;
ready: () => Promise<void>;
};
try {
await tf.setBackend("webgl");
} catch {
/* keep default backend */
}
await tf.ready();
await Promise.all([
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL),
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_URL),
]);
return faceapi;
} catch (err) {
// Degrade gracefully — People simply stays empty if models can't load.
console.warn("[gallery-ai] face model load failed; face clustering disabled.", err);
return null;
}
})();
return facePromise;
}
let faceWarned = false;
async function detectFaces(item: MediaItem, image: ImageSource): Promise<DetectedFace[]> {
const faceapi = await ensureFaceModels();
if (!faceapi) return [];
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1;
type TNetInput = Parameters<FaceApi["detectAllFaces"]>[0];
let results;
try {
results = await faceapi
.detectAllFaces(
image as unknown as TNetInput,
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }),
)
.withFaceLandmarks()
.withFaceDescriptors();
} catch (err) {
if (!faceWarned) {
faceWarned = true;
console.warn("[gallery-ai] face detection failed on", item.name, err);
}
return [];
}
return results.map((r) => {
const b = r.detection.box;
return {
confidence: r.detection.score,
box: {
x: clamp01(b.x / w),
y: clamp01(b.y / h),
width: clamp01(b.width / w),
height: clamp01(b.height / h),
},
embedding: Array.from(r.descriptor),
};
}) as DetectedFace[];
}
// ---------------------------------------------------------------------------
// OCR — tesseract.js, in-browser
// ---------------------------------------------------------------------------
// Must equal the EXACT tesseract.js version in package.json (pinned, no caret)
// so the worker CDN URL can never drift from the installed main-thread code.
const TESSERACT_VERSION = "5.1.1";
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${TESSERACT_VERSION}/dist/worker.min.js`;
const CORE_PATH = "https://cdn.jsdelivr.net/npm/tesseract.js-core@5";
// jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com),
// so every asset comes from ONE host that a CSP can allow-list.
const LANG_PATH = "https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0";
interface OcrWord {
text?: string;
confidence?: number;
}
interface OcrData {
text?: string;
confidence?: number;
words?: OcrWord[];
blocks?: Array<{ paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }> }> | null;
}
type TesseractWorker = import("tesseract.js").Worker;
let ocrWorkerPromise: Promise<TesseractWorker | null> | null = null;
function ensureOcrWorker(): Promise<TesseractWorker | null> {
ocrWorkerPromise ??= (async () => {
try {
const { createWorker } = await import("tesseract.js");
// v5: createWorker(langs, oem, options) already loads + initializes the
// language internally — do NOT call the removed v4 worker.load().
return await createWorker("eng", 1, {
workerPath: WORKER_PATH,
corePath: CORE_PATH,
langPath: LANG_PATH,
});
} catch (err) {
console.warn("[gallery-ai] tesseract worker init failed; OCR disabled.", err);
return null;
}
})();
return ocrWorkerPromise;
}
const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about
const MIN_WORDS = 4; // need several confident words to call it a document
const MIN_CHARS = 10;
function collectWords(data: OcrData): OcrWord[] {
if (Array.isArray(data.words) && data.words.length) return data.words;
const out: OcrWord[] = [];
for (const b of data.blocks ?? [])
for (const p of b.paragraphs ?? [])
for (const l of p.lines ?? []) for (const w of l.words ?? []) out.push(w);
return out;
}
/**
* Return real text or '' (not a document). tesseract hallucinates low-confidence
* gibberish for photos with no text, so we keep only high-confidence, word-shaped
* tokens and require several of them.
*/
function meaningfulText(data: OcrData): string {
const words = collectWords(data);
if (words.length > 0) {
const good = words.filter(
(w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ""),
);
const text = good
.map((w) => (w.text ?? "").trim())
.filter(Boolean)
.join(" ")
.trim();
return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : "";
}
// Fallback: overall confidence + count of word-shaped tokens.
const raw = (data.text ?? "").trim();
const conf = typeof data.confidence === "number" ? data.confidence : 0;
const realWords = raw.match(/[A-Za-z]{3,}/g) ?? [];
return conf >= 72 && realWords.length >= 6 ? raw : "";
}
async function ocr(_item: MediaItem, image: ImageSource): Promise<string> {
const worker = await ensureOcrWorker();
if (!worker) return "";
try {
// Request the block hierarchy so per-word confidence is available.
const { data } = (await worker.recognize(
image as unknown as HTMLImageElement,
{},
{ text: true, blocks: true },
)) as { data: OcrData };
return meaningfulText(data);
} catch (err) {
console.warn("[gallery-ai] OCR failed.", err);
return "";
}
}
// ---------------------------------------------------------------------------
// Semantic search — CLIP via transformers.js (ONNX-WASM), in-browser
// ---------------------------------------------------------------------------
const CLIP_MODEL_ID = "Xenova/clip-vit-base-patch16";
type Transformers = typeof import("@huggingface/transformers");
let transformersMod: Transformers | null = null;
/* eslint-disable @typescript-eslint/no-explicit-any -- transformers.js pipelines are untyped */
let clipVisionPromise: Promise<{ processor: any; model: any } | null> | null = null;
let clipTextPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
async function loadTransformers(): Promise<Transformers> {
if (!transformersMod) {
transformersMod = await import("@huggingface/transformers");
// Remote-only (models from the HF CDN); rely on browser cache between sessions.
transformersMod.env.allowLocalModels = false;
}
return transformersMod;
}
function ensureClipVision() {
clipVisionPromise ??= (async () => {
try {
const tf = await loadTransformers();
const [processor, model] = await Promise.all([
tf.AutoProcessor.from_pretrained(CLIP_MODEL_ID),
tf.CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL_ID),
]);
return { processor, model };
} catch (err) {
console.warn("[gallery-ai] CLIP vision load failed; semantic search disabled.", err);
return null;
}
})();
return clipVisionPromise;
}
function ensureClipText() {
clipTextPromise ??= (async () => {
try {
const tf = await loadTransformers();
const [tokenizer, model] = await Promise.all([
tf.AutoTokenizer.from_pretrained(CLIP_MODEL_ID),
tf.CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL_ID),
]);
return { tokenizer, model };
} catch (err) {
console.warn("[gallery-ai] CLIP text load failed; semantic search disabled.", err);
return null;
}
})();
return clipTextPromise;
}
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
function toCanvas(image: ImageSource, maxDim = 384): HTMLCanvasElement {
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
const scale = Math.min(1, maxDim / Math.max(w, h));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas not supported.");
ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
return canvas;
}
function tensorToArray(t: any): number[] {
const data: Float32Array = t?.data ?? t;
return Array.from(data as ArrayLike<number>);
}
async function embedImage(_item: MediaItem, image: ImageSource): Promise<number[]> {
const v = await ensureClipVision();
if (!v) return [];
try {
const tf = await loadTransformers();
const canvas = toCanvas(image);
const ctx = canvas.getContext("2d");
if (!ctx) return [];
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb();
const inputs = await v.processor(raw);
const out = await v.model(inputs);
return tensorToArray(out.image_embeds);
} catch (err) {
console.warn("[gallery-ai] embedImage failed.", err);
return [];
}
}
async function embedText(query: string): Promise<number[]> {
const t = await ensureClipText();
if (!t) return [];
try {
const inputs = t.tokenizer([query], { padding: true, truncation: true });
const out = await t.model(inputs);
return tensorToArray(out.text_embeds);
} catch (err) {
console.warn("[gallery-ai] embedText failed.", err);
return [];
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
// ---------------------------------------------------------------------------
// The provider
// ---------------------------------------------------------------------------
/** `true` only for the literal string "true", matching the SDK demo's semantics. */
function flag(v: string | undefined): boolean {
return v === "true";
}
export function createCrmAIProvider(): AIProvider {
// NEXT_PUBLIC_* are inlined at build time, so these must be read as full
// static member expressions — do NOT refactor to dynamic indexing.
const useRunpodDetect = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT);
const useRunpodBg = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_BG);
const useRunpodTilt = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_TILT);
return {
name: "crm-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)",
detectObjects: useRunpodDetect ? detectObjectsViaRunpod : detectObjectsInBrowser,
detectFaces,
ocr,
embedImage,
embedText,
async generativeEdit(item: MediaItem, image: ImageSource, op: GenerativeEditOp) {
// Remove Background runs fully in-browser (no key) via @imgly — works even
// with no backend. Other ops go through the /api/gallery/ai/edit route.
if (op.type === "remove-background") {
// Prefer the RunPod U²-Net endpoint when enabled; fall back to in-browser
// @imgly if it's off or the request fails, so this always produces a result.
if (useRunpodBg) {
try {
const { data, mimeType } = imageToBase64(image, 1600);
const res = await fetch(`${API}/edit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
imageBase64: data,
mimeType,
op: { type: "remove-background" },
params: {},
}),
});
if (res.ok) {
const { imageBase64: out, mimeType: outMime } = (await res.json()) as {
imageBase64: string;
mimeType?: string;
};
return base64ToBlob(out, outMime || "image/png");
}
} catch {
/* fall through to the in-browser remover */
}
}
const inputBlob = await canvasBlob(image, 1600);
const { removeBackground } = await import("@imgly/background-removal");
return removeBackground(inputBlob, { output: { format: "image/png" } });
}
// Outpaint / expand-canvas: pad the image with a neutral border, mark that
// border WHITE in the mask, and run it through the same inpaint path as
// generative-fill — no extra backend route needed.
if (op.type === "outpaint") {
const { imageBase64: padded, maskBase64: border } = padForOutpaint(
image,
typeof op.factor === "number" ? op.factor : 1.5,
);
const outParams: Record<string, unknown> = {
strength: typeof op.strength === "number" ? op.strength : 0.85,
};
const outRes = await fetch(`${API}/edit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
imageBase64: padded,
mimeType: "image/png",
op: {
type: "generative-fill",
prompt:
op.prompt ||
"Extend and continue the scene naturally, matching lighting, colors and perspective.",
},
maskBase64: border,
params: outParams,
}),
});
if (!outRes.ok) {
const err = (await outRes.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `AI request failed (${outRes.status}).`);
}
const outJson = (await outRes.json()) as { imageBase64: string; mimeType?: string };
return base64ToBlob(outJson.imageBase64, outJson.mimeType || "image/png");
}
const { data, mimeType, width, height } = imageToBase64(image, 1280);
// Masked ops carry an ImageData mask — rasterize it to a PNG matched to the
// (downscaled) image dims, and strip it from the op since ImageData is not
// JSON-serializable.
const maskBase64 = "mask" in op ? maskToBase64(op.mask, width, height) : undefined;
const wireOp: Record<string, unknown> = { type: op.type };
if ("prompt" in op && typeof op.prompt === "string") wireOp.prompt = op.prompt;
if ("factor" in op && typeof op.factor === "number") wireOp.factor = op.factor;
// Forward the "edit strength" slider (0..1) so the backend can scale the edit.
const params: Record<string, unknown> = {};
if ("strength" in op && typeof op.strength === "number") params.strength = op.strength;
const res = await fetch(`${API}/edit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ imageBase64: data, mimeType, op: wireOp, maskBase64, params }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `AI request failed (${res.status}).`);
}
const { imageBase64, mimeType: outMime } = (await res.json()) as {
imageBase64: string;
mimeType?: string;
};
return base64ToBlob(imageBase64, outMime || "image/png");
},
// Voice annotation: record → (optional denoise) → transcribe. Both proxy
// through server routes so the RunPod key stays server-side.
async transcribeAudio(audioBase64: string) {
const res = await fetch(`${API}/transcribe`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audio: audioBase64 }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `Transcription failed (${res.status}).`);
}
const { transcript } = (await res.json()) as { transcript?: string };
return (transcript ?? "").trim();
},
async denoiseAudio(audioBase64: string) {
const res = await fetch(`${API}/denoise`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audio: audioBase64 }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `Denoise failed (${res.status}).`);
}
const { audio } = (await res.json()) as { audio?: string };
return audio ?? audioBase64;
},
// Camera-tilt estimation is opt-in (needs the RunPod tilt endpoint deployed);
// gate it so the editor's Auto-straighten button only appears when configured.
estimateTilt: useRunpodTilt
? async (_item: MediaItem, image: ImageSource) => {
const { data } = imageToBase64(image, 1024);
const res = await fetch(`${API}/tilt`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ image: data }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(err.error || `Tilt estimate failed (${res.status}).`);
}
return (await res.json()) as {
rollDegrees: number;
pitchDegrees: number;
fovDegrees: number;
};
}
: undefined,
};
}
+418
View File
@@ -0,0 +1,418 @@
"use client";
// Smart Gallery data layer. Serves EITHER a device-local mock (when the Shell isn't configured — the
// demo keeps working offline) OR the live be-crm data door (crm.gallery.*), behind one StorageAdapter
// so the embedded @photo-gallery/sdk is mode-agnostic. Tenant scoping, comment authorship and byte
// authorization are all enforced server-side; this is just glue.
//
// Live contract (be-crm):
// query crm.gallery.state.load {} -> PersistedState
// cmd crm.gallery.state.apply StateChanges -> { upserted, removed }
// cmd crm.gallery.media.presignUpload { mediaId, mime, sizeBytes, filename? } -> { ref, uploadUrl, method, headers? }
// cmd crm.gallery.media.presignDownload { refs: string[] } -> { urls, expiresInSeconds }
// query crm.gallery.stats {} -> { items, albums, people, bytes }
// query crm.gallery.lock.status {} -> { hasPassword }
// cmd crm.gallery.lock.set { password: string | null } -> { hasPassword }
// cmd crm.gallery.lock.verify { password: string } -> { ok }
//
// BYTES NEVER PASS THROUGH be-crm OR THE BFF. `putMedia` mints a short-lived signed PUT URL and the
// browser transfers straight to object storage — the same rule media-api.ts follows for attachments.
import { useMemo } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import {
createLocalStorageAdapter,
type GalleryFeatures,
type GalleryUser,
type MediaItem,
type PersistedState,
type StateChanges,
type StorageAdapter,
type StoredBlob,
type ThemeTokens,
} from "@photo-gallery/sdk";
import { user as demoUser } from "@/components/dashboard/account-data";
import { useMyAccess } from "@/lib/access";
import { isShellConfigured } from "./appshell";
const SHELL = isShellConfigured();
/** Device-local store key for demo mode. Namespaced so it can't collide with the SDK's own demo. */
const LOCAL_STORE_KEY = "lup:smart-gallery:v1";
/** be-crm caps a single presignDownload at 500 refs — chunk anything larger. */
const PRESIGN_CHUNK = 500;
/** Largest single upload the gallery will attempt (matches be-crm's gallery cap). */
export const MAX_GALLERY_BYTES = 200 * 1024 * 1024;
/* ======================================================================== */
/* Wire types (be-crm shapes) */
/* ======================================================================== */
interface StateLoadDTO {
media: MediaItem[];
albums: PersistedState["albums"];
people: PersistedState["people"];
labelAliases?: Record<string, string>;
deletedLabels?: string[];
}
interface PresignUploadDTO {
ref: string;
uploadUrl: string;
method: "PUT";
headers?: Record<string, string>;
}
interface PresignDownloadDTO {
urls: Record<string, string>;
expiresInSeconds: number;
}
/** The subset of the AppShell SDK this module needs — keeps the adapter unit-testable. */
interface DataDoor {
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
}
/* ======================================================================== */
/* The live adapter — be-crm data door */
/* ======================================================================== */
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
/**
* A StorageAdapter backed by the be-crm data door.
*
* Metadata (media/albums/people) rides the door as JSON; bytes go direct to object storage via
* short-lived signed URLs. `storageRef` is the durable handle we persist `src` is only ever a
* signed URL with a TTL, so it is re-resolved from the refs on every `load()`.
*/
export function createDataDoorAdapter(sdk: DataDoor): StorageAdapter {
/** Resolve durable refs → fresh signed GET URLs, in chunks the door will accept. */
async function resolveRefs(refs: string[]): Promise<Record<string, string>> {
const unique = [...new Set(refs.filter(Boolean))];
if (!unique.length) return {};
const urls: Record<string, string> = {};
for (const group of chunk(unique, PRESIGN_CHUNK)) {
const res = await sdk.command<PresignDownloadDTO>("crm.gallery.media.presignDownload", { refs: group });
Object.assign(urls, res.urls ?? {});
}
return urls;
}
return {
name: "crm-data-door",
async load(): Promise<PersistedState | null> {
const state = await sdk.query<StateLoadDTO>("crm.gallery.state.load", {});
const media = state.media ?? [];
// Signed URLs expire, so `src` is always rebuilt from `storageRef` at load time. Items with no
// ref (e.g. a seeded remote URL) keep whatever `src` they were stored with.
const urls = await resolveRefs(media.map((m) => m.storageRef ?? "").filter(Boolean));
return {
media: media.map((m) => (m.storageRef && urls[m.storageRef] ? { ...m, src: urls[m.storageRef]! } : m)),
albums: state.albums ?? [],
people: state.people ?? [],
labelAliases: state.labelAliases ?? {},
deletedLabels: state.deletedLabels ?? [],
version: 1,
};
},
// Incremental sync is the real path (see applyChanges). `save` only runs if the store ever falls
// back to whole-state persistence; express it as one big change set so behaviour is identical.
async save(state: PersistedState): Promise<void> {
await sdk.command("crm.gallery.state.apply", {
upsertMedia: state.media,
upsertAlbums: state.albums,
upsertPeople: state.people,
labelAliases: state.labelAliases ?? {},
deletedLabels: state.deletedLabels ?? [],
});
},
/**
* Incremental persistence only the entities that actually changed. This is what makes a shared
* tenant library safe for concurrent editors: two people touching different photos never clobber
* each other, because neither sends the other's rows.
*/
async applyChanges(changes: StateChanges): Promise<void> {
// StateChanges is a closed interface; the door takes an open variables bag.
await sdk.command("crm.gallery.state.apply", { ...changes });
},
/** Presign → direct PUT → resolve a display URL. Bytes never touch be-crm or the BFF. */
async putMedia(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob> {
if (blob.size > MAX_GALLERY_BYTES) {
throw new Error(`File is too large (max ${Math.floor(MAX_GALLERY_BYTES / (1024 * 1024))} MB).`);
}
const mime = meta.mime || blob.type || "application/octet-stream";
const presigned = await sdk.command<PresignUploadDTO>("crm.gallery.media.presignUpload", {
mediaId: id,
mime,
sizeBytes: blob.size,
filename: meta.name,
});
const res = await fetch(presigned.uploadUrl, {
method: presigned.method ?? "PUT",
headers: { "content-type": mime, ...(presigned.headers ?? {}) },
body: blob,
});
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
const urls = await resolveRefs([presigned.ref]);
return { ref: presigned.ref, url: urls[presigned.ref] ?? presigned.ref };
},
};
}
/* ======================================================================== */
/* Public hooks */
/* ======================================================================== */
export interface GalleryStorage {
/** True when persisting to be-crm; false when running on the device-local demo store. */
live: boolean;
adapter: StorageAdapter;
}
/**
* The storage backend for the embedded gallery. Live against the be-crm data door once the Shell is
* configured, otherwise a device-local store so the demo works with no backend at all.
*
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
*/
export function useGalleryStorage(): GalleryStorage {
const { sdk } = useAppShell();
// The adapter identity must be stable — PhotoGallery captures it in a ref on first render.
return useMemo<GalleryStorage>(
() =>
SHELL
? { live: true, adapter: createDataDoorAdapter(sdk as unknown as DataDoor) }
: { live: false, adapter: createLocalStorageAdapter(LOCAL_STORE_KEY) },
[sdk],
);
}
/**
* The signed-in identity handed to the gallery so comments are attributed to a real person rather
* than a free-text name the author can type themselves.
*
* Falls back to the same static demo user the sidebar and topbar use when the Shell isn't wired, so
* the comment module behaves identically in the demo the CRM never shows an anonymous author.
* be-crm re-stamps `authorId` from the PAT on every write regardless, so this value is a display
* convenience, never the source of authority.
*/
export function useGalleryUser(): GalleryUser {
const { user, context } = useAuth();
return useMemo<GalleryUser>(() => {
if (!user) return { id: demoUser.id, name: demoUser.name };
const avatarUrl = user.avatarUrl ?? context?.principal?.avatarUrl;
return {
id: user.id,
name: user.displayName || user.email || demoUser.name,
...(user.email ? { email: user.email } : {}),
...(avatarUrl ? { avatarUrl } : {}),
};
}, [user, context]);
}
/* ======================================================================== */
/* Recently Deleted lock */
/* ======================================================================== */
/**
* The SDK's `lockProvider` contract, declared here rather than imported so this module keeps
* compiling against an SDK build that predates the prop. It is structurally identical to
* `PhotoGalleryProps['lockProvider']`.
*/
export interface GalleryLockProvider {
status(): Promise<{ hasPassword: boolean }>;
/** `null` clears the password. */
set(password: string | null): Promise<void>;
verify(password: string): Promise<boolean>;
}
/**
* A server-backed, per-user lock for the Recently Deleted view.
*
* Without this the SDK falls back to a device-local `localStorage` hash: the lock exists only on
* the browser that set it, so the same account on a second device sees an unlocked trash. Backed
* by `crm.gallery.lock.*`, the password becomes an account property be-crm stores only a
* scrypt hash with a per-record random salt, keyed by (tenant, principal), and rate-limits verify.
*
* Returns `undefined` in demo mode ON PURPOSE: with no backend there is nowhere to put the hash,
* and the SDK's own device-local behaviour is the right fallback for a demo.
*
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
*/
export function useGalleryLockProvider(): GalleryLockProvider | undefined {
const { sdk } = useAppShell();
return useMemo<GalleryLockProvider | undefined>(() => {
if (!SHELL) return undefined;
const door = sdk as unknown as DataDoor;
return {
status: () => door.query<{ hasPassword: boolean }>("crm.gallery.lock.status", {}),
// The SDK's contract returns void; the door's `{ hasPassword }` is redundant after a set.
set: async (password) => {
await door.command("crm.gallery.lock.set", { password });
},
// A wrong password is a normal `{ ok: false }`, not an error. A 403 (the verify lockout)
// still throws, which is what the SDK's prompt should surface.
verify: async (password) => (await door.command<{ ok: boolean }>("crm.gallery.lock.verify", { password })).ok,
};
}, [sdk]);
}
/* ======================================================================== */
/* Permission-gated features */
/* ======================================================================== */
/**
* The gallery capabilities the CRM gates behind team permissions. Each SDK feature toggle
* (and the whole-view `media.view` gate) maps to one permission id from be-crm's Media group.
* The SDK still enforces nothing here hiding a control is UX; be-crm enforces every write via
* GALLERY_VISIBILITY + tenant scoping regardless of what the UI shows.
*/
const MEDIA_PERMISSIONS = [
"media.view", "media.upload", "media.capture", "media.edit",
"media.delete", "media.export", "media.share", "media.map", "media.ai",
] as const;
/** SDK feature toggle → the permission id that unlocks it. */
const FEATURE_PERMISSION: Record<keyof GalleryFeatures, string> = {
editor: "media.edit",
camera: "media.capture",
import: "media.upload",
export: "media.export",
sharing: "media.share",
map: "media.map",
ai: "media.ai",
};
export interface ResolvedGalleryFeatures {
/** Feature toggles to hand the SDK's `features` prop, resolved from the caller's permissions. */
features: GalleryFeatures;
/** Whether the whole Smart Gallery view should render at all (`media.view`). */
canView: boolean;
}
/**
* Resolve the gallery's feature flags + view access from the signed-in user's CRM permissions.
*
* Fallback rule (SAFE default a control is enabled unless the user is positively known to lack it):
* 1. While access is still loading, stay permissive so features don't flash on then vanish.
* 2. Superadmins and owners get everything (they hold every permission anyway; this is explicit).
* 3. A member who has been assigned AT LEAST ONE `media.*` permission is gated precisely: each
* feature is enabled only if its mapped permission is in their list.
* 4. A member with ZERO `media.*` permissions assigned is treated as fully enabled. Roles are not
* configured with Media perms until an admin opts in (§5 just made them assignable), so gating a
* freshly-seeded member down to nothing would cripple the gallery before anyone could grant them
* anything. The "has at least one media.* perm" signal is what flips a role from this permissive
* default into precise per-feature gating.
*
* Because be-crm's mock access (demo, no Shell) returns superadmin + all perms, the demo shows
* everything via rule 2.
*/
export function useGalleryFeatures(): ResolvedGalleryFeatures {
const access = useMyAccess();
return useMemo<ResolvedGalleryFeatures>(() => {
const has = (p: string) => access.permissions.includes(p);
const privileged = access.isSuperadmin || access.roleSlugs.includes("owner");
const hasAnyMedia = MEDIA_PERMISSIONS.some(has);
// Permissive whenever we can't (yet) prove the user lacks a permission: loading, privileged, or a
// member who has no Media perms assigned at all. Otherwise gate precisely on the mapped id.
const allow = (perm: string) => access.loading || privileged || !hasAnyMedia || has(perm);
const features: GalleryFeatures = {
editor: allow(FEATURE_PERMISSION.editor),
camera: allow(FEATURE_PERMISSION.camera),
ai: allow(FEATURE_PERMISSION.ai),
map: allow(FEATURE_PERMISSION.map),
import: allow(FEATURE_PERMISSION.import),
export: allow(FEATURE_PERMISSION.export),
sharing: allow(FEATURE_PERMISSION.sharing),
};
return { features, canView: allow("media.view") };
}, [access]);
}
/* ======================================================================== */
/* Theme bridge */
/* ======================================================================== */
/**
* Maps the gallery's design tokens onto the CRM's own CSS variables.
*
* Every value is a `var(--crm-token)` reference rather than a literal hex, so the gallery inherits the
* dashboard's palette through the SAME `[data-theme]` cascade the rest of the app uses light/dark
* switch together, and a future palette change reaches the gallery with no code edit here.
*/
export const GALLERY_THEME_TOKENS: ThemeTokens = {
// Surfaces
bgLight: "var(--bg)",
bgDark: "var(--bg)",
elevatedLight: "var(--panel-3)",
elevatedDark: "var(--panel-3)",
sidebarBgLight: "var(--sidebar)",
sidebarBgDark: "var(--sidebar)",
toolbarBgLight: "var(--panel)",
toolbarBgDark: "var(--panel)",
cardLight: "var(--panel)",
cardDark: "var(--panel)",
cardHoverLight: "var(--panel-3)",
cardHoverDark: "var(--panel-3)",
menuBgLight: "var(--panel-3)",
menuBgDark: "var(--panel-3)",
// Text
textLight: "var(--text)",
textDark: "var(--text)",
textSecondaryLight: "var(--muted)",
textSecondaryDark: "var(--muted)",
textTertiaryLight: "var(--faint)",
textTertiaryDark: "var(--faint)",
// Lines + washes
separatorLight: "var(--border)",
separatorDark: "var(--border)",
separatorStrongLight: "var(--border-2)",
separatorStrongDark: "var(--border-2)",
hoverLight: "var(--track)",
hoverDark: "var(--track)",
activeLight: "var(--border-2)",
activeDark: "var(--border-2)",
sidebarSelectedLight: "var(--track)",
sidebarSelectedDark: "var(--track)",
glassBorderLight: "var(--border-2)",
glassBorderDark: "var(--border-2)",
// Brand
accent: "var(--orange)",
accentStrongLight: "var(--orange-2)",
accentStrongDark: "var(--orange-2)",
accentContrast: "#ffffff",
dangerLight: "var(--red)",
dangerDark: "var(--red)",
tileFav: "var(--red)",
segmentedActive: "var(--panel-3)",
// Overlays + chrome
overlayBg: "rgba(2,2,6,0.94)",
editorBg: "var(--panel-2)",
shadowSm: "0 1px 2px rgba(0,0,0,0.18)",
shadowMdLight: "var(--shadow)",
shadowMdDark: "var(--shadow)",
shadowLgLight: "0 30px 70px -20px rgba(15,23,42,0.25)",
shadowLgDark: "0 30px 70px -20px rgba(0,0,0,0.7)",
fontFamily: "var(--font)",
radiusMenu: 14,
sidebarRadius: 14,
};
+50
View File
@@ -0,0 +1,50 @@
"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/");
}
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
// Fall back to the extension for the text types IIOS allows, else a generic binary.
const EXT_MIME: Record<string, string> = {
md: "text/markdown", markdown: "text/markdown",
html: "text/html", htm: "text/html",
txt: "text/plain", csv: "text/csv",
};
function mimeForFile(file: File): string {
if (file.type) return file.type;
const ext = file.name.toLowerCase().split(".").pop() ?? "";
return EXT_MIME[ext] ?? "application/octet-stream";
}
/** 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 = mimeForFile(file);
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]);
}
+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";
+138
View File
@@ -0,0 +1,138 @@
"use client";
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
// when the Shell is configured (live); demo mode reports it unsupported.
//
// Live contract (be-crm data door → IIOS /v1/notifications/*):
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
import { useCallback, useEffect, useState } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
const SHELL = isShellConfigured();
const SW_URL = "/push-sw.js";
export type PushPermission = "default" | "granted" | "denied";
export interface PushState {
/** Browser can do Web Push AND we have a live backend to deliver it. */
supported: boolean;
/** OS/browser permission for notifications. */
permission: PushPermission;
/** This browser currently has an active push subscription registered with the backend. */
subscribed: boolean;
/** A subscribe/unsubscribe round-trip is in flight. */
busy: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
}
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function browserSupportsPush(): boolean {
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
}
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
const json = sub.toJSON();
return {
endpoint: sub.endpoint,
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
};
}
export function usePushNotifications(): PushState {
const { sdk } = useAppShell();
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
const [permission, setPermission] = useState<PushPermission>("default");
const [subscribed, setSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
useEffect(() => {
if (!supported) return;
setPermission(Notification.permission as PushPermission);
let cancelled = false;
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => {
if (!cancelled) setSubscribed(Boolean(sub));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [supported]);
const enable = useCallback(async () => {
if (!supported) return;
setBusy(true);
setError(null);
try {
const perm = await Notification.requestPermission();
setPermission(perm as PushPermission);
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
const reg = await navigator.serviceWorker.register(SW_URL);
await navigator.serviceWorker.ready;
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
const existing = await reg.pushManager.getSubscription();
const sub =
existing ??
(await reg.pushManager.subscribe({
userVisibleOnly: true,
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
}));
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
setSubscribed(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not enable notifications.");
} finally {
setBusy(false);
}
}, [supported, sdk]);
const disable = useCallback(async () => {
if (!supported) return;
setBusy(true);
setError(null);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
// Tell the backend first (still has the endpoint), then drop the local subscription.
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
await sub.unsubscribe();
}
setSubscribed(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not disable notifications.");
} finally {
setBusy(false);
}
}, [supported, sdk]);
return { supported, permission, subscribed, busy, error, enable, disable };
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
// notification center use a single connection (not one each). Opened at the dashboard level and
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { MessageSocket } from "@insignia/iios-kernel-client";
import { isShellConfigured } from "./appshell";
const RealtimeContext = createContext<MessageSocket | null>(null);
interface RealtimeDTO { url: string; audience: string; token?: string }
const SHELL = isShellConfigured();
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [socket, setSocket] = useState<MessageSocket | null>(null);
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [url, token]);
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
if (!SHELL) return <>{children}</>;
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
}
/** The shared socket, or null (demo mode / not yet connected). */
export function useRealtime(): MessageSocket | null {
return useContext(RealtimeContext);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
import { useCallback } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SearchResult {
interactionId: string;
threadId: string;
surface: "messenger" | "inbox";
title: string;
/** Snippet with <em>…</em> around the matched terms. */
snippet: string;
at: number;
}
export type SearchFn = (query: string) => Promise<SearchResult[]>;
const MOCK: SearchResult[] = [
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
];
const SHELL = isShellConfigured();
function useLiveSearch(): SearchFn {
const { sdk } = useAppShell();
return useCallback(async (query: string) => {
const q = query.trim();
if (!q) return [];
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
}, [sdk]);
}
function useMockSearch(): SearchFn {
return useCallback(async (query: string) => {
const q = query.trim().toLowerCase();
if (!q) return [];
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
}, []);
}
export function useGlobalSearch(): SearchFn {
return SHELL ? useLiveSearch() : useMockSearch();
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes.
*
* ---------------------------------------------------------------------------
* SCOPE AND LIMITATIONS READ BEFORE RELYING ON THIS
* ---------------------------------------------------------------------------
* State lives in a plain `Map` in THIS process's memory. That means:
*
* - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a
* caller gets up to N x the configured budget. On serverless platforms each
* cold start begins with an empty map, so the effective limit is weaker
* still.
* - NOT DURABLE. A restart or redeploy clears every counter.
* - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one
* window and `max` again at the start of the next up to 2x `max` across a
* window boundary. Acceptable here; the goal is to bound runaway cost, not
* to meter precisely.
*
* It exists because the routes it guards spend real money on GPU inference and
* shipping them with NO limit at all is worse than shipping an imperfect one.
*
* REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN
* ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed
* implementation can drop straight in the only change needed is making it
* async at the call sites.
*
* This is a throttle, not an authorization check. See src/lib/server/session.ts.
*/
interface Window {
/** Requests counted so far in the current window. */
count: number;
/** Epoch ms at which the current window ends. */
resetAt: number;
}
const windows = new Map<string, Window>();
/** Drop expired entries so the map cannot grow without bound. */
const SWEEP_INTERVAL_MS = 60_000;
let lastSweep = 0;
function sweep(now: number): void {
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
lastSweep = now;
for (const [key, w] of windows) {
if (w.resetAt <= now) windows.delete(key);
}
}
export interface LimitResult {
ok: boolean;
/** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */
retryAfter: number;
}
/**
* Count one request against `key` and report whether it is allowed.
*
* @param key Caller identity use `rateLimitKey()` from ./session.
* @param max Requests allowed per window.
* @param windowMs Window length in ms.
*/
export function limit(key: string, max: number, windowMs: number): LimitResult {
const now = Date.now();
sweep(now);
const existing = windows.get(key);
if (!existing || existing.resetAt <= now) {
windows.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true, retryAfter: 0 };
}
if (existing.count >= max) {
return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
}
existing.count += 1;
return { ok: true, retryAfter: 0 };
}
/** Test/maintenance helper — clears all counters. */
export function resetAllLimits(): void {
windows.clear();
lastSweep = 0;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* base64 / data-URI helpers shared by the RunPod endpoint wrappers.
* Different endpoints name their image field differently and some prefix a
* `data:` URI these helpers normalize both.
*
* PROVENANCE: a faithful port of
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/base64.ts`.
* The `pickOutputImage` normalization is intentionally identical so both apps
* tolerate the same set of endpoint response shapes.
*
* SERVER-ONLY.
*/
/** "data:image/png;base64,XXXX" -> "XXXX" (leaves a bare base64 string untouched). */
export function stripDataUri(s: string): string {
if (!s.startsWith("data:")) return s;
const i = s.indexOf(",");
return i === -1 ? s : s.slice(i + 1);
}
/** Wrap a bare base64 string in a data: URI. */
export function toDataUri(b64: string, mime: string): string {
return `data:${mime};base64,${stripDataUri(b64)}`;
}
/**
* Pull the first image-like base64 string out of a RunPod endpoint's `output`,
* regardless of which field name it used. Handles the common shapes:
* "iVBOR..." (raw string)
* { image: "..." } / { image_png } / { image_base64 }
* { images: ["..."] } (array)
* { output: { image: "..." } } (nested)
*/
export function pickOutputImage(output: unknown): string {
const s = findImageString(output, false);
if (!s) throw new Error("RunPod output did not contain an image.");
return stripDataUri(s);
}
/** Keys whose name implies the value IS the image — any non-empty string is accepted. */
const IMAGE_KEYS = ["image_png", "image", "image_base64", "images"] as const;
/** Generic wrapper keys — a string here must actually look like image data. */
const CONTAINER_KEYS = ["output", "result", "data"] as const;
/** A base64 image payload is long; a status/id string ("success", "job-abc") is short. */
function looksLikeImageData(s: string): boolean {
if (s.startsWith("data:image/")) return true;
return s.length >= 256 && /^[A-Za-z0-9+/=\s]+$/.test(s.slice(0, 256));
}
/**
* `strict` is true when we descended through a generic wrapper key (output/result/
* data), where a bare string could be a status/id rather than an image so it must
* pass `looksLikeImageData`. Under an explicit image key (or at top level) any
* non-empty string is taken as the image.
*/
function findImageString(value: unknown, strict: boolean, depth = 0): string | undefined {
if (depth > 5) return undefined;
if (typeof value === "string") {
if (!value) return undefined;
return !strict || looksLikeImageData(value) ? value : undefined;
}
if (Array.isArray(value)) {
for (const el of value) {
const s = findImageString(el, strict, depth + 1);
if (s) return s;
}
return undefined;
}
if (value && typeof value === "object") {
const o = value as Record<string, unknown>;
for (const key of IMAGE_KEYS) {
if (key in o) {
const s = findImageString(o[key], false, depth + 1);
if (s) return s;
}
}
for (const key of CONTAINER_KEYS) {
if (key in o) {
const s = findImageString(o[key], true, depth + 1);
if (s) return s;
}
}
}
return undefined;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Low-level RunPod transport. Every model runs as its own RunPod endpoint; this
* handles the common request envelope, bearer auth, and both response modes:
*
* - `/runsync` (preferred): the job runs synchronously and the body already
* contains `output` (or IS the output for a custom handler).
* - `/run`: returns `{ id, status }`; we poll `/status/{id}` until the job
* reaches a terminal state or the time budget (kept under the 60s serverless
* function cap) is exhausted.
*
* RUNPOD_API_KEY is read here so callers never handle the secret directly, and
* it is NEVER echoed into an error message. Upstream error bodies are truncated
* to 160 chars before being surfaced.
*
* PROVENANCE: a faithful port of
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/client.ts` (same 55s
* budget, same polling, same RunpodError.status mapping).
*
* SERVER-ONLY.
*/
export class RunpodError extends Error {
status: number;
constructor(message: string, status = 502) {
super(message);
this.name = "RunpodError";
this.status = status;
}
}
export interface RunpodCallOpts {
/** Human label used in error messages, e.g. "sd-inpaint". */
name: string;
/** Full endpoint URL from the per-model env var (…/runsync or …/run). */
url: string;
input: Record<string, unknown>;
/** Total budget for the whole call incl. polling. Default 55s (< the 60s cap). */
timeoutMs?: number;
pollIntervalMs?: number;
}
interface RunpodEnvelope {
id?: string;
status?: string;
output?: unknown;
error?: unknown;
}
export async function runpodCall<TOut = unknown>(opts: RunpodCallOpts): Promise<TOut> {
const { name, url, input } = opts;
const timeoutMs = opts.timeoutMs ?? 55_000;
const pollIntervalMs = opts.pollIntervalMs ?? 1500;
const key = process.env.RUNPOD_API_KEY;
if (!key) throw new RunpodError("RUNPOD_API_KEY is not set.", 500);
const deadline = Date.now() + timeoutMs;
const authHeaders = { authorization: `Bearer ${key}` };
let res: Response;
try {
res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", ...authHeaders },
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(timeoutMs),
cache: "no-store",
});
} catch {
throw new RunpodError(`Could not reach RunPod (${name}).`, 502);
}
if (!res.ok) {
// Truncated on purpose — never surface a full upstream body to the client.
const detail = (await res.text().catch(() => "")).slice(0, 160);
throw new RunpodError(`RunPod ${name} error (${res.status}). ${detail}`.trim(), 502);
}
const data = (await res.json().catch(() => null)) as RunpodEnvelope | null;
if (!data || typeof data !== "object") {
throw new RunpodError(`RunPod ${name} returned an invalid response.`, 502);
}
// Terminal failure reported in a job envelope.
if (data.status === "FAILED" || data.status === "CANCELLED") {
throw new RunpodError(`RunPod ${name} job ${data.status.toLowerCase()}.`, 502);
}
// No job id → this is not an async envelope; the body itself is the output.
// Covers custom /runsync handlers that return their result directly, even when
// it carries a `status` field (e.g. "success").
if (data.id === undefined) {
return (data.output !== undefined ? data.output : data) as TOut;
}
// Job envelope that already carries a completed/inline output.
if (data.output !== undefined && (data.status === undefined || data.status === "COMPLETED")) {
return data.output as TOut;
}
// Async: poll /status/{id} until COMPLETED / FAILED / the time budget runs out.
// Each wait + fetch is clamped to the remaining budget so the whole call stays
// under `timeoutMs` (kept below the 60s function cap).
const statusUrl = url.replace(/\/run(sync)?(\/?)$/, `/status/${data.id}`);
for (;;) {
const remaining = deadline - Date.now();
if (remaining < 500) break; // not enough budget for another round
await sleep(Math.min(pollIntervalMs, remaining));
const left = deadline - Date.now();
if (left <= 0) break;
let sres: Response;
try {
sres = await fetch(statusUrl, {
headers: authHeaders,
signal: AbortSignal.timeout(Math.min(10_000, Math.max(1000, left))),
cache: "no-store",
});
} catch {
continue; // transient — keep polling until the deadline
}
if (!sres.ok) continue;
const sdata = (await sres.json().catch(() => null)) as RunpodEnvelope | null;
if (!sdata) continue;
if (sdata.status === "COMPLETED") return sdata.output as TOut;
if (sdata.status === "FAILED" || sdata.status === "CANCELLED") {
throw new RunpodError(`RunPod ${name} job ${sdata.status.toLowerCase()}.`, 502);
}
}
throw new RunpodError(
`RunPod ${name} timed out (raise the endpoint's speed or use /runsync).`,
504,
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+301
View File
@@ -0,0 +1,301 @@
/**
* Typed wrappers for each RunPod endpoint behind the Smart Gallery.
* Every function reads its own `RUNPOD_*_URL` env var, sends the exact `input`
* contract the model spec documents, and normalizes the response.
*
* PROVENANCE: a faithful port of
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/endpoints.ts`
* identical `normalizeDetections` / `normalizeBox` logic and identical env var
* names, so an endpoint deployed for the SDK demo works here unchanged.
*
* SERVER-ONLY. Missing/invalid URLs throw a RunpodError(500) so an unconfigured
* op surfaces as a clear message rather than a crash.
*/
import { stripDataUri, pickOutputImage } from "./base64";
import { RunpodError, runpodCall } from "./client";
import type {
Img2ImgReq,
InpaintReq,
RunpodDetection,
RunpodImageResult,
TiltResult,
TranscriptResult,
} from "./types";
function envNum(v: string | undefined, fallback: number): number {
// Treat a blank/whitespace env var as unset — Number('') is 0 (finite), which
// would otherwise send e.g. strength:0 for `RUNPOD_SD_STRENGTH=`.
if (v == null || v.trim() === "") return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function endpointUrl(envVar: string): string {
const url = process.env[envVar];
if (!url || !/^https?:\/\//i.test(url)) {
throw new RunpodError(`${envVar} is not set (or is not an http(s) URL).`, 500);
}
return url;
}
/** Run an image-in/image-out endpoint and normalize the result to base64 PNG. */
async function imageOp(
name: string,
envVar: string,
input: Record<string, unknown>,
): Promise<RunpodImageResult> {
const output = await runpodCall({ name, url: endpointUrl(envVar), input });
return { imageBase64: pickOutputImage(output), mimeType: "image/png" };
}
// ---------------------------------------------------------------------------
// Image endpoints
// ---------------------------------------------------------------------------
/** #6 Background removal (U²-Net via rembg). model: u2net | u2netp | u2net_human_seg */
export function rpRemoveBackground(imageB64: string, model?: string): Promise<RunpodImageResult> {
return imageOp("background-removal", "RUNPOD_BG_REMOVE_URL", {
task: "remove-bg",
image: imageB64,
...(model ? { model_name: model } : {}),
});
}
/** #7 Real-ESRGAN enhance/upscale (RealESRGAN_x4plus). The caller's scale (from the
* op / restore pass) is authoritative it is not overridden by any env default. */
export function rpUpscale(
imageB64: string,
scale: 2 | 4,
faceEnhance = false,
): Promise<RunpodImageResult> {
return imageOp("upscale", "RUNPOD_UPSCALE_URL", {
task: "upscale",
image: imageB64,
scale,
face_enhance: faceEnhance,
});
}
/** #8 DDColor B&W → colorize. */
export function rpColorize(imageB64: string, inputSize?: number): Promise<RunpodImageResult> {
return imageOp("colorize", "RUNPOD_COLORIZE_URL", {
image: imageB64,
...(inputSize ? { input_size: inputSize } : {}),
});
}
/** #9 SD 3.5 masked inpainting (sky fix / eraser / fill). Also #11 outpaint (pre-padded). */
export function rpInpaint(p: InpaintReq): Promise<RunpodImageResult> {
const input: Record<string, unknown> = {
task: "inpaint",
image: p.imageB64,
mask: p.maskB64,
prompt: p.prompt,
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.8),
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
};
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
if (negative) input.negative_prompt = negative;
if (p.seed != null) input.seed = p.seed;
return imageOp("sd-inpaint", "RUNPOD_SD_INPAINT_URL", input);
}
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
export function rpImg2Img(p: Img2ImgReq): Promise<RunpodImageResult> {
const input: Record<string, unknown> = {
task: "img2img",
image: p.imageB64,
prompt: p.prompt,
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.6),
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
};
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
if (negative) input.negative_prompt = negative;
if (p.seed != null) input.seed = p.seed;
return imageOp("sd-img2img", "RUNPOD_SD_IMG2IMG_URL", input);
}
// ---------------------------------------------------------------------------
// #1 YOLO detection → SDK DetectedObject shape (box as fractions 0..1)
// ---------------------------------------------------------------------------
export async function rpDetect(
imageB64: string,
width: number,
height: number,
): Promise<RunpodDetection[]> {
const output = await runpodCall<unknown>({
name: "yolo-detect",
url: endpointUrl("RUNPOD_YOLO_URL"),
input: { image: imageB64, task: "detect" },
});
return normalizeDetections(output, width, height);
}
function extractDetectionArray(output: unknown): unknown[] {
if (Array.isArray(output)) return output;
if (output && typeof output === "object") {
const o = output as Record<string, unknown>;
for (const key of ["detections", "predictions", "objects", "results", "boxes"]) {
if (Array.isArray(o[key])) return o[key] as unknown[];
}
}
return [];
}
/** First non-empty STRING among the args (numbers ignored — a numeric `class` is an index, not a name). */
function firstLabel(...vals: unknown[]): string | undefined {
for (const v of vals) {
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
}
function normalizeDetections(output: unknown, width: number, height: number): RunpodDetection[] {
const out: RunpodDetection[] = [];
for (const raw of extractDetectionArray(output)) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
// Prefer a human-readable name (ultralytics tojson puts the string in `name`
// and a numeric index in `class`); fall back to class_<id>. Using firstLabel
// (not `??`) also means an explicit empty-string label doesn't get kept + dropped.
const classId = o.class_id ?? (typeof o.class === "number" ? o.class : undefined);
const label = (
firstLabel(o.label, o.name, o.class_name, typeof o.class === "string" ? o.class : undefined) ??
(classId != null ? `class_${classId}` : "object")
).toLowerCase();
const confidence = Number(o.confidence ?? o.score ?? o.conf ?? 0) || 0;
const box = normalizeBox(o, width, height);
if (box && label) out.push({ label, confidence, box });
}
return out;
}
function num(v: unknown): number | null {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function asNum4(v: unknown): [number, number, number, number] | null {
if (!Array.isArray(v) || v.length < 4) return null;
const a = num(v[0]);
const b = num(v[1]);
const c = num(v[2]);
const d = num(v[3]);
return a === null || b === null || c === null || d === null ? null : [a, b, c, d];
}
/**
* Normalize a detection box to {x, y, width, height} as fractions 0..1 of the
* image, from whatever shape the endpoint emits:
* - `xyxy: [x1,y1,x2,y2]` (ultralytics) and generic `box: [...]` corner form
* - `xywh: [...]` and COCO `bbox: [x,y,w,h]` x/y/width/height form
* - object `{x1,y1,x2,y2}` / `{left,top,right,bottom}` (ultralytics tojson) corners
* - object `{x,y,width,height}` x/y/width/height
* Pixel values (any component > 1) are divided by the image dims; already-
* normalized fractions pass through.
*/
function normalizeBox(
o: Record<string, unknown>,
width: number,
height: number,
): RunpodDetection["box"] | null {
const W = width || 1;
const H = height || 1;
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
const frac = (x: number, y: number, w: number, h: number): RunpodDetection["box"] => {
if (Math.max(Math.abs(x), Math.abs(y), Math.abs(w), Math.abs(h)) > 1) {
x /= W;
y /= H;
w /= W;
h /= H;
}
return { x: clamp01(x), y: clamp01(y), width: clamp01(w), height: clamp01(h) };
};
const fromXyxy = (x1: number, y1: number, x2: number, y2: number) =>
frac(x1, y1, x2 - x1, y2 - y1);
// 1. Array boxes, interpreted by which key holds them.
const xyxyArr = asNum4(o.xyxy);
if (xyxyArr) return fromXyxy(xyxyArr[0], xyxyArr[1], xyxyArr[2], xyxyArr[3]);
const xywhArr = asNum4(o.xywh) ?? asNum4(o.bbox); // COCO `bbox` is [x,y,w,h]
if (xywhArr) return frac(xywhArr[0], xywhArr[1], xywhArr[2], xywhArr[3]);
const boxArr = asNum4(o.box); // generic array box → assume corner form
if (boxArr) return fromXyxy(boxArr[0], boxArr[1], boxArr[2], boxArr[3]);
// 2. Object boxes (either nested under `box` or directly on the detection).
const src =
o.box && typeof o.box === "object" && !Array.isArray(o.box)
? (o.box as Record<string, unknown>)
: o;
const x1 = num(src.x1 ?? src.left);
const y1 = num(src.y1 ?? src.top);
const x2 = num(src.x2 ?? src.right);
const y2 = num(src.y2 ?? src.bottom);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return fromXyxy(x1, y1, x2, y2);
const x = num(src.x);
const y = num(src.y);
const w = num(src.width);
const h = num(src.height);
if (x !== null && y !== null && w !== null && h !== null) return frac(x, y, w, h);
return null;
}
// ---------------------------------------------------------------------------
// Audio / calibration endpoints
// ---------------------------------------------------------------------------
/** #2 Camera tilt (DeepSingleImageCalibration). */
export async function rpTilt(imageB64: string): Promise<TiltResult> {
const o = await runpodCall<Record<string, unknown>>({
name: "tilt",
url: endpointUrl("RUNPOD_TILT_URL"),
input: { image: imageB64 },
});
return {
rollDegrees: Number(o.roll_degrees ?? 0) || 0,
pitchDegrees: Number(o.pitch_degrees ?? 0) || 0,
fovDegrees: Number(o.fov_degrees ?? 0) || 0,
};
}
/** #3 Voice-to-text (Parakeet). Audio must be WAV 16kHz mono PCM16. */
export async function rpTranscribe(
audioB64: string,
opts?: { language?: string; timestamps?: boolean; punctuation?: boolean },
): Promise<TranscriptResult> {
const o = await runpodCall<Record<string, unknown>>({
name: "transcribe",
url: endpointUrl("RUNPOD_STT_URL"),
input: { audio: audioB64, task: "transcribe", ...(opts ?? {}) },
});
const rawSegments = Array.isArray(o.segments) ? o.segments : [];
const segments = rawSegments.map((s) => {
const seg = (s ?? {}) as Record<string, unknown>;
return {
text: String(seg.text ?? ""),
startSec: Number(seg.start_sec ?? 0) || 0,
endSec: Number(seg.end_sec ?? 0) || 0,
};
});
return {
transcript: String(o.transcript ?? ""),
segments: segments.length ? segments : undefined,
};
}
/** #12 Audio noise removal (RNNoise). Audio must be WAV 48kHz mono 16-bit PCM. */
export async function rpDenoiseAudio(audioB64: string): Promise<{ audioB64: string }> {
const o = await runpodCall<Record<string, unknown>>({
name: "audio-denoise",
url: endpointUrl("RUNPOD_AUDIO_DENOISE_URL"),
input: { audio: audioB64, task: "denoise" },
});
const a = o.audio ?? o.output ?? "";
return { audioB64: stripDataUri(String(a)) };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Request/response types for the RunPod endpoints behind the Smart Gallery.
*
* PROVENANCE: a faithful port of
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/types.ts`.
* Keep the two in sync the RunPod endpoints are shared between the standalone
* SDK demo and this CRM.
*
* SERVER-ONLY. Imported by src/app/api/gallery/ai/* route handlers, never by a
* client component (which must not see a RunPod URL or key).
*/
/** Normalized image result returned by every image endpoint (raw base64, no data: prefix). */
export interface RunpodImageResult {
imageBase64: string;
mimeType: string;
}
/** #9 SD 3.5 masked inpainting (also #11 outpaint, pre-padded). white in mask = regenerate. */
export interface InpaintReq {
imageB64: string;
maskB64: string;
prompt: string;
negativePrompt?: string;
strength?: number;
guidanceScale?: number;
steps?: number;
seed?: number;
}
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
export interface Img2ImgReq {
imageB64: string;
prompt: string;
negativePrompt?: string;
strength?: number;
guidanceScale?: number;
steps?: number;
seed?: number;
}
/**
* #1 YOLO construction-material classifier, normalized to the SDK's
* `DetectedObject` shape (box as fractions 0..1 of the image).
*/
export interface RunpodDetection {
label: string;
confidence: number;
box: { x: number; y: number; width: number; height: number };
}
/** #2 DeepSingleImageCalibration — camera tilt. */
export interface TiltResult {
rollDegrees: number;
pitchDegrees: number;
fovDegrees: number;
}
/** #3 Parakeet voice-to-text. */
export interface TranscriptSegment {
text: string;
startSec: number;
endSec: number;
}
export interface TranscriptResult {
transcript: string;
segments?: TranscriptSegment[];
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Session gate for the Smart Gallery AI routes.
*
* ---------------------------------------------------------------------------
* TRUST MODEL
* ---------------------------------------------------------------------------
* These routes proxy a paid, rate-limited GPU backend (RunPod) using a secret
* held only on this server. An unauthenticated route is therefore not merely an
* information-disclosure problem it is a billable-resource problem: anyone who
* can reach the URL can spend the operator's GPU budget, and can use the CRM as
* an open relay for arbitrary image/audio processing.
*
* The upstream SDK demo's routes (apps/web/src/app/api/ai/*) are COMPLETELY
* unauthenticated. This module is the fix; every ported route must call it
* before doing any work.
*
* WHO IS TRUSTED
* We do not verify a JWT here and we do not hold any signing key. The single
* source of truth for "is this caller signed in" is the Shell BFF
* (`${BFF_ORIGIN}/api/session/context`), which owns the HttpOnly session cookie.
* We forward the caller's raw `cookie` header to it and treat a 200 as proof of
* a session. Consequences of that choice, stated explicitly:
*
* - The BFF is trusted absolutely. If it is compromised or misconfigured to
* answer 200 for anonymous callers, these routes are open. BFF_ORIGIN must
* therefore only ever point at an origin the operator controls.
* - We forward the cookie header verbatim and nothing else. No Authorization
* header, no bearer token, and never the RunPod key.
* - NOTHING IS CACHED. A cached "yes" would keep a revoked/expired session
* alive for the cache lifetime, so every AI request costs one BFF round
* trip. That is deliberate: correctness over latency for a spend gate.
* - A network failure reaching the BFF returns 503 (fail CLOSED), never 200.
* If we cannot prove a session, we do not spend GPU budget.
*
* DEMO MODE
* When the Shell is not configured (`NEXT_PUBLIC_SUPABASE_URL` unset) the CRM
* runs on its mock portal and there is no session to check, so we allow the
* request and log ONCE at startup-of-first-use. This is the same gate
* `isShellConfigured()` uses for mock-vs-real auth elsewhere in the app.
* IMPORTANT: never deploy to a public origin with the Shell unconfigured AND a
* real RUNPOD_API_KEY present that combination is an open, billable endpoint.
*
* WHAT THIS IS NOT
* This is authentication only, not authorization. It answers "is there a valid
* session", not "may this principal use the gallery". Per-resource policy for
* gallery data lives in be-crm behind the `crm.gallery` resource; if these AI
* routes ever need the same, check it there rather than re-deriving it here.
*/
export type GallerySession =
| { ok: true; principalId?: string }
| { ok: false; status: number; error: string };
/** Mirrors src/lib/appshell.ts — kept local so this stays server-only. */
function isShellConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
}
let demoModeWarned = false;
/**
* Resolve the caller's session. Returns `{ ok: true }` (optionally with the
* principal id, used to key rate limits) or a ready-to-return failure with the
* status and message the route should emit.
*/
export async function requireGallerySession(req: Request): Promise<GallerySession> {
if (!isShellConfigured()) {
if (!demoModeWarned) {
demoModeWarned = true;
console.warn(
"[gallery-ai] Shell is not configured (NEXT_PUBLIC_SUPABASE_URL unset) — " +
"AI routes are UNAUTHENTICATED in demo mode. Do not expose this deployment publicly " +
"while RUNPOD_API_KEY is set.",
);
}
return { ok: true };
}
const cookie = req.headers.get("cookie");
if (!cookie) return { ok: false, status: 401, error: "Not signed in" };
const origin = (process.env.BFF_ORIGIN ?? "http://localhost:4000").replace(/\/$/, "");
let res: Response;
try {
res = await fetch(`${origin}/api/session/context`, {
headers: { cookie, accept: "application/json" },
// Never cache an auth decision — see the trust-model note above.
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
} catch {
// Fail closed: we could not prove a session, so we do not spend GPU budget.
return { ok: false, status: 503, error: "Session service unavailable" };
}
if (res.status === 401) return { ok: false, status: 401, error: "Not signed in" };
if (!res.ok) return { ok: false, status: 503, error: "Session service unavailable" };
// The principal id is best-effort: it only sharpens the rate-limit key, so a
// shape we don't recognize degrades to IP-keyed limiting rather than failing.
let principalId: string | undefined;
try {
const data = (await res.json()) as Record<string, unknown> | null;
principalId = pickPrincipalId(data);
} catch {
/* ignore — see above */
}
return principalId ? { ok: true, principalId } : { ok: true };
}
function pickPrincipalId(data: Record<string, unknown> | null): string | undefined {
if (!data) return undefined;
const direct = data.userId ?? data.principalId ?? data.sub ?? data.id;
if (typeof direct === "string" && direct) return direct;
const user = data.user;
if (user && typeof user === "object") {
const u = user as Record<string, unknown>;
const nested = u.id ?? u.userId ?? u.sub;
if (typeof nested === "string" && nested) return nested;
}
return undefined;
}
/**
* Rate-limit key for a request: the authenticated principal when known,
* otherwise the first hop of `x-forwarded-for`.
*
* NOTE the first hop is client-controlled unless a trusted proxy overwrites the
* header. It is good enough to throttle honest clients and casual abuse; it is
* NOT a security boundary. The session gate above is the security boundary.
*/
export function rateLimitKey(req: Request, principalId?: string): string {
if (principalId) return `u:${principalId}`;
const xff = req.headers.get("x-forwarded-for") ?? "";
const first = xff.split(",")[0]?.trim();
return `ip:${first || "unknown"}`;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps
// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface.
//
// Live contract (be-crm → IIOS BYO credential store):
// query crm.settings.sms.status {} -> { configured, enabled?, hints? }
// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status
// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only
// non-secret hints (from-number + SID last-4).
import { useCallback, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string }
export interface SmsStatus {
configured: boolean;
enabled: boolean;
fromNumber?: string;
sidLast4?: string;
}
export interface SmsSettingsData {
live: boolean;
loading: boolean;
error: string | null;
status: SmsStatus;
configure: (input: SmsCredentials) => Promise<void>;
refetch: () => void;
}
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } }
function toStatus(dto?: StatusDTO | null): SmsStatus {
return {
configured: !!dto?.configured,
enabled: dto?.enabled ?? false,
fromNumber: dto?.hints?.fromNumber,
sidLast4: dto?.hints?.sidLast4,
};
}
/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */
function useMockSms(): SmsSettingsData {
const [status, setStatus] = useState<SmsStatus>({ configured: false, enabled: false });
const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => {
setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) });
}, []);
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
}
/* ---- Live (be-crm data door) ---- */
function useLiveSms(): SmsSettingsData {
const { sdk } = useAppShell();
const q = useQuery<StatusDTO>("crm.settings.sms.status", {});
const configure = useCallback(async (input: SmsCredentials) => {
await sdk.command("crm.settings.sms.configure", { ...input });
q.refetch();
}, [sdk, q]);
return {
live: true,
loading: q.loading,
error: q.error ? String(q.error) : null,
status: toStatus(q.data),
configure,
refetch: q.refetch,
};
}
const SHELL = isShellConfigured();
export function useSmsSettings(): SmsSettingsData {
// SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path
// runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveSms() : useMockSms();
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock
// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*).
//
// Live contract (be-crm → IIOS BYO credential store):
// query crm.settings.smtp.status {} -> { configured, enabled?, hints? }
// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status
// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints.
import { useCallback, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SmtpCredentials {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
fromEmail: string;
fromName?: string;
}
export interface SmtpStatus {
configured: boolean;
enabled: boolean;
host?: string;
port?: number;
user?: string;
fromEmail?: string;
fromName?: string;
}
export interface SmtpSettingsData {
live: boolean;
loading: boolean;
error: string | null;
status: SmtpStatus;
configure: (input: SmtpCredentials) => Promise<void>;
refetch: () => void;
}
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } }
function toStatus(dto?: StatusDTO | null): SmtpStatus {
return {
configured: !!dto?.configured,
enabled: dto?.enabled ?? false,
host: dto?.hints?.host,
port: dto?.hints?.port,
user: dto?.hints?.user,
fromEmail: dto?.hints?.fromEmail,
fromName: dto?.hints?.fromName,
};
}
/* ---- Mock (demo mode) — stores only the non-secret hints ---- */
function useMockSmtp(): SmtpSettingsData {
const [status, setStatus] = useState<SmtpStatus>({ configured: false, enabled: false });
const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => {
setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) });
}, []);
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
}
/* ---- Live (be-crm data door) ---- */
function useLiveSmtp(): SmtpSettingsData {
const { sdk } = useAppShell();
const q = useQuery<StatusDTO>("crm.settings.smtp.status", {});
const configure = useCallback(async (input: SmtpCredentials) => {
await sdk.command("crm.settings.smtp.configure", { ...input });
q.refetch();
}, [sdk, q]);
return {
live: true,
loading: q.loading,
error: q.error ? String(q.error) : null,
status: toStatus(q.data),
configure,
refetch: q.refetch,
};
}
const SHELL = isShellConfigured();
export function useSmtpSettings(): SmtpSettingsData {
return SHELL ? useLiveSmtp() : useMockSmtp();
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
// configured — the polished demo keeps working) OR the live be-crm data door
// (crm.team.*), behind one interface so the component is mode-agnostic.
//
// Live contract (be-crm, multi-role):
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
// cmd crm.team.member.setRoles { id, roleIds }
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
// cmd crm.team.member.remove { id }
// cmd crm.team.invitation.create/resend/revoke
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
} from "@/components/dashboard/team-data";
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
export type UiStatus = "active" | "away" | "offline";
export interface UiRole {
id: string; slug: string; name: string; color: string;
description: string; system: boolean; isOwner: boolean;
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
}
export interface UiMember {
id: string; principalId?: string; name: string; initials: string; email: string;
title: string; roleIds: string[]; gradient: string; status: UiStatus;
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
/** Accept token for the invite link (pending invites only; no email delivery yet). */
token?: string;
}
export interface TeamData {
live: boolean; loading: boolean; error: string | null;
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
removeMember: (id: string) => Promise<void>;
/** Create an invite. be-crm emails the invitee automatically. */
invite: (email: string, roleIds: string[]) => Promise<void>;
/** Resend an invite (regenerates the token + re-emails it). */
resendInvite: (id: string) => Promise<void>;
revokeInvite: (id: string) => Promise<void>;
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
refetch: () => void;
}
/* ---- helpers ------------------------------------------------------------ */
const GRADIENTS = [
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
/* ---- be-crm DTO types (subset used here) -------------------------------- */
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number; email?: string | null; firstName?: string | null; lastName?: string | null; displayName?: string | null }
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
if (Number.isNaN(then)) return iso;
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
/* ---- mock → UI ---------------------------------------------------------- */
const mockRole = (r: SeedRole): UiRole => ({
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
});
const mockMember = (m: SeedMember): UiMember => ({
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
});
const mockInvite = (i: SeedInvite): UiInvite => ({
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
});
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
function useMockTeam(): TeamData {
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
}, []);
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
setMembers((l) => l.map((m) => (m.id === id
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
: m)));
}, []);
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
const invite = useCallback(async (email: string, roleIds: string[]) => {
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
}, []);
const resendInvite = useCallback(async () => {}, []);
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
})));
}, []);
return { live: false, loading: false, error: null, members, roles, invites,
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
function useLiveTeam(): TeamData {
const { sdk } = useAppShell();
const { user } = useAuth();
const meId = user?.id;
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
})), [rolesQ.data]);
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
// Prefer the member's real name/email from their CRM registration; fall back to the
// ACE (for the current user), then job title, then a short principal id.
const name = m.displayName?.trim()
|| (isYou && user?.displayName)
|| m.jobTitle?.trim()
|| `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`;
const email = m.email || (isYou ? user?.email : undefined) || "";
return {
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
email,
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
status: (m.status === "active" ? "active" : "offline") as UiStatus,
lastActive: m.status === "active" ? "Active" : "—",
deals: m.openDeals, joined: relTime(m.joinedAt),
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
};
}), [membersQ.data, meId, user]);
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
})), [invitesQ.data]);
return {
live: true,
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
members, roles, invites,
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
updateMember: (id, patch) => cmd("crm.team.member.update", {
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
}),
removeMember: (id) => cmd("crm.team.member.remove", { id }),
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
setPermission: (roleId, permId, granted) =>
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useTeamData(): TeamData {
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
// so the same hook path runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveTeam() : useMockTeam();
}
+38
View File
@@ -0,0 +1,38 @@
# vendor/
Third-party source vendored into this repo so the app builds from a **single checkout**.
## `photo-gallery-sdk/`
`@photo-gallery/sdk` — the Smart Gallery engine. Upstream:
`https://git.lynkedup.cloud/KaushikRK99/advance-photo-gallery-web-sdk` (`packages/photo-sdk`).
**This directory is generated. Do not hand-edit it** — changes belong upstream, then:
```bash
npm run sync:gallery-sdk # re-copies src/ + package.json + README.md from the sibling checkout
git add vendor/photo-gallery-sdk && git commit -m "chore: sync @photo-gallery/sdk"
```
### Why vendored instead of a registry dependency
The SDK is not published anywhere yet. A `file:../advance-photo-gallery-web-sdk/...` dependency
works on a developer machine that has both repos side by side, but **fails every CI/Vercel build**,
because the deploy only ever checks out this repo. Vendoring keeps the dependency graph closed.
Only `src/`, `package.json` and `README.md` are copied — the package's `exports` point at `src/`
and `next.config.ts` lists it in `transpilePackages`, so no build output is needed. Its own
`node_modules` is deliberately excluded: without it, the SDK's sources resolve `@types/react` from
this repo's React 19 rather than the pnpm workspace's React 18, which is what lets them type-check
here at all.
### Replacing this with a real dependency
When the SDK is published to the Gitea npm registry (the same one `@insignia/*` uses — Vercel
already has `GITEA_TOKEN` for it):
1. Publish it from the SDK repo under a scope this repo's `.npmrc` maps to the registry.
2. In `package.json`, swap `"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk"` for the version range.
3. Delete this directory and the `sync:gallery-sdk` script.
Nothing else changes — no import paths, no config.
+288
View File
@@ -0,0 +1,288 @@
# @photo-gallery/sdk
A reusable, macOS Photos-style photo gallery for React / Next.js — light & dark, fully responsive,
pluggable storage and AI. One component, no required CSS framework.
```tsx
import { PhotoGallery } from '@photo-gallery/sdk';
import '@photo-gallery/sdk/styles.css';
export default () => <PhotoGallery photos={myPhotos} theme="system" />;
```
See the [repository README](../../README.md) for full documentation, props, adapters and the
AI-provider interface.
## Embedding in a host app
By default `<PhotoGallery>` behaves like a standalone app: it owns the sidebar, the toolbar, the
theme switcher and the global keyboard shortcuts, and it assumes a full-viewport parent. All of
that is opt-out, so the gallery can be dropped into an existing product's shell instead.
```tsx
<div style={{ height: 'calc(100vh - 64px)' }}>
<PhotoGallery
embedded
adapter={dataDoorAdapter}
currentUser={{ id: user.id, name: user.name, avatarUrl: user.avatar }}
shareBaseUrl="https://app.example.com/gallery"
chrome={{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: false }}
keyboardShortcuts={false}
theme={hostTheme}
themeTokens={hostTokens}
/>
</div>
```
### New props
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `embedded` | `boolean` | `false` | Lays out at `height: 100%` inside the host container instead of assuming a full-viewport parent, and switches interactive elements to `cursor: pointer`. |
| `currentUser` | `GalleryUser` | | The host's signed-in user (`{ id, name, email?, avatarUrl? }`). Comments are stamped with this identity; the free-text author field disappears. |
| `shareBaseUrl` | `string` | `${location.origin}/gallery` | Base URL for generated share links (`?shared=<token>` is appended). |
| `chrome` | `Partial<GalleryChrome>` | `DEFAULT_CHROME` | Suppress chrome the host already provides: `{ titlebar, sidebar, toolbar, themeSwitcher }`. |
| `hiddenViews` | `ViewId[]` | `[]` | Sidebar rows + Collections cards to hide, e.g. `['screenshots', 'sys:documents']`. A section label drops when all its rows are hidden; if the active view becomes hidden the gallery falls back to `library`. |
| `keyboardShortcuts` | `boolean` | `true` | Bind global shortcuts (⌘A, Delete, F, Esc, Enter) to `window`. |
| `defaultFullscreen` | `boolean` | `false` | Start maximised over the host page. |
| `lockProvider` | `LockProvider` | | Server-backed, per-user lock for Recently Deleted. Replaces the device-local localStorage hash entirely. |
`showWindowChrome` still works and maps onto `chrome.titlebar`; an explicit `chrome.titlebar` wins.
`DEFAULT_CHROME` is exported (`{ titlebar: false, sidebar: true, toolbar: true, themeSwitcher: true }`).
Turning `chrome.themeSwitcher` off removes the Appearance items from the "More" menu, so a host that
owns light/dark cannot be overridden from inside the gallery. The config is now **live**: changing
`theme`, `themeTokens`, `currentUser` or `chrome` re-applies without remounting the gallery.
### Full screen / maximise
The toolbar carries a maximise/restore button (right-hand group, next to Info). Turning it on adds
`apg--fullscreen` to the root element, which goes `position: fixed; inset: 0; z-index: 1400;
height: 100dvh; border-radius: 0` — so the gallery fills the viewport no matter what height the host
container gave it, and sits above the host's own chrome. Escape leaves full screen, but only once
nothing else owns Escape: the lightbox, the photo/video editors, the camera, modals and context menus
all get it first, and it still clears an object focus or a selection before it un-maximises. Escape is
part of the shortcut set, so `keyboardShortcuts={false}` opts out of that too — the button always works.
The SDK's own overlays needed no change. They are `position: fixed; inset: 0`, which resolves against
the viewport — and in full screen the gallery covers exactly the viewport, so they still land right.
`z-index: 1400` opens a stacking context, so their existing 10001300 z-indexes now stack *inside* it:
above the gallery's content and, through 1400, above the host's chrome.
Headless control lives on the store: `fullscreen`, `setFullscreen(next)`, `toggleFullscreen()`.
`defaultFullscreen` only seeds the initial value; the user's toggle owns it afterwards.
### Server-backed Recently Deleted lock
By default the Recently Deleted lock is a password hash in `localStorage` — device-local, and invisible
to your backend. Pass a `lockProvider` and the SDK delegates every lock operation to it instead, so the
lock belongs to the *user* and follows them across devices. The localStorage path is untouched when no
provider is given.
```tsx
<PhotoGallery
embedded
lockProvider={{
status: () => call('crm.gallery.lock.status', {}),
set: async (password) => { await call('crm.gallery.lock.set', { password }); },
verify: async (password) => (await call('crm.gallery.lock.verify', { password })).ok,
}}
/>
```
```ts
interface LockProvider {
status(): Promise<{ hasPassword: boolean }>;
set(password: string | null): Promise<void>; // null clears it
verify(password: string): Promise<boolean>;
}
```
Drive your UI off `lockConfigured: boolean` (refreshed from `status()` during `init()`, and again if
the provider arrives late), not `lock.hash` — that field is meaningless on the provider path. Failures
are distinguished: a `verify()` that *resolves false* sets `lockError: 'wrong-password'` ("Incorrect
password."), while one that *rejects* sets `lockError: 'unavailable'` ("Couldn't check the password.
Please try again.") so the user retries instead of doubting what they typed. `clearLockError()` resets
it; `refreshLockStatus()` re-reads `status()` on demand.
### Theme tokens → CSS variables
Every value is optional. Tokens ending in `Light`/`Dark` are theme-paired (the matching one is
applied for the resolved theme); bare names are theme-independent. Numbers are emitted as `px`.
Values are rejected if they contain `url(`, `expression(`, `javascript:` or `<>{}`.
| Token | CSS variable | Notes |
| --- | --- | --- |
| `accent` | `--apg-accent` | Also overrides the `accentColor` prop. |
| `accentStrongLight` / `accentStrongDark` | `--apg-accent-strong` | Accent behind white text (AA contrast). |
| `accentContrast` | `--apg-accent-contrast` | Foreground on top of the accent. |
| `dangerLight` / `dangerDark` | `--apg-danger` | Destructive actions. |
| `bgLight` / `bgDark` | `--apg-bg` + `--apg-bg-content` | App / content background. |
| `elevatedLight` / `elevatedDark` | `--apg-bg-elevated` | Raised surfaces. |
| `cardLight` / `cardDark` | `--apg-card` | Collection cards. |
| `cardHoverLight` / `cardHoverDark` | `--apg-card-hover` | Card hover state. |
| `sidebarBgLight` / `sidebarBgDark` | `--apg-sidebar-bg` | Sidebar glass (semi-dark always uses the dark value). |
| `toolbarBgLight` / `toolbarBgDark` | `--apg-toolbar-bg` | Top toolbar glass. |
| `menuBgLight` / `menuBgDark` | `--apg-menu-bg` | Context menus, Info panel, popovers. |
| `separatorLight` / `separatorDark` | `--apg-separator` | Hairlines. |
| `separatorStrongLight` / `separatorStrongDark` | `--apg-separator-strong` | Input borders, scrollbars. |
| `hoverLight` / `hoverDark` | `--apg-hover` | Row / icon-button hover wash. |
| `activeLight` / `activeDark` | `--apg-active` | Pressed state. |
| `sidebarSelectedLight` / `sidebarSelectedDark` | `--apg-sidebar-selected` | Selected sidebar row. |
| `textLight` / `textDark` | `--apg-text` | Primary text. |
| `textSecondaryLight` / `textSecondaryDark` | `--apg-text-secondary` | Labels, captions. |
| `textTertiaryLight` / `textTertiaryDark` | `--apg-text-tertiary` | Hints, timestamps. |
| `glassBorderLight` / `glassBorderDark` | `--apg-glass-border` | Border on glass surfaces. |
| `fontFamily` | `--apg-font` | Font stack for the whole gallery. |
| `sidebarRadius` | `--apg-sidebar-radius` | px. |
| `radiusMenu` | `--apg-radius-menu` | px. |
| `shadowSm` | `--apg-shadow-sm` | |
| `shadowMdLight` / `shadowMdDark` | `--apg-shadow-md` | Menus, action bar. |
| `shadowLgLight` / `shadowLgDark` | `--apg-shadow-lg` | Modals. |
| `tileFav` | `--apg-tile-fav` | Favourite heart on a tile (default `#ff3b30`). |
| `overlayBg` | `--apg-overlay-bg` | Lightbox backdrop (default `rgba(0,0,0,0.97)`). |
| `editorBg` | `--apg-editor-bg` | Editor chrome (default `#161617`). |
| `segmentedActive` | `--apg-segmented-active` | Active segmented pill. |
| `sidebarWidth` | `--apg-sidebar-w` | px. |
| `toolbarHeight` | `--apg-toolbar-h` | px. Also drives `--apg-overlay-top`. |
The Info panel is positioned at `top: var(--apg-overlay-top, 64px)` — set `--apg-overlay-top` via the
`style` prop to push it below the host's own header. Full-screen overlays (lightbox, editor, camera,
modals, context menus) stay `position: fixed`, which is correct for a modal over a host app.
### Incremental storage adapters
`StorageAdapter` gained two optional members. Both are additive — existing `save`/`putBlob` adapters
keep working unchanged.
- **`applyChanges(changes: StateChanges)`** — when present the store calls this *instead of* `save()`
on every (debounced, 400 ms) change, sending only what differs from the last persisted snapshot:
`{ upsertMedia?, removeMedia?, upsertAlbums?, removeAlbums?, upsertPeople?, removePeople?,
labelAliases?, deletedLabels? }`. Overlapping writes are serialized, and a rejection rolls the
snapshot back so the next persist retries the same diff. Only non-system albums are ever persisted.
- **`putMedia(id, blob, { name, mime }): Promise<StoredBlob>`** — preferred over `putBlob`. It returns
`{ ref, url }`: the store sets `item.src = url` (renderable now, may be a short-lived signed URL)
and `item.storageRef = ref` (the durable reference that survives a reload).
Worked example against a data-door backend:
```ts
import type { StorageAdapter, StateChanges, StoredBlob } from '@photo-gallery/sdk';
export function createDataDoorAdapter(call: (a: string, p: unknown) => Promise<any>): StorageAdapter {
return {
name: 'data-door',
async load() {
return await call('crm.gallery.state.load', {});
},
// Kept as a fallback for callers that don't use applyChanges.
async save(state) {
await call('crm.gallery.state.apply', {
upsertMedia: state.media,
upsertAlbums: state.albums,
upsertPeople: state.people,
labelAliases: state.labelAliases,
deletedLabels: state.deletedLabels,
} satisfies StateChanges);
},
async applyChanges(changes: StateChanges) {
await call('crm.gallery.state.apply', changes);
},
async putMedia(id, blob, meta): Promise<StoredBlob> {
const { ref, uploadUrl, method, headers } = await call('crm.gallery.media.presignUpload', {
mediaId: id,
mime: meta.mime,
sizeBytes: blob.size,
filename: meta.name,
});
await fetch(uploadUrl, { method, headers, body: blob });
const { urls } = await call('crm.gallery.media.presignDownload', { refs: [ref] });
return { ref, url: urls[ref] };
},
};
}
```
### Identity-aware comments
With `currentUser` set, `addComment(id, text)` stamps `{ authorId, author, authorAvatar }` from the
configured user, the Info panel renders the real avatar/name instead of a name input (and stops using
the `apg:comment-author` localStorage key), and delete affordances appear only on comments whose
`authorId` matches. `deleteComment` is a client-side no-op for someone else's comment — the backend
remains the authority. Without `currentUser`, behaviour is exactly as before.
### Uploader & version identity
With `currentUser` set, imports (`importFiles`) and camera captures stamp `MediaItem.uploadedBy`
(a `GalleryUser`) — a re-import never overwrites an existing value. Saving an edit (`addVersion`) or
restoring one (`restoreVersion`) stamps `{ authorId, author, authorAvatar }` onto the `MediaVersion`.
The Info panel shows an "Uploaded by" block (avatar + name + muted email) and the author beside each
version's timestamp; the Versions & Audit view shows the latest editor. All fields are optional — with
no `currentUser`, nothing is stamped and the UI renders exactly as before. `be-crm` still owns
`owner_principal_id`; `uploadedBy` is display identity only.
### Editable caption & notes
The Info panel's caption and a new multi-line `MediaItem.note` are click-to-edit: they save on blur or
Enter (⌘/Ctrl+Enter for the note) via `updateMedia`, so they persist through the adapter. `note` is
folded into the search haystack, so notes are searchable.
### Info panel media & comments
Video items render a real inline `<video controls>` preview (`.apg-info__thumb--video`) at the top of the
panel instead of a frozen poster `<img>`, so a video is playable straight from Info. Comments render as
cards (`.apg-comment`) with generous spacing around the "Uploaded by" block, the "Analyzing…" line and
between comments, and a divider separates the thread from the compose form.
### Full-address reverse geocoding
When a located item's Info panel opens and `location.formatted` isn't cached yet, the SDK reverse-
geocodes the coordinates once via free OpenStreetMap Nominatim (`addressdetails=1`), parses the result
into `GeoLocation` (`road`, `neighbourhood`, `suburb`, `city`, `county`, `state`, `postcode`,
`country`, `countryCode`, `formatted`), and persists it with `updateMedia` so it survives a reload and
isn't re-fetched. The panel shows the one-line address plus a City / State / Postcode / Country grid
and the raw lat/lng; clicking the address opens the Map. A hardened host CSP must allow
`nominatim.openstreetmap.org` in `connect-src`.
### Floating toolbar
The top toolbar now floats as a rounded glass bar (margins on all sides, `--apg-radius-lg`,
`--apg-shadow-md`) to match the sidebar, in embedded and full-screen alike. It stays in normal flow, so
the viewport begins below it and scrolling is unaffected. The wide Years/Months/All-Photos segmented is
replaced by a compact **"All Photos ▾"** dropdown (`.apg-scalemenu`, reusing the `.apg-menu` popover
with its menu role, checkmarks and arrow-key nav) — this clears the zoom-out () button that the
segmented used to overlap on narrow toolbars.
### Map date-range control
The Map location sheet's filter replaces the two bare From/To date inputs with a single **date range**
button (`.apg-daterange`) that opens a popover of quick presets (All dates, Last 7 days, Last 30 days,
This year) plus a custom From/To pair. It drives the same underlying from/to state, so the AND-combine
with search + object chips, the live "N of M" count, and Clear all keep working.
Map pins fly-to on click (`flyTo`, never zooming out), carry a per-location count badge and a hover
mini-slider strip; the single-photo hover tooltip (`.apg-pin__tip`) is `user-select:none` and edge-clamped
— MapView sets `--tip-dx` to slide it back inside the map and adds `.apg-pin__tip--below` to flip it under
the pin near the top edge — so it can't overflow or get clipped.
### Video editor transport
The video editor applies its rotate / flip / crop transform to the video *frame only*, never to a native
`<video controls>` bar (which used to rotate with the frame and look broken). Playback is driven by a custom
transport rendered outside the transformed element — play/pause, a scrubber and a time readout
(`.apg-vedit__transport` / `.apg-vedit__scrub` / `.apg-vedit__time`).
## Build
```bash
pnpm build # → dist/ : index.js (ESM), index.cjs (CJS), index.d.ts, styles.css
```
The package exports source from `src/` for zero-build use inside the monorepo (via
`transpilePackages`), and `dist/` is produced for external publishing.
MIT · Original implementation, not affiliated with Apple.
+1
View File
@@ -0,0 +1 @@
../nanoid/bin/nanoid.js
+20
View File
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright 2017 Andrey Sitnik <andrey@sitnik.es>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+34
View File
@@ -0,0 +1,34 @@
# Nano ID
<img src="https://ai.github.io/nanoid/logo.svg" align="right"
alt="Nano ID logo by Anton Lovchikov" width="180" height="94">
**English** | [日本語](./README.ja.md) | [Русский](./README.ru.md) | [简体中文](./README.zh-CN.md) | [Bahasa Indonesia](./README.id-ID.md) | [한국어](./README.ko.md) | [العربية](./README.ar.md)
A tiny, secure, URL-friendly, unique string ID generator for JavaScript.
> “An amazing level of senseless perfectionism,
> which is simply impossible not to respect.”
- **Small.** 118 bytes (minified and brotlied). No dependencies.
[Size Limit] controls the size.
- **Safe.** It uses hardware random generator. Can be used in clusters.
- **Short IDs.** It uses a larger alphabet than UUID (`A-Za-z0-9_-`).
So ID size was reduced from 36 to 21 symbols.
- **Portable.** Nano ID was ported
to over [20 programming languages](./README.md#other-programming-languages).
```js
import { nanoid } from 'nanoid'
model.id = nanoid() //=> "V1StGXR8_Z5jdHi6B-myT"
```
---
<img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="" width="22" height="16" />  Made at <b><a href="https://evilmartians.com/devtools?utm_source=nanoid&utm_campaign=devtools-button&utm_medium=github">Evil Martians</a></b>, product consulting for <b>developer tools</b>.
---
[online tool]: https://gitpod.io/#https://github.com/ai/nanoid/
[with Babel]: https://developer.epages.com/blog/coding/how-to-transpile-node-modules-with-babel-and-webpack-in-a-monorepo/
[Size Limit]: https://github.com/ai/size-limit
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { customAlphabet, nanoid } from '../index.js'
function print(msg) {
process.stdout.write(msg + '\n')
}
function error(msg) {
process.stderr.write(msg + '\n')
process.exit(1)
}
if (process.argv.includes('--version') || process.argv.includes('-v')) {
let root = dirname(fileURLToPath(import.meta.url))
let pkg = JSON.parse(readFileSync(join(root, '..', 'package.json'), 'utf8'))
print(pkg.version)
process.exit()
}
if (process.argv.includes('--help') || process.argv.includes('-h')) {
print(`Usage
$ nanoid [options]
Options
-s, --size Generated ID size
-a, --alphabet Alphabet to use
-v, --version Show version number
-h, --help Show this help
Examples
$ nanoid -s 15
S9sBF77U6sDB8Yg
$ nanoid --size 10 --alphabet abc
bcabababca`)
process.exit()
}
let alphabet, size
for (let i = 2; i < process.argv.length; i++) {
let arg = process.argv[i]
if (arg === '--size' || arg === '-s') {
size = Number(process.argv[i + 1])
i += 1
if (Number.isNaN(size) || size <= 0) {
error('Size must be positive integer')
}
} else if (arg === '--alphabet' || arg === '-a') {
alphabet = process.argv[i + 1]
i += 1
} else {
error('Unknown argument ' + arg)
}
}
if (alphabet) {
let customNanoid = customAlphabet(alphabet, size)
print(customNanoid())
} else {
print(nanoid(size))
}
+57
View File
@@ -0,0 +1,57 @@
import { urlAlphabet as scopedUrlAlphabet } from './url-alphabet/index.js'
export { urlAlphabet } from './url-alphabet/index.js'
export let random = bytes => crypto.getRandomValues(new Uint8Array(bytes))
export let customRandom = (alphabet, defaultSize, getRandom) => {
let safeByteCutoff = 256 - (256 % alphabet.length)
if (safeByteCutoff === 256) {
let mask = alphabet.length - 1
return (size = defaultSize) => {
if (!size) return ''
let id = ''
while (true) {
let bytes = getRandom(size)
let j = size
while (j--) {
id += alphabet[bytes[j] & mask]
if (id.length >= size) return id
}
}
}
}
let step = Math.ceil((1.6 * 256 * defaultSize) / safeByteCutoff)
return (size = defaultSize) => {
if (!size) return ''
let id = ''
while (true) {
let bytes = getRandom(step)
let j = step
while (j--) {
if (bytes[j] < safeByteCutoff) {
id += alphabet[bytes[j] % alphabet.length]
if (id.length >= size) return id
}
}
}
}
}
export let customAlphabet = (alphabet, size = 21) =>
customRandom(alphabet, size | 0, random)
export let nanoid = (size = 21) => {
let id = ''
let bytes = crypto.getRandomValues(new Uint8Array((size |= 0)))
while (size--) {
id += scopedUrlAlphabet[bytes[size] & 63]
}
return id
}
+106
View File
@@ -0,0 +1,106 @@
/**
* A tiny, secure, URL-friendly, unique string ID generator for JavaScript
* with hardware random generator.
*
* ```js
* import { nanoid } from 'nanoid'
* model.id = nanoid() //=> "V1StGXR8_Z5jdHi6B-myT"
* ```
*
* @module
*/
/**
* Generate secure URL-friendly unique ID.
*
* By default, the ID will have 21 symbols to have a collision probability
* similar to UUID v4.
*
* ```js
* import { nanoid } from 'nanoid'
* model.id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqL"
* ```
*
* @param size Size of the ID. The default size is 21.
* @typeparam Type The ID type to replace `string` with some opaque type.
* @returns A random string.
*/
export function nanoid<Type extends string>(size?: number): Type
/**
* Generate secure unique ID with custom alphabet.
*
* Alphabet must contain 256 symbols or less. Otherwise, the generator
* will not be secure.
*
* @param alphabet Alphabet used to generate the ID.
* @param defaultSize Size of the ID. The default size is 21.
* @typeparam Type The ID type to replace `string` with some opaque type.
* @returns A random string generator.
*
* ```js
* import { customAlphabet } from 'nanoid'
* const nanoid = customAlphabet('0123456789абвгдеё', 5)
* nanoid() //=> "8ё56а"
* ```
*/
export function customAlphabet<Type extends string>(
alphabet: string,
defaultSize?: number
): (size?: number) => Type
/**
* Generate unique ID with custom random generator and alphabet.
*
* Alphabet must contain 256 symbols or less. Otherwise, the generator
* will not be secure.
*
* ```js
* import { customRandom } from 'nanoid'
*
* const nanoid = customRandom('abcdef', 5, size => {
* const random = []
* for (let i = 0; i < size; i++) {
* random.push(randomByte())
* }
* return random
* })
*
* nanoid() //=> "fbaef"
* ```
*
* @param alphabet Alphabet used to generate a random string.
* @param size Size of the random string.
* @param random A random bytes generator.
* @typeparam Type The ID type to replace `string` with some opaque type.
* @returns A random string generator.
*/
export function customRandom<Type extends string>(
alphabet: string,
size: number,
random: (bytes: number) => Uint8Array
): (size?: number) => Type
/**
* URL safe symbols.
*
* ```js
* import { urlAlphabet } from 'nanoid'
* const nanoid = customAlphabet(urlAlphabet, 10)
* nanoid() //=> "Uakgb_J5m9"
* ```
*/
export const urlAlphabet: string
/**
* Generate an array of random bytes collected from hardware noise.
*
* ```js
* import { customRandom, random } from 'nanoid'
* const nanoid = customRandom("abcdef", 5, random)
* ```
*
* @param bytes Size of the array.
* @returns An array of random bytes.
*/
export function random(bytes: number): Uint8Array
+83
View File
@@ -0,0 +1,83 @@
import { webcrypto as crypto } from 'node:crypto'
import { urlAlphabet as scopedUrlAlphabet } from './url-alphabet/index.js'
export { urlAlphabet } from './url-alphabet/index.js'
const POOL_SIZE_MULTIPLIER = 128
let pool, poolOffset
function fillPool(bytes) {
if (bytes < 0) throw new RangeError('Wrong ID size')
try {
if (!pool || pool.length < bytes) {
pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER)
crypto.getRandomValues(pool)
poolOffset = 0
} else if (poolOffset + bytes > pool.length) {
crypto.getRandomValues(pool)
poolOffset = 0
}
} catch (e) {
pool = undefined
throw e
}
poolOffset += bytes
}
export function random(bytes) {
fillPool((bytes |= 0))
return pool.subarray(poolOffset - bytes, poolOffset)
}
export function customRandom(alphabet, defaultSize, getRandom) {
let safeByteCutoff = 256 - (256 % alphabet.length)
if (safeByteCutoff === 256) {
let mask = alphabet.length - 1
return (size = defaultSize) => {
if (!size) return ''
let id = ''
while (true) {
let bytes = getRandom(size)
let i = size
while (i--) {
id += alphabet[bytes[i] & mask]
if (id.length >= size) return id
}
}
}
}
let step = Math.ceil((1.6 * 256 * defaultSize) / safeByteCutoff)
return (size = defaultSize) => {
if (!size) return ''
let id = ''
while (true) {
let bytes = getRandom(step)
let i = step
while (i--) {
if (bytes[i] < safeByteCutoff) {
id += alphabet[bytes[i] % alphabet.length]
if (id.length >= size) return id
}
}
}
}
}
export function customAlphabet(alphabet, size = 21) {
return customRandom(alphabet, size, random)
}
export function nanoid(size = 21) {
fillPool((size |= 0))
let id = ''
for (let i = poolOffset - size; i < poolOffset; i++) {
id += scopedUrlAlphabet[pool[i] & 63]
}
return id
}
+1
View File
@@ -0,0 +1 @@
let a="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";export let nanoid=(e=21)=>{let t="",r=crypto.getRandomValues(new Uint8Array(e|=0));for(;e--;)t+=a[63&r[e]];return t};
+48
View File
@@ -0,0 +1,48 @@
/**
* By default, Nano ID uses hardware random bytes generation for security
* and low collision probability. If you are not so concerned with security,
* you can use it for environments without hardware random generators.
*
* ```js
* import { nanoid } from 'nanoid/non-secure'
* const id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqLJ"
* ```
*
* @module
*/
/**
* Generate URL-friendly unique ID. This method uses the non-secure
* predictable random generator with bigger collision probability.
*
* ```js
* import { nanoid } from 'nanoid/non-secure'
* model.id = nanoid() //=> "Uakgb_J5m9g-0JDMbcJqL"
* ```
*
* @param size Size of the ID. The default size is 21.
* @typeparam Type The ID type to replace `string` with some opaque type.
* @returns A random string.
*/
export function nanoid<Type extends string>(size?: number): Type
/**
* Generate a unique ID based on a custom alphabet.
* This method uses the non-secure predictable random generator
* with bigger collision probability.
*
* @param alphabet Alphabet used to generate the ID.
* @param defaultSize Size of the ID. The default size is 21.
* @typeparam Type The ID type to replace `string` with some opaque type.
* @returns A random string generator.
*
* ```js
* import { customAlphabet } from 'nanoid/non-secure'
* const nanoid = customAlphabet('0123456789абвгдеё', 5)
* model.id = nanoid() //=> "8ё56а"
* ```
*/
export function customAlphabet<Type extends string>(
alphabet: string,
defaultSize?: number
): (size?: number) => Type
+23
View File
@@ -0,0 +1,23 @@
let urlAlphabet =
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
export let customAlphabet = (alphabet, defaultSize = 21) => {
return (size = defaultSize) => {
let id = ''
let i = size | 0
while (i-- > 0) {
id += alphabet[(Math.random() * alphabet.length) | 0]
}
return id
}
}
export let nanoid = (size = 21) => {
let id = ''
let i = size | 0
while (i-- > 0) {
id += urlAlphabet[(Math.random() * 64) | 0]
}
return id
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "nanoid",
"version": "5.1.16",
"description": "A tiny (118 bytes), secure URL-friendly unique string ID generator",
"keywords": [
"id",
"random",
"url",
"uuid"
],
"license": "MIT",
"author": "Andrey Sitnik <andrey@sitnik.es>",
"repository": "ai/nanoid",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": "./bin/nanoid.js",
"type": "module",
"sideEffects": false,
"browser": {
"./index.js": "./index.browser.js"
},
"types": "./index.d.ts",
"react-native": {
"./index.js": "./index.browser.js"
},
"exports": {
".": {
"types": "./index.d.ts",
"browser": "./index.browser.js",
"react-native": "./index.browser.js",
"default": "./index.js"
},
"./non-secure": {
"types": "./non-secure/index.d.ts",
"default": "./non-secure/index.js"
},
"./package.json": "./package.json"
},
"engines": {
"node": "^18 || >=20"
}
}
+2
View File
@@ -0,0 +1,2 @@
export let urlAlphabet =
'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@photo-gallery/sdk",
"version": "0.1.0",
"description": "A reusable, macOS Photos-style photo gallery for React / Next.js. The shadcn of photo galleries.",
"license": "MIT",
"type": "module",
"sideEffects": [
"*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./styles.css": "./src/styles/sdk.css"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist",
"src"
],
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@tanstack/react-virtual": "^3.10.8",
"clsx": "^2.1.1",
"exifr": "^7.1.3",
"framer-motion": "^11.11.9",
"leaflet": "^1.9.4",
"nanoid": "^5.0.7",
"zustand": "^4.5.5"
},
"_vendored": {
"from": "advance-photo-gallery-web-sdk/packages/photo-sdk",
"note": "Generated by scripts/sync-gallery-sdk.mjs — do not hand-edit. See vendor/README.md."
}
}
+122
View File
@@ -0,0 +1,122 @@
import { STORAGE_KEY } from '../constants';
import { normalizeMediaItem } from '../lib/media';
import type { Album, MediaItem } from '../types';
import type { PersistedState, StorageAdapter } from './types';
const CURRENT_VERSION = 1;
/**
* Default zero-config adapter. Persists library metadata to localStorage and
* (optionally) binary blobs to IndexedDB so imported files survive reloads.
*
* Safe under SSR: all browser APIs are guarded with `typeof window` checks.
*/
export function createLocalStorageAdapter(key: string = STORAGE_KEY): StorageAdapter {
const hasWindow = typeof window !== 'undefined';
return {
name: 'localStorage',
async load(): Promise<PersistedState | null> {
if (!hasWindow) return null;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as PersistedState;
if (!parsed || typeof parsed !== 'object') return null;
// Field-level validation — never trust persisted data blindly. Every media
// item is re-normalized (URL scheme allow-list, string coercion) so a
// tampered localStorage record cannot inject unsafe values into the UI.
const media: MediaItem[] = Array.isArray(parsed.media)
? parsed.media
.map((m) => normalizeMediaItem(m))
.filter((m): m is MediaItem => m !== null)
: [];
const albums: Album[] = Array.isArray(parsed.albums)
? parsed.albums
.filter((a): a is Album => Boolean(a) && typeof a === 'object')
.map((a) => ({
...a,
name: typeof a.name === 'string' ? a.name : 'Album',
mediaIds: Array.isArray(a.mediaIds)
? a.mediaIds.filter((id): id is string => typeof id === 'string')
: [],
}))
: [];
// Keep only string→string entries — never trust persisted data blindly.
const labelAliases: Record<string, string> =
parsed.labelAliases && typeof parsed.labelAliases === 'object'
? Object.fromEntries(
Object.entries(parsed.labelAliases).filter(
([k, v]) => typeof k === 'string' && typeof v === 'string',
),
)
: {};
const deletedLabels: string[] = Array.isArray(parsed.deletedLabels)
? (parsed.deletedLabels as unknown[]).filter((l): l is string => typeof l === 'string')
: [];
return {
media,
albums,
people: Array.isArray(parsed.people) ? parsed.people : [],
labelAliases,
deletedLabels,
version: typeof parsed.version === 'number' ? parsed.version : CURRENT_VERSION,
};
} catch {
return null;
}
},
async save(state: PersistedState): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.setItem(
key,
JSON.stringify({ ...state, version: CURRENT_VERSION }),
);
} catch {
// Quota exceeded or storage disabled — fail silently, app still works in-memory.
}
},
async putBlob(id: string, blob: Blob): Promise<string> {
if (!hasWindow) return '';
const db = await openBlobDb();
if (!db) return URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(blob, id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return URL.createObjectURL(blob);
},
async clear(): Promise<void> {
if (!hasWindow) return;
try {
window.localStorage.removeItem(key);
} catch {
/* noop */
}
},
};
}
let dbPromise: Promise<IDBDatabase | null> | null = null;
function openBlobDb(): Promise<IDBDatabase | null> {
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const req = indexedDB.open('photo-gallery-sdk-blobs', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('blobs')) db.createObjectStore('blobs');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
return dbPromise;
}

Some files were not shown because too many files have changed in this diff Show More