Contacts — Technical Reference
This page covers the data models, workspace isolation, and import architecture behind the Contacts feature.
Data Models
contacts
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id — tenant isolation (ON DELETE CASCADE) |
email | text | Required. Contact's email address. Unique per workspace |
first_name | text | First name |
last_name | text | Last name |
phone | text | Phone number |
source | text | How the contact was created: manual, form, import, eventbrite, stripe |
address_line1 | text | Street address line 1 |
address_line2 | text | Street address line 2 |
city | text | City |
state | text | State or province |
postal_code | text | ZIP or postal code |
country | text | Country |
birth_month | integer | Birthday month (1–12). Must be paired with birth_day |
birth_day | integer | Birthday day (1–31). Must be paired with birth_month |
is_subscribed | boolean | Whether the contact has opted in to marketing emails |
opt_in_timestamp | timestamptz | When consent was given |
opt_in_source | text | Where consent originated (form ID, "CSV Import", etc.) |
opt_in_ip | inet | IP address at time of consent |
unsubscribed_at | timestamptz | When the contact unsubscribed (NULL if subscribed) |
assigned_user_id | uuid | FK → user_profiles.id — assigned workspace member |
portal_token | uuid | Client portal authentication token |
created_at | timestamptz | When the contact was created |
updated_at | timestamptz | When the contact was last modified |
Unique constraint: UNIQUE(workspace_id, email) — one contact per email per workspace.
transactions
| Column | Type | Description |
|---|---|---|
id | uuid | Primary key |
workspace_id | uuid | FK → workspaces.id — tenant isolation (ON DELETE CASCADE) |
contact_id | uuid | FK → contacts.id (ON DELETE SET NULL) |
product_id | uuid | FK → products.id (ON DELETE SET NULL) |
amount_paid | numeric(10,2) | Amount paid |
currency | text | Currency code (e.g. 'usd') |
external_stripe_transaction_id | text | Composite key ${sessionId}_${productId} (unique constraint) |
stripe_payment_intent_id | text | Stripe Payment Intent ID (e.g. pi_xxx) for refund matching; null for free/fully discounted orders |
status | text | 'succeeded' or 'refunded' (CHECK constraint) |
amount_refunded | numeric(10,2) | Total amount refunded |
transaction_date | timestamptz | Date of the transaction |
created_at | timestamptz | Record creation timestamp |
Unique constraint: UNIQUE(external_stripe_transaction_id) — prevents duplicate purchase recording.
Architecture
Workspace Isolation
Contacts are strictly isolated by workspace. Two workspaces can each have a contact with
jane@example.com — they are completely independent records. This isolation is enforced
at the database level using PostgreSQL Row-Level Security (RLS), meaning it cannot be
bypassed by application bugs.
Contact Source Enum
The source field tracks how the contact entered the workspace:
| Value | Trigger |
|---|---|
manual | Created by a workspace member via the dashboard (default) |
form | Captured via a Gordon CRM form submission |
import | Uploaded via CSV import |
eventbrite | Auto-synced from an Eventbrite event registration |
stripe | Auto-created from a Stripe purchase |
If no source is provided during creation, it defaults to manual.
CSV Import Pipeline
The CSV import runs as a single atomic database transaction. If any critical error occurs, everything rolls back — no partial data is committed.
Upsert strategy — keyed on workspace_id + email:
- Parse and validate each row (email required, birthday validation)
- For each row, check if a contact with that email already exists
- New contacts → INSERT with all provided fields
- Existing contacts → UPDATE only non-empty CSV columns (blank columns are skipped to prevent accidental data erasure)
- Process tags: create missing tags, insert
contact_tagsrecords withON CONFLICT DO NOTHING - Process notes: insert
notesrecords with type'contact' - If "Mark as Subscribed" is enabled, record consent proof (preserving existing consent for already-subscribed contacts)
Birthday validation:
birth_monthandbirth_daymust both be provided or both omitted- Valid ranges: month 1–12, day 1–31
- Invalid values are skipped per-row with an error logged
Error handling:
- Invalid rows are skipped, not failed
- Error report includes row number and reason for each skip
- All valid rows are processed regardless of individual row failures
Email Change Behavior
When a contact's email is updated via updateContact():
- Duplicate Email Prevention: The server action catches database unique constraint violations (Postgres error code
23505on the(workspace_id, email)composite key) and returns a friendly error message:"A contact with this email already exists in this workspace." - Suppression Cleanup:
bouncesuppressions are automatically deleted (inbox reputation resets with the new address). - Suppression Preservation:
complaintandunsubscribesuppressions are explicitly preserved (respecting human preferences).
See Subscriptions & Consent — Technical Reference for the full suppression lifecycle.
Contact Detail Query (getContactById)
To populate the Contact Detail view, the getContactById server action retrieves the contact record and performs database joins to resolve:
updated_by_profile: Resolves the coworker profile (first_name,last_name) who last modified the contact record.- Creator Details: Resolves the author profile if the contact was manually created.
These fields feed the unified vertical audit trail in the UI, displaying the creators/modifiers and dates clearly.
Consent Audit Lifecycle
Marketing consent changes (opt-in or opt-out) write records to a dynamic consent audit timeline:
- Tracking Variables: Captures the opt-in timestamp, the source channel (e.g. Stripe checkout, Eventbrite webhook, manual dashboard consent checkbox), and the client IP address.
- Audit Trails: Rendered chronologically in the timeline, ensuring that every transition in a contact's email status is logged with audit proof.
Stripe Webhook Ingestion
The /api/webhooks/stripe endpoint handles incoming Stripe Connect checkout events (checkout.session.completed) to automatically populate contact records and purchase ledgers:
- Workspace Resolution: In production, the workspace is resolved via the Connect platform's
stripe_account_idmatching a record in thestripe_connectionstable. In development or test environments whereevent.accountmay be absent, a fallback mechanism attempts to match the workspace by queryingstripe_connectionsfor a single record matching the event'slivemode. - Data Capture: Ingests the customer's email, name, phone, and full billing address (
address_line1,address_line2,city,state,postal_code,country) from Stripe'scustomer_details.[!TIP] For the phone and billing address fields to sync, administrators must configure their Stripe Payment Links in the Stripe Dashboard to "Require phone number" and "Collect billing address".
- Transaction Recording: Upserts a record in the
transactionstable mapping the purchase to the resolved contact. This captures the product ID, currency, amount paid, and stages the Stripe Payment Intent ID (stripe_payment_intent_id) to enable subsequent refund tracking. If the checkout represents a free or fully discounted order, the Payment Intent ID is recorded asnull. - Auto-Subscription Opt-In: If the workspace's Stripe Connection has
auto_subscribe_purchasersenabled, the webhook automatically attempts to opt the purchaser into marketing emails:- Spam Complaint Guard: If the contact has an active permanent
complaintor manual block (manual) suppression, the auto-subscribe action is bypassed. - Opt-in Chain of Custody Preservation: If the contact is already cleanly subscribed (
is_subscribed = trueandunsubscribed_atisnull), the system skips updating opt-in metadata (preservingopt_in_source,opt_in_timestamp, andopt_in_ipfor compliance). - Subscription State: For eligible purchasers who are not subscribed, the system sets
is_subscribed = true, setsopt_in_source = 'stripe_auto_subscribe', records the opt-in timestamp, and clearsunsubscribed_at. - Suppression Cleanup: Automatically deletes active non-hard suppressions (
bounce,unsubscribe) for the contact, resetting their email eligibility.
- Spam Complaint Guard: If the contact has an active permanent
- Intelligent Overwrite Guard: Rather than using a blind upsert, the CRM uses a fetch-then-conditional-update strategy to avoid overwriting existing contact details:
- New Contacts: A new contact is created with all available Stripe information. No audit logs are created.
- Existing Contacts: The CRM compares incoming Stripe data against the existing record, using the same diff-and-audit mechanism described below.
System Notes and Detail Overwrite Audits (diffAndUpdateContact)
To ensure that automated data ingestion does not silently overwrite existing contact details, the CRM utilizes a shared helper diffAndUpdateContact (in src/lib/utils/contact-diff.ts) during real-time contact creation and update paths:
- Diff Tracking: Compares incoming fields (
first_name,last_name,phone) against the existing record. US phone numbers are normalized (digits stripped, leading+1prefix removed for 11-digit numbers) before comparison to prevent false-positive change detections. - Single Merged Write: Consolidates all changed fields with any additional updates (e.g. consent fields or source) and runs a single update query to minimize database load.
- System Notes: If any existing non-empty fields are overwritten, the helper automatically writes a dual-write (HTML + plain-text) system note to the
notestable (settingcreated_by = NULL), which renders in the client timeline under the author"⚡ System". - Application Paths: Integrated into Form Submissions (
POST /api/forms/[id]), Form Verification (GET /api/forms/verify), Eventbrite Registration Ingestion (syncOrderAttendees), and Eventbrite Attendee Updates (handleAttendeeUpdated). It is bypassed in bulk import paths (CSV import, historical Eventbrite backfills) to optimize performance. - Concurrency & Race Conditions: Webhook and public API routes wrap contact creation in a retry loop. If a unique constraint violation (error code
23505) occurs (because the contact was created between the initial check and insert), the code catches the error, re-fetches the existing contact, and routes updates throughdiffAndUpdateContact.
Stripe Refund Reconciliation
The CRM handles the charge.refunded Stripe webhook event using an automated 3-Tier Refund Matching Engine to reconcile purchases and update the transactions ledger.
The 3-Tier Matching Engine
When a refund is initiated in Stripe, the webhook maps the event to CRM transaction rows using the stripe_payment_intent_id (Stripe Payment Intent ID). The engine processes the refund in one of three tiers based on the refund amount and remaining balances:
-
Tier 1: Full Refund
- Trigger: The refund amount (delta) is equal to or greater than the total remaining un-refunded balance across all candidate transaction rows for that Payment Intent.
- Action: All candidate transaction rows are marked as
'refunded'withamount_refundedset to their fullamount_paid. - Side Effects: Fires the
product_refundedautomation trigger for each refunded product. Auto-closes any open refund review tasks for this payment intent using the reason: "Automatically closed — a full refund was processed in Stripe for this transaction."
-
Tier 2: Exact Single-Product Match
- Trigger: The refund amount matches the remaining un-refunded balance of exactly one transaction row for that Payment Intent.
- Action: That matching transaction row is updated to
'refunded'with itsamount_refundedset to its fullamount_paid. - Side Effects: Fires the
product_refundedautomation trigger for the matched product. Auto-closes open review tasks for this payment intent using the reason: "Automatically closed — all refunds for this transaction have been reconciled."
-
Tier 3: Ambiguous or Partial Refund
- Trigger: The refund amount does not match any single product's remaining balance, or it matches multiple products' remaining balances (making auto-resolution ambiguous).
- Action: The system generates a manual "Review Refund" task assigned to the workspace owner.
- Side Effects: Auto-closes (supersedes) previous open review tasks for the same payment intent. No
product_refundedautomation is triggered at this stage, as the refund must be manually reconciled.
Review Task UX & Timezone-Aware Due Dates
For Tier 3 review tasks, the CRM creates a detailed, user-friendly task description containing:
- Original purchase date and list of products (with already-refunded items clearly annotated).
- An explanation of why the refund could not be auto-applied (e.g. amount did not match any single product, or matched multiple).
- Clear action options (A: process additional refund in Stripe, B: navigate to contact profile and record partial refund).
- Timezone-Aware Due Date: The review task is set as due "tomorrow", with the due date calculated using the workspace owner's IANA timezone (from
user_profiles.timezone) to prevent UTC drift. The date is stored as aYYYY-MM-DDdate-only string in thetaskstable.
Task Auto-Closure & System Audit Notes
When a refund is reconciled (Tier 1/2) or updated (Tier 3), any open refund review tasks associated with the stripe_payment_intent_id are automatically closed. The auto-closure logic:
- Sets the task's
statusto'completed'and updates thecompleted_attimestamp. - Inserts a system-authored audit note in the
notestable with type'task'(withcreated_by = NULL, which renders as"⚡ System"in the UI) explaining the reason for closure.
Checkout Idempotency Hardening
To prevent duplicate data recording and accidental automation triggers from replayed Stripe webhook events:
- If the transaction is already recorded, the webhook is a complete no-op (no duplicate transaction records, no duplicate automation rule executions, and no timestamp modifications).
Administrative Stripe Backfill Infrastructure
To reconcile historical records that predate the Stripe webhook integration, the CRM provides administrative backfill endpoints and a local CLI orchestration script. These tools reconstruct the database ledger without causing duplicate triggers or user alerts.
1. Payment Intent Backfill Endpoint (GET /api/admin/backfill-payment-intents)
- Purpose: Retrieves Stripe Payment Intent IDs for historical transaction rows lacking one.
- Mechanism: Parses the composite external ID key (
external_stripe_transaction_idformat:[session_id]_[product_id]) to extract the Stripe checkout session ID. It then queries the Stripe API (checkout.sessions.retrieve()) to fetch the corresponding payment intent. - Self-Draining Offset-0 Pagination: Queries rows where
stripe_payment_intent_id IS NULL. To ensure the query pool drains even when errors occur, the endpoint writes one of four sentinel values to the database:- Real
pi_xxxvalue: Successfully matched. 'none': Free order or 100% discount checkouts.'error_fetching': Stripe API or network lookup failure.'invalid_format': Composite key format mismatch.
- Real
- Tenant Isolation & Security: Scoped with a
Stripe-AccountConnect header. It enforces a disconnected workspace check, failing immediately if the workspace does not have a Stripe connection.
2. Refund Backfill Endpoint (GET /api/admin/backfill-refunds)
- Purpose: Retroactively synchronizes Stripe refunds with the database transactions ledger.
- Mechanism: Queries Stripe's refunds API with optional date range query parameters (
sinceanduntilUnix timestamps) to fetch charges. It builds the brackets query parameters (created[gte]andcreated[lte]) manually to prevent standardURLSearchParamsformatting errors. - Processing: Deduplicates refund events by charge ID. For each charge, it retrieves the full Stripe charge resource and passes it through the shared 3-Tier Refund Matching Engine.
- No Side-Effects: Enforces
skipAutomations = true, silencing campaign auto-enrollments, internal notifications, and review task creations. - Cursor-Based Pagination: Returns a
nextCursorvalue mapping to Stripe'sstarting_aftercursor parameter for sequential execution.
3. CLI Orchestration Script (scripts/run-backfill.js)
- Purpose: Local administrative script to orchestrate the backfill stages.
- Supabase Service Role Bypass: Authenticates using the Supabase Service Role key (
SUPABASE_SERVICE_ROLE_KEY), bypassing Row-Level Security (RLS) to modify database records directly. - Orchestration Sequence:
- Runs the payment intent backfill loop until the
remainingcount reaches0. - Runs the refund backfill loop with cursor pagination to reconcile all historical refunds.
- Runs the payment intent backfill loop until the
- Robustness: Manages Stripe API rate-limits, handles network retries, and outputs formatted console progress metrics.
[!NOTE] Detailed operational instructions, dev and production runbooks, environment variable requirements, and troubleshooting tables for the backfill script are documented inside the repository at
scripts/README.md.
Unified Transactions Feed
The getContactTransactions() server action builds a unified transaction feed for the contact details page. This feed integrates:
- Direct purchases from the
transactionstable (such as Stripe product purchases). - Paid event registrations from the
event_registrationstable. - Completed payments from the
deal_paymentstable (where the deal is associated with the contact and the payment status is'paid'). These entries are formatted as[Deal Title] — [Payment Label].
All entries are merged and sorted chronologically (newest first) by the payment or creation date.
Phone Number Formatting
For display purposes across the CRM dashboard (such as the contacts list, contact detail card, and import preview tables), phone numbers are normalized using a cosmetic utility:
- US Numbers: US country code numbers in E.164 format (
+1XXXXXXXXXX) and other 10-digit formats are formatted as(XXX) XXX-XXXX. - Other Formats: Non-US or unrecognized number formats are rendered as-is.
- Database Scope: This formatting is purely cosmetic and does not alter the raw stored text in the Supabase database or editing forms.
Contact List Query & Email Status Badges
The /contacts list page queries contact profiles along with their associated tags and suppression logs in a single query. The 5-tier email eligibility state is calculated dynamically on the client side at runtime using a priority hierarchy:
- Spam: Contact has a suppression record with reason
'complaint'(rendered in dark red). - Bounced: Contact has a suppression record with reason
'bounce'(rendered in orange). - Unsubscribed: Contact's
unsubscribed_attimestamp is set (rendered in red). - Subscribed: Contact has
is_subscribed = true(rendered in green). - Not Opted In: Default fallback state (rendered in slate).
getContacts Server Action
The getContacts server action retrieves a paginated, filtered, and sorted list of contacts.
export interface ContactListParams {
page?: number;
pageSize?: number;
search?: string;
tagId?: string;
tagIds?: string[];
tagMode?: "any" | "all";
excludeTagIds?: string[];
source?: string;
eventId?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
}Filtering Parameters:
tagIds: An array of tag UUID strings to filter contacts by.tagMode: Determines how multi-tag selection is resolved. Supports"any"(default) or"all".excludeTagIds: An array of tag UUID strings representing contacts to exclude.source: An exact-match source string (e.g.'manual','form','import').tagId: Legacy. Preserved single-tag filter parameter for backward compatibility (e.g., when deep-linking from events).eventId: Filters contacts registered for a specific event.
Tag Filtering Resolution:
The server action processes tag filters sequentially using in-memory set algebra:
- Multi-tag AND (
tagMode = 'all'): For each tag intagIds, it queriescontact_tagsfor matchingcontact_idarrays. It then computes the intersection of these arrays (contacts must possess all selected tags). - Multi-tag OR (
tagMode = 'any'): Queriescontact_tagsmatching any tag intagIdsusing.in()and deduplicates the resultingcontact_idarray in memory (contacts must possess at least one selected tag). - Tag Exclusion (
excludeTagIds): Queries contacts matching any excluded tag and generates a subtraction set. If include tags were active, it subtracts these IDs from the included contact ID set. If no include tags were active, it fetches all contact IDs in the workspace and subtracts the excluded set from it.
createContact Server Action
The createContact server action creates a new contact record in the workspace:
export async function createContact(params: {
workspaceId: string;
email: string;
firstName?: string;
lastName?: string;
phone?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
birthMonth?: number;
birthDay?: number;
isSubscribed?: boolean;
}): Promise<{ data?: ContactRecord; error?: string }>- Validation: Enforces unique email checks per workspace and rejects birth month/day mismatches.
- Fields Capture: Natively supports full address fields and birthday month/day parameters on initial creation.
updateContactSource
The updateContactSource action manually updates a contact's original entrance source:
export async function updateContactSource(
contactId: string,
workspaceId: string,
newSource: string,
reason: string
): Promise<{ success?: boolean; error?: string }>- Role-Based Access Control (RBAC): Validates that the executing user's role is either
'owner'or'admin'within the workspace, rejecting the operation withInsufficient permissionsotherwise. - System Audit Note: On success, inserts a system-authored audit note (
createSystemNote) in the contact's timeline, logging the change (e.g.🔄 Source manually changed from '{old}' to '{new}' by {user}. Reason: {reason}).
getDistinctSources
The getDistinctSources action retrieves all unique contact sources present in the workspace:
export async function getDistinctSources(workspaceId: string): Promise<{
data: string[];
}>It queries the contacts table for all non-null source values, filters out blanks, deduplicates them in-memory (to bypass PostgREST's lack of SELECT DISTINCT support), sorts the values alphabetically, and returns them to populate the filter builder radio picker.
Sorting Parameters:
sortBy: Specifies the column to sort by. Supported values:"name": Sorts alphabetically byfirst_nameascending/descending, then bylast_nameascending/descending."source": Sorts alphabetically by the contact creation source (e.g.'manual','form','import')."created_at"(default): Sorts by contact creation date (created_at).
sortDir: The sort direction ("asc"or"desc"). Defaults to"desc".
recordTransactionRefund
The recordTransactionRefund action records a full or partial refund for a specific product purchase transaction:
export async function recordTransactionRefund(params: {
transactionId: string;
refundAmount: number; // Refund amount in dollars
runAutomations: boolean; // Whether to fire trigger side effects (full refunds only)
}): Promise<{ success: boolean; error?: string }>- Validation: Ensures the refund amount is greater than
0and does not exceed the transaction's remaining balance. It clamps the value using cents-based calculation (Math.round(amount * 100)) to prevent floating-point overshoot. - Ledger Update: Updates
amount_refunded(reconciling with cents math) and transitionsstatusto'refunded'if the remaining balance reaches zero. - Automation Side Effects: If
runAutomationsis set totrueand the transaction is fully refunded, it fires theproduct_refundedautomation trigger. - Task Auto-Closure: If a payment intent is fully balanced (all sibling transactions are fully refunded), it auto-closes any open refund review tasks associated with the payment intent.
revertTransactionRefund
The revertTransactionRefund action reverts a transaction's refund status:
export async function revertTransactionRefund(
transactionId: string
): Promise<{ success: boolean; error?: string }>- Ledger Reset: Resets the transaction's
statusto'succeeded'and clears theamount_refundedcolumn to0.
Related Entity Connections
A contact can be connected to many entities through foreign key relationships:
| Entity | Table | FK Column | Cascade |
|---|---|---|---|
| Tags | contact_tags | contact_id | ON DELETE CASCADE |
| Notes | notes | contact_id | ON DELETE CASCADE |
| Companies | company_contacts | contact_id | ON DELETE CASCADE |
| Deals | deals | contact_id | ON DELETE SET NULL |
| Tasks | tasks | contact_id | ON DELETE SET NULL |
| Appointments | appointment_external_attendees (junction) | contact_id | ON DELETE CASCADE |
| Campaign Enrollments | campaign_enrollments | contact_id | ON DELETE CASCADE |
| Event Registrations | event_registrations | contact_id | ON DELETE CASCADE |
| Transactions | transactions | contact_id | ON DELETE SET NULL |
| Email Sends | email_sends | contact_id | ON DELETE SET NULL |
| Suppressions | contact_suppressions | contact_id | ON DELETE CASCADE |
| Agreements | agreement_contacts | contact_id | ON DELETE CASCADE |
Security
RLS Policies
| Operation | Policy |
|---|---|
| SELECT | Workspace members can view contacts in their workspace |
| INSERT | Workspace members can create contacts in their workspace |
| UPDATE | Workspace members can edit contacts in their workspace |
| DELETE | Workspace members can delete contacts in their workspace |
Transactions RLS Policies
| Operation | Policy |
|---|---|
| SELECT | Workspace members can view transactions in their workspace |
| INSERT | Workspace members can insert transactions in their workspace |
| UPDATE | Workspace members can update status and amount_refunded on transactions in their workspace |
| DELETE | Workspace members can delete transactions in their workspace |
Database Triggers
contacts_protect_source (Migration 079)
To ensure the integrity of ingestion tracking, a BEFORE UPDATE trigger is registered on the contacts table to prevent automated processes from overwriting a contact's original source column:
- Automated context block: Webhooks, form endpoints, and sync scripts authenticate using Supabase's service role key (
createAdminClient()), which does not set an active user session (auth.uid() = NULL). IfOLD.sourceis set, the trigger silently forcesNEW.source := OLD.source. - Manual override allowance: If
auth.uid()is present (meaning a workspace member initiates the update through an authenticated client session) and the user has'owner'or'admin'role, the trigger permits the source change.
Performance & Hydration Optimization
Server/Client Page Split & Lazy Loading
To eliminate initial-load waterfalls and optimize performance, the contacts directory has been split into dedicated server and client layers:
- Server Component (
pages/contacts/page.tsx): Resolves cookies and active workspace context, fetching only page 1 data and basic metadata in a single server-side step to support fast initial HTML delivery. - Client Component (
contacts-client.tsx): Handles client state, search input filters, active query parameter parsing, sorting headers, and table pagination. - Dynamic Lazy Loading: Defer chunk loading for heavy dialogs (
AddContactDialog,ImportCsvDialog, andImportResultsDialog) usingnext/dynamicdynamic imports withssr: false. These components are only downloaded and mounted when a user clicks the respective action button, reducing the bundle size of the initial contacts dashboard. - Hydration & Flash Guards:
- isFirstQueryRef Guard: A client-side mounting reference guard that prevents firing duplicate data fetch requests during initial mounting.
- Workspace Sync Gate: When switching workspaces, a
useEffectsynchronization hook compares the active workspace ID and resets pagination index and query filters only when a workspace transition occurs, preventing UI flashes. - DOM Hydration Warning Fix: Replaced nested
divcontainers with blockspancomponents inside dialog descriptions to enforce strict HTML validation and prevent React hydration mismatch warnings.
Mobile Viewport Containment & Scroll-Spy Navigation
To resolve horizontal page overflows and coordinate sticky navigation across contact detail pages on small screens:
- Global Viewport Containment: Added
overflow-x-hiddenon the document body to contain horizontal page drift without breaking sticky positions. - Flex Container Shrinkage:
min-w-0is propagated down layout containers, grid rows, and card headers to let flex/grid items shrink cleanly on small screens, preventing card blowouts. Card lists (Tasks, Courses, Agreements, etc.) are insulated withw-fullandoverflow-x-hiddento force text truncation. - Coordinate-Based Navigation: Native
.scrollIntoView()is replaced with coordinate-based.scrollTo()calculations in React and vanilla click handlers to eliminate jitter. Viewport middle comparisons trigger tab transitions:- Top edge crossing the middle of the viewport scrolling down triggers a forward tab transition.
- Bottom edge crossing the middle of the viewport scrolling up triggers a backward tab transition.
- Calculation removes dependency on header height lookups.
- Scroll Lock Safety: The programmatic scroll-lock safety timeout is increased to 2000ms.
- Vanilla Bootstrapper: An inline
<script>tags block installs lightweight click handlers and scroll tracking before hydration to ensure instant mobile utility. The vanilla scroll handler is cleanly unbound upon React mount to prevent event duplication or race conditions. - Coordination: Uses
suppressHydrationWarningon root divs to permit pre-hydration DOM mutations by the bootstrapper without React runtime errors.