Client Portal

The Client Portal

A branded, self-serve site where Landscapt clients view their account, pay invoices, and act on estimates — entirely separate from staff login.

What it is

The client portal is a separate, public-facing Next.js route tree at /portal, distinct from the staff app. It is not a preview or an embed — it's its own login, its own layout shell, and its own set of pages, all scoped to a single Client record.

A client can view their balance and pay invoices online, see upcoming and completed job visits, review and act on open estimates (accept with an e-signature, decline, or request changes), submit support tickets, browse a shared document library, and manage their own contact info and saved payment method. None of this requires a staff account — portal users authenticate as their own Supabase Auth user, tagged with user_metadata.portal so they can never be confused with a staff login.

Every portal page is a server component that resolves the signed-in user to a client_id + org_id pair via getPortalContext(), then queries that client's own data — the same tables Landscapt staff use (crm_invoices, crm_job_visits, estimates, tickets), just filtered down to one client.

Giving a client access

Clients don't self-register. Access is invite-only, and staff control it from the client record:

  1. Open the client in Landscapt CRM, open the menu on the client header, and choose Send Portal Invite.
  2. Confirm (or edit) the email address. The dialog reads the client's current portal state: if an invite is already out, the button says Re-send invite and the dialog notes whether the earlier one has expired or simply hasn't been accepted yet; if the client already has an active portal login, it says so (with their last login) and offers Revoke access & send new invite instead — a client can only have one active portal account at a time.
  3. An invite email goes out via Resend, branded with the org's name, containing a link to /portal/register/[token]. If email delivery fails, the invite link is still returned to staff so it can be shared manually.
  4. The invite expires after 7 days. Sending a new invite for the same client automatically revokes any still-pending one.
  5. The client opens the link, sees the email pre-filled (read-only), sets a password (minimum 8 characters, one uppercase, one lowercase, one number), and is signed in automatically.

Checking where a client stands. The client's Details tab has a Client Portal block showing the current status: No access (never invited), Invited with the invite date and expiry — or Invite expired once the 7 days are up — or Active with the login email, registration date, and last login ("Never signed in" if they registered but haven't been back). A button beneath it reads Send portal invite, Re-send invite, or Manage portal access depending on that status, and opens the same dialog as the menu. Start here when a client says they can't log in.

Revoking access is the mirror action: the same menu's portal dialog offers a reset that soft-deletes the client_portal_users row (recording deleted_at, per this project's no-hard-delete rule) and also deletes the underlying Supabase Auth user outright, so the email address becomes available for a fresh invite. Any still-pending invite for that client is revoked at the same time.

Signing in, and multi-company clients

Portal sign-in lives at /portal/login — a completely separate page from staff login, with its own email/password form. On successful sign-in the app checks for user.user_metadata.portal; if it's missing (i.e. this is a staff account, not a portal account) the session is immediately signed back out with "No portal account found for this email." First-time visitors can also paste their invite link or bare token directly into the login page instead of clicking through email.

One real edge case the code explicitly handles: the same email address can be a client of two different Landscapt-using organizations. If someone accepts a second company's invite using an email that already has a portal account, registration links the new company's client_portal_users row to the existing auth user rather than creating a second login — the person then signs in once and picks which company to view.

That selection happens at /portal/select-org: if a signed-in portal user has more than one active org and hasn't chosen one yet (tracked via a portal_org_id cookie), they land on a picker showing each company's name, logo/accent color, and the client name under that company, before being dropped into that org's portal. A "Switch company" link on the Account page lets them return to the picker later.

Billing and online payment

The Billing tab lists every invoice split into Outstanding and History, with a running balance-due total and a PDF download for each invoice. A client with exactly one outstanding invoice gets a one-click Pay Now; with more than one, they pay each invoice individually from its row.

Payment runs through Stripe (via Stripe Elements/Payment Element), scoped to the org's connected Stripe account. Clients choose Card or Bank Transfer (ACH); starting a payment creates a payment intent, and the confirmation screen shows the amount due, any processing fee, and the total charge before the client confirms. On success the invoice balance updates within a few seconds (the UI refreshes the page automatically). If the org has no Stripe publishable key configured, the dialog simply says online payments aren't available yet, in place of the payment form.

A client can also save a payment method on their Account page for reuse, and remove it later; the Billing tab links there rather than duplicating that management UI.

Reviewing and accepting estimates

Only estimates the org has sent to that client (stage sent) show up, split into Pending Review and History. Three actions are available on an open, non-expired estimate:

  • Review & Accept — opens a dialog rendering the estimate's line items (respecting the same section grouping and display settings — quantities, line totals, section subtotals — staff configured on the estimate itself). If the estimate has line items, each one has its own checkbox, pre-checked, so a client can accept only part of an estimate; the dialog recalculates the selected total live. Typing a full name into the signature field and confirming posts the acceptance (and which line items were accepted, if partial) to the estimate. Lines the client left unchecked are marked lost on the estimate and are excluded from the Accepted Estimates by Service reports.
  • Decline — a confirmation dialog that notifies the org's team; no reason is required from the client. On the staff side the estimate moves to the Lost stage with the reason "Declined by client via portal" — the same outcome the office would record by hand, so it counts against Close Ratios and drops out of the open pipeline immediately.
  • Request Changes — a free-text message sent back to the org without accepting or declining; the estimate stays open and the button is replaced with a "Request sent" confirmation.
An estimate past its expires_at date is shown as Expired and its action buttons disappear, even if its underlying status is still sent or viewed.

Branding and portal settings

Staff configure the portal from Landscapt Settings → Client Portal. Every Landscapt Settings tab can be linked to directly with ?tab= — for example /crm/settings?tab=client-portal opens this tab, and switching tabs updates the address bar so the link you copy reopens the tab you were on. Settings are per-org — there is one client_portal_settings row per org, and everything a client sees is white-labeled to that org's own name, color, and logo rather than showing "Equipt" or "Landscapt" branding anywhere in the shell.

Company NameShown in the portal header, browser tab area, and outbound emails (invite email, etc.). Falls back to the org’s own name if blank.
Accent ColorUsed for the logo-placeholder tile and various accents when no logo is set.
Logo URLA public image URL. Recommended 200×60px PNG or SVG. Replaces the accent-color letter tile in the header when set.
Welcome MessageFree text; stored but not yet rendered anywhere in the current portal pages.
Support Phone / Support EmailShown in the header (phone) and mobile menu / footer (phone and email) as tel: and mailto: links.
Show EstimatesToggles the Estimates nav tab.
Allow TicketsToggles the Tickets nav tab and the ability to submit new tickets.
Document LibraryToggles the Documents nav tab.
Visible Ticket CategoriesA checklist of the org’s CRM ticket categories (Settings → CRM). Only checked categories appear in the client-facing “New Ticket” category dropdown — keep internal-only categories like “Collections” unchecked.

The same settings page also hosts the Document Library manager: staff upload files (title, category, optional description) that appear identically to every client of that org — it's one shared library, not per-client. Clients download files via a short-lived (5-minute) signed Supabase Storage URL generated on demand, so nothing client-facing ever holds a permanent public link to the file.

Gotchas

  • No portal access configured. A client with no client_portal_users row simply has no way in — there's no self-signup. If a client says they can't log in, check the portal status on their Details tab (No access / Invited / Invite expired / Active) before assuming a bug.
  • One portal account per client, but one login can span multiple clients/orgs. Don't confuse the two directions: a given Client record can only have one active portal account, but a given email address/person can be linked to several different orgs' client records and switch between them at /portal/select-org.
  • Feature toggles hide the tab, not the route. Turning off "Allow Tickets" etc. removes the nav item; verify server-side authorization separately if you're extending these pages, rather than assuming a hidden tab means the underlying data is inaccessible.
© 2026 Landscapt. All rights reserved.