Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d82d71d0b2 | |||
| a459fefb7f | |||
| f634312b83 |
@@ -14,83 +14,3 @@ 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.
|
||||
|
||||
@@ -42,7 +42,3 @@ next-env.d.ts
|
||||
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
certificates
|
||||
AI_ASSISTANT_BACKEND_PLAN.md
|
||||
TEAM_MANAGEMENT_BACKEND_PLAN.md
|
||||
@@ -1,6 +1,2 @@
|
||||
@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}
|
||||
|
||||
@@ -1,827 +0,0 @@
|
||||
# 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 | 1‑to‑many | all leads tenant-scoped |
|
||||
| tenant → verification | 1‑to‑many | |
|
||||
| lead → phones | 1‑to‑many | ≥1, exactly one primary |
|
||||
| lead → emails | 1‑to‑many | 0..N |
|
||||
| lead → attachments | 1‑to‑many | 0..N (pending) |
|
||||
| lead → status history | 1‑to‑many | audit |
|
||||
| verification → activities | 1‑to‑many | timeline |
|
||||
| verification → lead | 1‑to‑(0..1) | promotion link, bidirectional FK |
|
||||
| member → lead (assigned/canvasser/creator) | many‑to‑1 each | nullable |
|
||||
| member → verification (assignee) | many‑to‑1 | 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 1‑to‑many.
|
||||
|
||||
---
|
||||
|
||||
## 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 V5–V7 |
|
||||
| `crm.leadVerification.get` | `{ id }` | `VerificationDTO` (+ activities) | detail V16–V18 |
|
||||
| `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.*
|
||||
@@ -1,230 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,240 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +1,34 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// The marketing landing is served as static files from /public (ported verbatim
|
||||
// from lynkeduppro-landing). These rewrites reproduce the landing's vercel.json
|
||||
// clean-URL routing: "/" is the React marketing home, "/1".."/19" are the static
|
||||
// pages under /public/page. `beforeFiles` lets them override the app router's "/"
|
||||
// (which otherwise redirects to /portal/login).
|
||||
const landingRewrites: { source: string; destination: string }[] = [
|
||||
{ source: "/", destination: "/index.html" },
|
||||
{ source: "/thanks", destination: "/thanks.html" },
|
||||
{ source: "/1", destination: "/page/index.html" },
|
||||
// "/2".."/19" -> /public/page/<n>.html
|
||||
...Array.from({ length: 18 }, (_, i) => i + 2).map((n) => ({
|
||||
source: `/${n}`,
|
||||
destination: `/page/${n}.html`,
|
||||
})),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 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.
|
||||
// The SDK ships ESM/TS; let Next transpile it.
|
||||
transpilePackages: ["@abe-kap/appshell-sdk"],
|
||||
async rewrites() {
|
||||
// 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 /api/* route handlers (geo, and the founder checkout).
|
||||
const bff = process.env.BFF_ORIGIN ?? "http://localhost:4000";
|
||||
return [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }];
|
||||
return {
|
||||
beforeFiles: landingRewrites,
|
||||
afterFiles: [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,31 +6,19 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"sync:gallery-sdk": "node scripts/sync-gallery-sdk.mjs"
|
||||
"lint": "eslint"
|
||||
},
|
||||
"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",
|
||||
"tesseract.js": "5.1.1"
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* global React, ReactDOM, Nav, Hero, Manifesto, TrustMarquee, LiveMetrics, SpecSheet, CoreFeatures, ConnectedOps, MobileSection, Analytics, FieldIntelligence, Testimonials, Pricing, FinalCTA, Footer */
|
||||
|
||||
const { useEffect, useRef, useState } = React;
|
||||
|
||||
function App() {
|
||||
const glowRef = useRef(null);
|
||||
const progressRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cursor glow follower
|
||||
const onMove = (e) => {
|
||||
if (glowRef.current) {
|
||||
glowRef.current.style.left = e.clientX + "px";
|
||||
glowRef.current.style.top = e.clientY + "px";
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
|
||||
// Scroll progress
|
||||
const onScroll = () => {
|
||||
if (progressRef.current) {
|
||||
const h = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const pct = h > 0 ? (window.scrollY / h) * 100 : 0;
|
||||
progressRef.current.style.width = pct + "%";
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build particle set once
|
||||
const particles = React.useMemo(() => {
|
||||
return Array.from({ length: 26 }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 18,
|
||||
dur: 18 + Math.random() * 22,
|
||||
size: 1 + Math.random() * 2.5,
|
||||
opacity: 0.3 + Math.random() * 0.6,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="bg-layer"/>
|
||||
<div className="bg-grid"/>
|
||||
<div className="particles">
|
||||
{particles.map(p => (
|
||||
<span key={p.id} className="particle" style={{
|
||||
left: `${p.left}%`,
|
||||
bottom: "-10px",
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
animationDuration: `${p.dur}s`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
opacity: p.opacity,
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="cursor-glow" ref={glowRef}/>
|
||||
<div className="scroll-progress" ref={progressRef}/>
|
||||
|
||||
<Nav/>
|
||||
<Hero/>
|
||||
<TrustMarquee/>
|
||||
<Manifesto/>
|
||||
<LiveMetrics/>
|
||||
<SpecSheet/>
|
||||
<CoreFeatures/>
|
||||
<ConnectedOps/>
|
||||
<MobileSection/>
|
||||
<Analytics/>
|
||||
<FieldIntelligence/>
|
||||
<Testimonials/>
|
||||
<Pricing/>
|
||||
<FinalCTA/>
|
||||
{/* <Footer/> hidden — links were all pointing to Book Demo; revisit later */}
|
||||
|
||||
{/* Sticky system pill */}
|
||||
<div className="sys-pill">
|
||||
<span className="dot"/>
|
||||
<span>SYS · ALL GREEN</span>
|
||||
<span style={{ color: "var(--cyan-2)" }}>| 142 MS</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
|
||||
@@ -0,0 +1,649 @@
|
||||
/* global React, Reveal, GlowCard, Icon, Sparkline, DonutChart, BlueprintHouse, Counter */
|
||||
// Features, Connected Operations, Mobile
|
||||
|
||||
const FEATURES = [
|
||||
{ code: "M.01", name: "AI Copilot", icon: "ai", color: "var(--cyan)",
|
||||
desc: "An intelligent operator that thinks ahead, rerouting crews, flagging risk, and surfacing next best actions in real time.",
|
||||
tag: "ALWAYS ON" },
|
||||
{ code: "M.02", name: "Smart Scheduling", icon: "calendar", color: "var(--orange)",
|
||||
desc: "Auto-organizes routes, weather, conflicts and dependencies. Suggests the perfect time, every time.",
|
||||
tag: "ADAPTIVE" },
|
||||
{ code: "M.03", name: "Crew Automation", icon: "crew", color: "var(--green)",
|
||||
desc: "Dispatch, check-ins, and timecards run themselves. Field teams stay aligned without the chatter.",
|
||||
tag: "HANDS-FREE" },
|
||||
{ code: "M.04", name: "GPS Fleet Tracking", icon: "fleet", color: "var(--violet)",
|
||||
desc: "Live vehicle telemetry, geofenced job zones, ETA forecasting, and route optimization at a glance.",
|
||||
tag: "REAL-TIME" },
|
||||
{ code: "M.05", name: "Budget Intelligence", icon: "budget", color: "var(--cyan)",
|
||||
desc: "Predictive margins, automated cost capture, and instant variance alerts before they bite.",
|
||||
tag: "PREDICTIVE" },
|
||||
{ code: "M.06", name: "Real-time Reporting", icon: "report", color: "var(--orange)",
|
||||
desc: "Production, profit, and performance, generated continuously from connected operations data.",
|
||||
tag: "LIVE" },
|
||||
{ code: "M.07", name: "Client Communication", icon: "chat", color: "var(--green)",
|
||||
desc: "Branded portal, automated touchpoints, and a single thread per project. Nothing falls through.",
|
||||
tag: "BRANDED" },
|
||||
{ code: "M.08", name: "Field Intelligence", icon: "field", color: "var(--violet)",
|
||||
desc: "AI-powered measurements, photo evidence, claim-ready reports and on-site decisions in minutes.",
|
||||
tag: "AI VISION" },
|
||||
];
|
||||
|
||||
// ============ SPEC SHEET - six-system architecture =============
|
||||
function SpecSheet() {
|
||||
const systems = [
|
||||
{ code: "S.01", name: "CRM", icon: "lead", color: "var(--cyan)", desc: "Pipeline, leads & relationships" },
|
||||
{ code: "S.02", name: "Canvas", icon: "doc", color: "var(--orange)", desc: "Business development" },
|
||||
{ code: "S.03", name: "Measurements",icon: "target", color: "var(--green)", desc: "AI roof & site dimensions" },
|
||||
{ code: "S.04", name: "Client", icon: "chat", color: "var(--violet)", desc: "Branded portals & comms" },
|
||||
{ code: "S.05", name: "Billing", icon: "money", color: "var(--cyan)", desc: "Invoicing, payments, AR" },
|
||||
{ code: "S.06", name: "Estimating", icon: "report", color: "var(--orange)", desc: "Proposals that close" },
|
||||
];
|
||||
return (
|
||||
<section className="section tight" id="systems">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 48, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§04</span>
|
||||
<span>SPEC SHEET · CORE STACK</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22 }}>
|
||||
Six systems.<br/>
|
||||
<span className="accent">One intelligent CRM/ERP.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="row gap-12">
|
||||
<span className="dim-tag">// ALL CONNECTED</span>
|
||||
<span className="dim-tag">// ALL AUTOMATED</span>
|
||||
<span className="dim-tag" style={{ color: "var(--orange-2)", borderColor: "rgba(255,138,30,0.4)" }}>// BUILT TO PERFORM</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<div style={{
|
||||
position: "relative",
|
||||
padding: "44px 36px 32px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 20,
|
||||
background: `
|
||||
repeating-linear-gradient(0deg, transparent 0 26px, rgba(0,209,255,0.04) 26px 27px),
|
||||
radial-gradient(700px 280px at 50% 0%, rgba(0,209,255,0.12), transparent 70%),
|
||||
rgba(2,6,14,0.55)
|
||||
`,
|
||||
}}>
|
||||
<DimCorner pos="tl"/>
|
||||
<DimCorner pos="tr"/>
|
||||
<DimCorner pos="bl"/>
|
||||
<DimCorner pos="br"/>
|
||||
|
||||
{/* Spec header bar */}
|
||||
<div className="row" style={{
|
||||
justifyContent: "space-between",
|
||||
paddingBottom: 18,
|
||||
borderBottom: "1px dashed var(--border)",
|
||||
marginBottom: 28,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--muted)",
|
||||
}}>
|
||||
<span>LYNKEDUP PRO · TECHNICAL SPECIFICATION</span>
|
||||
<span style={{ color: "var(--cyan-2)" }}>SHEET 04 / 10</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 18 }}>
|
||||
{systems.map((s, i) => (
|
||||
<Reveal key={s.code} delay={(i % 6) + 1}>
|
||||
<div style={{
|
||||
padding: 20,
|
||||
border: `1px solid ${s.color}55`,
|
||||
borderRadius: 12,
|
||||
background: `linear-gradient(180deg, ${s.color}10, transparent)`,
|
||||
position: "relative",
|
||||
transition: "transform 0.3s, box-shadow 0.3s",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.transform = "translateY(-4px)";
|
||||
e.currentTarget.style.boxShadow = `0 18px 40px -16px ${s.color}99`;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.transform = "none";
|
||||
e.currentTarget.style.boxShadow = "none";
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
border: `1px solid ${s.color}99`,
|
||||
background: `${s.color}18`,
|
||||
display: "grid", placeItems: "center",
|
||||
marginBottom: 18,
|
||||
boxShadow: `inset 0 0 18px -4px ${s.color}77`,
|
||||
}}>
|
||||
<Icon name={s.icon} size={22} stroke={s.color}/>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: s.color, letterSpacing: "0.18em", marginBottom: 6 }}>{s.code} /</div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 18, fontWeight: 600, marginBottom: 6 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.45 }}>{s.desc}</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* spec footer */}
|
||||
<div style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 0,
|
||||
marginTop: 32,
|
||||
borderTop: "1px dashed var(--border)",
|
||||
paddingTop: 22,
|
||||
}}>
|
||||
{[
|
||||
{ l: "SEAMLESS COLLABORATION", d: "Office, sales reps, crews & subs aligned", c: "var(--cyan)" },
|
||||
{ l: "SMARTER DECISIONS", d: "AI insights you can act on", c: "var(--orange)" },
|
||||
{ l: "PROFITABLE GROWTH", d: "Close more, margin up", c: "var(--green)" },
|
||||
{ l: "ZERO HANDOFF LOSS", d: "Same data layer end-to-end", c: "var(--violet)" },
|
||||
].map(f => (
|
||||
<div key={f.l} style={{ paddingRight: 18, borderRight: "1px solid var(--border)" }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: f.c, letterSpacing: "0.16em", marginBottom: 8 }}>● {f.l}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-2)" }}>{f.d}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CoreFeatures() {
|
||||
return (
|
||||
<section className="section" id="features">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 56, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§05</span>
|
||||
<span>PLATFORM CAPABILITIES</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22, maxWidth: 700 }}>
|
||||
Eight modules.<br/>
|
||||
<span className="accent">One operating brain.</span>
|
||||
</h2>
|
||||
<p className="sub" style={{ marginTop: 18 }}>
|
||||
Every module shares the same connected data layer,
|
||||
so insight in one corner of the business becomes action everywhere else.
|
||||
</p>
|
||||
</div>
|
||||
<span className="dim-tag" style={{ height: 30, padding: "4px 12px" }}>// MODULES 01-08</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 20 }}>
|
||||
{FEATURES.map((f, i) => (
|
||||
<Reveal key={f.name} delay={(i % 4) + 1}>
|
||||
<GlowCard className="feature-card" style={{ minHeight: 280, padding: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: 26, flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 22 }}>
|
||||
<div style={{
|
||||
width: 54, height: 54, borderRadius: 14,
|
||||
border: `1px solid ${f.color}55`,
|
||||
background: `linear-gradient(180deg, ${f.color}22, transparent)`,
|
||||
display: "grid", placeItems: "center",
|
||||
boxShadow: `0 0 24px -6px ${f.color}88, inset 0 0 24px -6px ${f.color}55`,
|
||||
}}>
|
||||
<Icon name={f.icon} size={26} stroke={f.color}/>
|
||||
</div>
|
||||
<span className="mono" style={{ fontSize: 10, color: f.color, letterSpacing: "0.2em" }}>{f.code} /</span>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<h3 className="h-card">{f.name}</h3>
|
||||
<span className="mono" style={{ fontSize: 9, color: f.color, letterSpacing: "0.18em", opacity: 0.8 }}>{f.tag}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, color: "var(--ink-2)", lineHeight: 1.5, margin: 0 }}>{f.desc}</p>
|
||||
<div style={{ flex: 1 }}/>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 2,
|
||||
background: `linear-gradient(90deg, transparent, ${f.color}, transparent)`,
|
||||
opacity: 0.6,
|
||||
}}/>
|
||||
</GlowCard>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Connected Operations -----------------
|
||||
function ConnectedOps() {
|
||||
// workflow nodes positioned around central blueprint
|
||||
const nodes = [
|
||||
{ x: 12, y: 14, label: "Leads", icon: "lead", sub: "Auto-qualified" },
|
||||
{ x: 12, y: 38, label: "Schedule", icon: "calendar", sub: "Adapts in real time" },
|
||||
{ x: 12, y: 62, label: "Inspections",icon: "doc", sub: "Field-captured" },
|
||||
{ x: 88, y: 14, label: "Estimates", icon: "money", sub: "Create faster" },
|
||||
{ x: 88, y: 38, label: "Jobs", icon: "hammer", sub: "Every step tracked" },
|
||||
{ x: 88, y: 62, label: "Invoices", icon: "report", sub: "Paid faster" },
|
||||
];
|
||||
return (
|
||||
<section className="section" id="platform">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 56, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§06</span>
|
||||
<span>CONNECTED OPERATIONS</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22 }}>
|
||||
All your work.<br/>
|
||||
Connected. <span className="accent">All in one place.</span>
|
||||
</h2>
|
||||
<p className="sub" style={{ marginTop: 18 }}>
|
||||
From leads to final invoice, every operational detail flows
|
||||
through the same nervous system. Nothing slips through the cracks.
|
||||
</p>
|
||||
</div>
|
||||
<span className="dim-tag" style={{ height: 30, padding: "4px 12px" }}>// ONE SOURCE OF TRUTH</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<GlowCard style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
position: "relative",
|
||||
height: 720,
|
||||
background: `
|
||||
radial-gradient(800px 400px at 50% 50%, rgba(0,209,255,0.12), transparent 70%),
|
||||
linear-gradient(180deg, rgba(4,10,22,0.4), rgba(2,6,14,0.6))
|
||||
`,
|
||||
}}>
|
||||
{/* SVG connection lines */}
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||
<defs>
|
||||
<linearGradient id="line-g" x1="0" x2="1">
|
||||
<stop offset="0" stopColor="#00d1ff" stopOpacity="0"/>
|
||||
<stop offset="0.5" stopColor="#00d1ff" stopOpacity="0.7"/>
|
||||
<stop offset="1" stopColor="#00d1ff" stopOpacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{nodes.map((n, i) => {
|
||||
const cx = 50, cy = 50;
|
||||
return (
|
||||
<g key={i}>
|
||||
<path
|
||||
d={`M${n.x} ${n.y} C ${(n.x+cx)/2} ${n.y}, ${(n.x+cx)/2} ${cy}, ${cx} ${cy}`}
|
||||
fill="none"
|
||||
stroke="rgba(0,209,255,0.32)"
|
||||
strokeWidth="0.15"
|
||||
strokeDasharray="0.6 0.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle r="0.5" fill="#7be8ff">
|
||||
<animateMotion
|
||||
dur={`${3 + i * 0.3}s`}
|
||||
repeatCount="indefinite"
|
||||
path={`M${n.x} ${n.y} C ${(n.x+cx)/2} ${n.y}, ${(n.x+cx)/2} ${cy}, ${cx} ${cy}`}
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Central wireframe house */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "50%", top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
<BlueprintHouse size={520} animate={false} withScan={true}/>
|
||||
</div>
|
||||
|
||||
{/* Central hex logo overlay */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "50%", top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 110, height: 110,
|
||||
display: "grid", placeItems: "center",
|
||||
background: "radial-gradient(circle, rgba(0,209,255,0.3), transparent 60%)",
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro"
|
||||
width="100" height="100"
|
||||
style={{ width: 100, height: 100, objectFit: "contain", filter: "drop-shadow(0 0 20px rgba(0,209,255,0.9))" }}/>
|
||||
</div>
|
||||
|
||||
{/* Workflow nodes */}
|
||||
{nodes.map((n, i) => (
|
||||
<div key={n.label} style={{
|
||||
position: "absolute",
|
||||
left: `${n.x}%`, top: `${n.y}%`,
|
||||
transform: `translate(${n.x < 50 ? "-10%" : "-90%"}, -50%)`,
|
||||
zIndex: 3,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 200,
|
||||
padding: 14,
|
||||
background: "linear-gradient(180deg, rgba(8,18,36,0.92), rgba(4,10,22,0.88))",
|
||||
border: "1px solid rgba(0,209,255,0.35)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(14px)",
|
||||
boxShadow: "0 8px 24px -10px rgba(0,209,255,0.45)",
|
||||
animation: `floatY ${5 + i * 0.4}s ease-in-out infinite`,
|
||||
animationDelay: `${i * 0.3}s`,
|
||||
}}>
|
||||
<div className="row gap-12" style={{ marginBottom: 6 }}>
|
||||
<Icon name={n.icon} size={18} stroke="var(--cyan)"/>
|
||||
<span style={{ fontFamily: "var(--font-display)", fontSize: 15, fontWeight: 600 }}>{n.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>{n.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer ribbon */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 24, right: 24, bottom: 22,
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "14px 22px",
|
||||
background: "rgba(0,209,255,0.06)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(10px)",
|
||||
}}>
|
||||
<div className="row gap-16">
|
||||
<span className="live-dot"/>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--cyan-2)", letterSpacing: "0.18em" }}>
|
||||
ONE SYSTEM · ONE SOURCE OF TRUTH
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-24" style={{ fontSize: 11, color: "var(--muted)" }} >
|
||||
<span><Counter to={2847} /> events / hr</span>
|
||||
<span>·</span>
|
||||
<span><Counter to={147} suffix=" ms"/> sync</span>
|
||||
<span>·</span>
|
||||
<span className="mono" style={{ color: "var(--green)" }}>● HEALTHY</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlowCard>
|
||||
</Reveal>
|
||||
|
||||
{/* Outcome bar */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 20, marginTop: 32 }}>
|
||||
{[
|
||||
{ icon: "clock", color: "var(--cyan)", title: "Save time", desc: "Automate the busy work and focus on what matters." },
|
||||
{ icon: "target", color: "var(--orange)", title: "Reduce errors", desc: "Connected data means fewer mistakes." },
|
||||
{ icon: "growth", color: "var(--green)", title: "Grow faster", desc: "Better systems. Better decisions. Bigger results." },
|
||||
].map((o, i) => (
|
||||
<Reveal key={o.title} delay={i + 1}>
|
||||
<div style={{
|
||||
padding: 22, display: "flex", gap: 16, alignItems: "flex-start",
|
||||
border: "1px solid var(--border)", borderRadius: 16,
|
||||
background: "rgba(0,209,255,0.03)",
|
||||
}}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
border: `1px solid ${o.color}55`,
|
||||
display: "grid", placeItems: "center",
|
||||
background: `${o.color}15`,
|
||||
}}>
|
||||
<Icon name={o.icon} size={22} stroke={o.color}/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 17, fontWeight: 600, marginBottom: 4 }}>{o.title}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--muted)" }}>{o.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------- Mobile Experience ----------------
|
||||
function MobilePhone() {
|
||||
return (
|
||||
<div style={{ position: "relative", width: 320, height: 660 }}>
|
||||
{/* Phone frame */}
|
||||
<div style={{
|
||||
width: "100%", height: "100%",
|
||||
background: "linear-gradient(160deg, #1a2540, #07101e)",
|
||||
borderRadius: 42,
|
||||
padding: 8,
|
||||
boxShadow: "0 60px 80px -30px rgba(0,209,255,0.45), 0 0 0 1px rgba(0,209,255,0.25), inset 0 0 0 1px rgba(255,255,255,0.05)",
|
||||
position: "relative",
|
||||
}}>
|
||||
<div style={{
|
||||
width: "100%", height: "100%",
|
||||
background: "linear-gradient(180deg, #050c18, #02060e)",
|
||||
borderRadius: 34,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}>
|
||||
{/* Notch */}
|
||||
<div style={{
|
||||
position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)",
|
||||
width: 110, height: 26, background: "#000", borderRadius: 13, zIndex: 5,
|
||||
}}/>
|
||||
{/* Status bar */}
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between",
|
||||
padding: "16px 24px 0",
|
||||
fontSize: 12, fontFamily: "var(--font-display)", fontWeight: 600,
|
||||
color: "#fff",
|
||||
}}>
|
||||
<span>9:41</span>
|
||||
<span style={{ opacity: 0 }}>.</span>
|
||||
<span className="mono">●●●● <span style={{ marginLeft: 4 }}>5G</span></span>
|
||||
</div>
|
||||
|
||||
{/* App content */}
|
||||
<div style={{ padding: "40px 18px 18px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 6 }}>
|
||||
<span style={{ fontFamily: "var(--font-display)", fontWeight: 600, fontSize: 16 }}>June 23, 2026</span>
|
||||
<Icon name="bell" size={18} stroke="var(--cyan)"/>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--cyan-2)", letterSpacing: "0.16em" }}>TODAY · 3 SCHEDULED</div>
|
||||
|
||||
{/* Schedule cards */}
|
||||
{[
|
||||
{ t: "10:00 AM", title: "Roof Inspection", sub: "123 Maple St", active: true },
|
||||
{ t: "1:30 PM", title: "Estimate Review", sub: "456 Oak Ave" },
|
||||
{ t: "11:00 AM", title: "Material Order", sub: "789 Pine Rd" },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
background: s.active ? "linear-gradient(180deg, #ff8a1e, #d96100)" : "rgba(0,209,255,0.07)",
|
||||
border: s.active ? "1px solid #ffb24d" : "1px solid var(--border)",
|
||||
color: s.active ? "#1a0800" : "var(--ink)",
|
||||
boxShadow: s.active ? "0 12px 24px -8px rgba(255,138,30,0.6)" : "none",
|
||||
}}>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 13 }}>{s.t}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginTop: 2 }}>{s.title}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.75 }}>{s.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bottom tabs */}
|
||||
<div className="row" style={{ justifyContent: "space-around", marginTop: 16, padding: "10px 0", borderTop: "1px solid rgba(0,209,255,0.15)" }}>
|
||||
{["calendar","sync","bell","pin"].map((ic, i) => (
|
||||
<div key={i} style={{ width: 32, height: 32, borderRadius: 8, display: "grid", placeItems: "center",
|
||||
background: i === 0 ? "rgba(0,209,255,0.18)" : "transparent" }}>
|
||||
<Icon name={ic} size={16} stroke={i === 0 ? "var(--cyan)" : "var(--muted)"}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side notification glows */}
|
||||
<div style={{ position: "absolute", top: 80, right: -180, animation: "floatY 5s ease-in-out infinite" }}>
|
||||
<div style={{
|
||||
width: 220, padding: 14,
|
||||
background: "rgba(0,209,255,0.08)",
|
||||
border: "1px solid rgba(0,209,255,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(14px)",
|
||||
boxShadow: "0 12px 30px -10px rgba(0,209,255,0.5)",
|
||||
}}>
|
||||
<div className="row gap-8" style={{ marginBottom: 6 }}>
|
||||
<span className="live-dot"/>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--cyan-2)", letterSpacing: "0.16em" }}>NEW ALERT</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Diego's crew clocked in</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>456 Oak Ave · 3 min ago</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", bottom: 80, left: -200, animation: "floatY 6s ease-in-out infinite", animationDelay: "1s" }}>
|
||||
<div style={{
|
||||
width: 240, padding: 14,
|
||||
background: "rgba(255,138,30,0.07)",
|
||||
border: "1px solid rgba(255,138,30,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(14px)",
|
||||
boxShadow: "0 12px 30px -10px rgba(255,138,30,0.45)",
|
||||
}}>
|
||||
<div className="row gap-8" style={{ marginBottom: 6 }}>
|
||||
<Icon name="spark" size={12} stroke="var(--orange)"/>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--orange-2)", letterSpacing: "0.16em" }}>AI REMINDER</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Leave by 9:42 AM</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>Traffic on Maple St, moderate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "absolute", top: 350, right: -160, animation: "floatY 5.5s ease-in-out infinite", animationDelay: "2s" }}>
|
||||
<div style={{
|
||||
width: 180, padding: 12,
|
||||
background: "rgba(43,212,156,0.07)",
|
||||
border: "1px solid rgba(43,212,156,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(14px)",
|
||||
boxShadow: "0 12px 30px -10px rgba(43,212,156,0.4)",
|
||||
}}>
|
||||
<div className="row gap-8" style={{ marginBottom: 4 }}>
|
||||
<Icon name="report" size={12} stroke="var(--green)"/>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--green)", letterSpacing: "0.16em" }}>DAILY REPORT</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>3 jobs · $12.4K</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>Generated · 4:55 PM</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ambient glow behind phone */}
|
||||
<div className="ring-glow" style={{
|
||||
width: 500, height: 500, top: 50, left: -100,
|
||||
background: "radial-gradient(circle, rgba(0,209,255,0.4), transparent 60%)", zIndex: -1,
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSection() {
|
||||
return (
|
||||
<section className="section" id="mobile">
|
||||
<div className="beam top"/>
|
||||
<div className="shell split">
|
||||
<div>
|
||||
<Reveal>
|
||||
<span className="section-number">
|
||||
<span className="num">§07</span>
|
||||
<span>FIELD MOBILE</span>
|
||||
</span>
|
||||
</Reveal>
|
||||
<Reveal delay={1}>
|
||||
<h2 className="h-section" style={{ marginTop: 22 }}>
|
||||
Never miss what matters.<br/>
|
||||
<span className="accent">Stay ahead every day.</span>
|
||||
</h2>
|
||||
</Reveal>
|
||||
<Reveal delay={2}>
|
||||
<p className="sub" style={{ marginTop: 24 }}>
|
||||
The LynkedUp Pro mobile companion turns your phone into a field
|
||||
command center. Smart scheduling, real-time alerts, and AI reminders
|
||||
keep your day on track, and your business moving forward.
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal delay={3}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 36 }}>
|
||||
{[
|
||||
{ icon: "calendar", title: "Smart scheduling", sub: "Automatically organize and adapt.", color: "var(--cyan)" },
|
||||
{ icon: "bell", title: "Real-time alerts", sub: "Know what's next. Always.", color: "var(--orange)" },
|
||||
{ icon: "pin", title: "Location ready", sub: "Get directions and job details in one tap.", color: "var(--green)" },
|
||||
{ icon: "sync", title: "Sync everywhere", sub: "Stay aligned across your entire team.", color: "var(--violet)" },
|
||||
].map(m => (
|
||||
<div key={m.title} className="row gap-16" style={{
|
||||
padding: "14px 16px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
background: "rgba(0,209,255,0.03)",
|
||||
transition: "border-color 0.25s, transform 0.25s",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = "var(--border-strong)";
|
||||
e.currentTarget.style.transform = "translateX(6px)";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
e.currentTarget.style.transform = "none";
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 42, height: 42, borderRadius: 10,
|
||||
border: `1px solid ${m.color}55`,
|
||||
background: `${m.color}15`,
|
||||
display: "grid", placeItems: "center",
|
||||
}}>
|
||||
<Icon name={m.icon} size={20} stroke={m.color}/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontWeight: 600, fontSize: 15, letterSpacing: "0.04em", textTransform: "uppercase" }}>{m.title}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--muted)" }}>{m.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<Reveal delay={2}>
|
||||
<img
|
||||
src="page/assets/mobile.png"
|
||||
alt="LynkedUp Pro mobile app"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 320,
|
||||
display: "block",
|
||||
borderRadius: 28,
|
||||
border: "1px solid var(--border-strong)",
|
||||
boxShadow: "0 40px 90px -30px rgba(0,0,0,0.85), 0 0 0 1px rgba(0,209,255,0.08)",
|
||||
}}
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { CoreFeatures, ConnectedOps, MobileSection, SpecSheet });
|
||||
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>LynkedUp Pro - AI Construction Operating System</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="styles.css"/>
|
||||
|
||||
<style>
|
||||
/* Local animation primitives the components rely on */
|
||||
@keyframes floatY {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
@keyframes growBar {
|
||||
from { transform: scaleY(0); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
@keyframes growW {
|
||||
from { width: 0; }
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- React 18 + Babel -->
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Components in dependency order -->
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="hero.jsx"></script>
|
||||
<script type="text/babel" src="features.jsx"></script>
|
||||
<script type="text/babel" src="sections.jsx"></script>
|
||||
<script type="text/babel" src="app.jsx"></script>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,543 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>LynkedUp Pro - Know Exactly Where Every Job Stands, Right Now</title>
|
||||
<meta name="description" content="Projects module: full stage pipeline, estimate statuses, live budget breakdown (team cost, commission, material). Zero guesswork on every job."/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:stretch;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.shot{display:block;width:100%;height:100%;border-radius:18px;border:1px solid var(--border);object-fit:cover;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
/* ── background image helpers ── */
|
||||
.has-bg{position:relative;overflow:hidden}
|
||||
.has-bg>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.has-bg>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.has-bg>.container,.has-bg>.hero-bg,.has-bg>.eyebrow,.has-bg>h1,.has-bg>.hero-sub,.has-bg>.hero-actions,.has-bg>.micro-row,.has-bg>.stat-row{position:relative;z-index:2}
|
||||
/* ── bigger, more impactful headings ── */
|
||||
.s-title{font-size:clamp(36px,5.2vw,64px);line-height:.96}
|
||||
/* ── CTA strip ── */
|
||||
.cta-strip{padding:86px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.cta-strip>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.cta-strip>.glow{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:720px;height:380px;border-radius:50%;z-index:1;pointer-events:none}
|
||||
.cta-strip>.wrap{position:relative;z-index:2;max-width:700px;margin:0 auto}
|
||||
.strip-tag{display:inline-flex;align-items:center;gap:7px;padding:5px 16px;border-radius:100px;margin-bottom:22px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.strip-tdot{width:6px;height:6px;border-radius:50%;display:inline-block;animation:blink 2s ease infinite}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5.2vw,62px);font-weight:900;line-height:.96;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.75;max-width:520px;margin:0 auto 30px}
|
||||
.strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.strip-trust{display:flex;gap:18px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.strip-trust b{color:var(--green)}
|
||||
/* ── metric compare cards ── */
|
||||
.metric-pills{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-bottom:30px}
|
||||
.mpill{background:var(--card);border-radius:12px;padding:16px 22px;text-align:center;min-width:130px}
|
||||
.mpill-val{font-family:'Barlow Condensed',sans-serif;font-size:30px;font-weight:900;line-height:1}
|
||||
.mpill-lab{font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||
@media(max-width:1280px){section{padding:80px 24px}.cta-section{padding:80px 24px}.cta-strip{padding:76px 24px}}
|
||||
@media(max-width:1024px){nav{padding:14px 24px}section{padding:72px 22px}.cta-section{padding:72px 22px}.cta-strip{padding:68px 22px}.pain-grid{grid-template-columns:repeat(2,1fr)}.sol-split{gap:36px}h1{font-size:clamp(44px,7vw,76px)}.hero{padding:108px 20px 64px}.proof-card{padding:30px 26px}.proof-result{top:18px;right:18px}.stat-row{gap:28px;margin-top:48px}}
|
||||
@media(max-width:768px){nav{padding:12px 18px}section{padding:56px 18px}.cta-section{padding:56px 18px}.cta-strip{padding:56px 18px}.hero{min-height:auto;padding:96px 16px 56px}h1{font-size:clamp(40px,9vw,64px);line-height:.95}.hero-sub{font-size:15px}.s-title{font-size:clamp(30px,7vw,46px)}.stat-row{gap:18px;margin-top:40px}.stat+.stat::before{display:none}.stat-num{font-size:32px}.proof-card{padding:26px 22px;margin-top:32px}.proof-result{position:static;display:inline-block;margin-bottom:14px}.proof-quote{font-size:15px}.cta-section h2{font-size:clamp(30px,7vw,46px)}.cta-strip h2{font-size:clamp(28px,7vw,46px)}.sol-points li{font-size:14px}.p-item{padding:22px 18px}.metric-pills{gap:8px}.mpill{min-width:110px;padding:14px 16px}.mpill-val{font-size:24px}}
|
||||
@media(max-width:480px){nav{padding:10px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}section{padding:48px 16px}.cta-section{padding:48px 16px}.cta-strip{padding:48px 16px}.hero{padding:88px 14px 48px}h1{font-size:clamp(34px,10vw,52px);letter-spacing:-.5px}.hero-sub{font-size:14px;margin-top:18px}.s-title{font-size:clamp(28px,8vw,40px);letter-spacing:0}.s-sub{font-size:15px}.micro{font-size:12px}.micro-row{gap:10px}.stat-row{flex-direction:column;gap:18px;margin-top:32px}.stat-num{font-size:28px}.pain-grid{margin-top:30px}.p-item h3{font-size:15px}.p-item p{font-size:13px}.proof-card{padding:22px 18px;border-radius:14px}.proof-quote{font-size:14px;line-height:1.7}.proof-author{flex-wrap:wrap;gap:10px}.cta-section h2,.cta-strip h2{font-size:clamp(26px,8vw,38px)}.cta-section p,.cta-strip p{font-size:14px}.btn-primary,.btn-ghost{padding:12px 22px;font-size:15px}.sol-stats{grid-template-columns:1fr 1fr;gap:8px}.sol-stat{padding:12px}.sol-stat-num{font-size:22px}.mock-card{border-radius:14px}.metric-pills{flex-wrap:wrap}.mpill{min-width:90px;padding:12px 14px}.mpill-val{font-size:22px}.cta-micro{gap:8px;font-size:11px}.strip-tag{font-size:10px;padding:4px 12px;letter-spacing:.8px}footer{padding:28px 16px 22px}footer p{font-size:12px}.mob-sticky{padding:10px 12px}.mob-sticky a{font-size:13px;padding:11px}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}}
|
||||
@media(max-width:360px){h1{font-size:32px}.s-title{font-size:26px}.cta-section h2,.cta-strip h2{font-size:24px}.btn-primary,.btn-ghost{padding:11px 18px;font-size:14px}.sol-stats{grid-template-columns:1fr}}
|
||||
@media(max-width:860px){.mob-sticky{display:flex}body{padding-bottom:72px}.sol-split{grid-template-columns:1fr!important}.pain-grid{grid-template-columns:1fr!important}.shot{height:auto}}
|
||||
@media(max-width:768px){.stat+.stat::before{display:none}.stat-row{gap:18px}}
|
||||
@media(max-width:560px){.sol-stats{grid-template-columns:1fr 1fr}.stat-row{flex-direction:column;gap:18px}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin:24px auto 0}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}}
|
||||
@media(max-width:360px){.sol-stats{grid-template-columns:1fr}}
|
||||
@keyframes hpulse{0%,100%{opacity:.9;transform:scale(1)}50%{opacity:.45;transform:scale(1.3)}}
|
||||
.stage-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 14px;border-radius:100px;font-size:12px;font-weight:600;font-family:'Outfit',sans-serif}
|
||||
</style></head><body>
|
||||
<nav><a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a><a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a></nav>
|
||||
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=1800&q=80');filter:brightness(.11) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(5,8,15,.5) 0%,rgba(5,8,15,.8) 55%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(139,92,246,.1),transparent 65%)"></div></div>
|
||||
<div class="eyebrow"><span class="dot" style="background:#a855f7"></span> Projects · Stages · Estimates · Budget</div>
|
||||
<h1>Right Now, One Job<br>Is Over Budget.<br><span style="color:#a855f7">Do You Know Which?</span></h1>
|
||||
<p class="hero-sub"><strong>Which jobs are stalled at estimate? Which ones are bleeding margin?</strong> Without a live projects view, you find out too late, after the job closes and the money's already gone. LynkedUp Pro is your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">See Projects Module →</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row" style="margin-top:18px">
|
||||
<span class="micro" style="color:var(--orange)">4.9★ · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>✅</span> Full stage pipeline</span>
|
||||
<span class="micro"><span>✅</span> Live budget per job</span>
|
||||
<span class="micro"><span>✅</span> Estimate status tracking</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:#a855f7">6</div><div class="stat-label">Pipeline stages tracked</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">4</div><div class="stat-label">Estimate statuses (live)</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">Live</div><div class="stat-label">Budget vs actual per job</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">1-click</div><div class="stat-label">PDF estimate download</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?w=1800&q=80');filter:brightness(.1) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(11,18,32,.98) 0%,rgba(11,18,32,.92) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">The Margin Was Gone<br>Before the Job Was <span style="color:var(--red)">Even Finished.</span></h2>
|
||||
<p class="s-sub">Most roofing software shows you a pipeline. LynkedUp Pro shows you the full financial picture of every job in real time, so problems surface before they cost you.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item"><div class="p-icon">🗂️</div><h3>Estimates Stalling Invisibly</h3><p>A rep sent a draft 5 days ago. Has the homeowner seen it? Approved it? Rejected it? Without status tracking you find out when it's too late.</p><span class="p-cost">Cost: Jobs lost to follow-up gaps</span></div>
|
||||
<div class="p-item"><div class="p-icon">💸</div><h3>Jobs Going Over Budget Silently</h3><p>Team cost, commissions, and material spend accumulate with no warning. By the time you notice the margin is gone, the job is already complete.</p><span class="p-cost">Cost: Margin erosion per job</span></div>
|
||||
<div class="p-item"><div class="p-icon">📋</div><h3>Stage Confusion Across the Team</h3><p>Your sales rep thinks the job is at "appointment scheduled." Your manager thinks it's at "estimate sent." Your admin thinks it's still a lead. No one knows what's actually happening.</p><span class="p-cost">Cost: Coordination overhead daily</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CTA STRIP 1 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(10,5,18,.97) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(168,85,247,.08),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(168,85,247,.1);border:1px solid rgba(168,85,247,.25);color:#a855f7">
|
||||
<span class="strip-tdot" style="background:#a855f7"></span>
|
||||
Every day without live tracking is a day you're flying blind
|
||||
</div>
|
||||
<h2>Your Jobs Are Running.<br><span style="color:#a855f7">Are You Running Them</span><br>or Are They <span style="color:var(--red)">Running You?</span></h2>
|
||||
<p>Right now, somewhere in your active jobs, a material order just pushed a budget over the edge. An estimate has been sitting unread for 4 days. A stage hasn't moved in a week. You don't know - but you should.</p>
|
||||
<!-- 3 pain pills -->
|
||||
<div class="metric-pills">
|
||||
<div class="mpill" style="border:1px solid rgba(239,68,68,.2)">
|
||||
<div class="mpill-val" style="color:var(--red)">3-5</div>
|
||||
<div class="mpill-lab">Jobs over budget unnoticed / month</div>
|
||||
</div>
|
||||
<div class="mpill" style="border:1px solid rgba(245,158,11,.2)">
|
||||
<div class="mpill-val" style="color:var(--orange)">4 days</div>
|
||||
<div class="mpill-lab">Avg estimate sitting unread</div>
|
||||
</div>
|
||||
<div class="mpill" style="border:1px solid rgba(168,85,247,.2)">
|
||||
<div class="mpill-val" style="color:#a855f7">$0</div>
|
||||
<div class="mpill-lab">Margin warning without LynkedUp</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Show Me Every Job Live →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> Free 30-min demo</span>
|
||||
<span><b>✅</b> No credit card</span>
|
||||
<span><b>✅</b> Live in 24 hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1800&q=80');filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(90deg,rgba(5,8,15,1) 44%,rgba(5,8,15,.72) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:#a855f7">The Fix</div>
|
||||
<h2 class="s-title">Catch Overruns <span style="color:#a855f7">Live.</span><br>Close Estimates <span style="color:var(--green)">Faster.</span><br>Know Every Stage.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro's Projects module tracks every job through a defined stage pipeline with live estimate status and a real-time budget breakdown, so you always know exactly where you stand.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">🔄</span><span><strong>6-stage pipeline</strong> - Contacted → Appointment Scheduled → Estimate Sent → Approved → Job Active → Invoiced. Every stage visible to every stakeholder.</span></li>
|
||||
<li><span class="ico">📄</span><span><strong>4 estimate statuses</strong> - Draft, Sent, Approved, Rejected. One-click PDF download. Customer signs digitally on the spot.</span></li>
|
||||
<li><span class="ico">💰</span><span><strong>Live budget per job</strong> - team cost, commission, actor cost, material cost all tracked in real time against project budget. Margin warning before it's gone.</span></li>
|
||||
<li><span class="ico">👤</span><span><strong>Role-based visibility</strong> - reps see their jobs, managers see their team, owners see everything. Right data to the right person.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:#a855f7">6</div><div class="sol-stat-label">Defined pipeline stages</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">4</div><div class="sol-stat-label">Live estimate statuses</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">Live</div><div class="sol-stat-label">Budget vs spend tracking</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">PDF</div><div class="sol-stat-label">One-click estimate export</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- PROJECTS MOCK -->
|
||||
<div class="mock-card">
|
||||
<img src="/page/assets/pipeline.png" alt="LynkedUp Pro Overview Dashboard" style="display:block;width:100%;height:auto" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CTA STRIP 2 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(2,14,10,.97) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(0,229,160,.07),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.22);color:var(--green)">
|
||||
<span class="strip-tdot" style="background:var(--green)"></span>
|
||||
Your margin doesn't vanish overnight - it leaks $50 at a time
|
||||
</div>
|
||||
<h2>See the Leak <span style="color:var(--green)">Before</span><br>It Becomes a <span style="color:var(--red)">Loss.</span></h2>
|
||||
<p>LynkedUp Pro flags budget overruns the moment your material spend hits 80% of budget, not after the job closes. Three caught overruns a month is thousands back in your pocket.</p>
|
||||
<!-- Before / After comparison -->
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:12px;align-items:center;max-width:560px;margin:0 auto 30px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:22px 26px">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px">Without LynkedUp</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--red);line-height:1">After close</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:5px">When you find out the job lost money</div>
|
||||
</div>
|
||||
<div style="font-size:20px;color:var(--border2);text-align:center;padding:0 4px">vs</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px">With LynkedUp Pro</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--green);line-height:1">Live alert</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:5px">At 80% budget - while you can still act</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Protect My Margins Now →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> Live budget alerts</span>
|
||||
<span><b>✅</b> All 6 pipeline stages</span>
|
||||
<span><b>✅</b> Free onboarding</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
<section class="proof-section has-bg reveal" id="proof">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.88) 55%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:#a855f7;text-align:center">Real Impact</div>
|
||||
<h2 class="s-title" style="text-align:center">He Caught 3 Overruns<br>in One Month. <span style="color:#a855f7">That's Real</span><br><span style="color:var(--green)">Money Back.</span></h2>
|
||||
<div class="proof-card" style="border-color:rgba(139,92,246,.2)">
|
||||
<div class="proof-result" style="border-color:rgba(139,92,246,.2);background:rgba(139,92,246,.07)"><div class="pr-num" style="color:#a855f7">/bin/sh</div><div class="pr-label">Budget Surprises</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"Before LynkedUp Pro I was finding out jobs went over budget after they were done. Now the system flags it live. The minute our material spend hits 80% of budget I get a notification. We caught three jobs in the last month before they went negative. That's real money."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#a855f7,#0ea5e9)">JR</div>
|
||||
<div><div class="proof-name">Jason Rodriguez</div><div class="proof-role">Owner · Plano, TX</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(139,92,246,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div style="display:inline-block;background:rgba(139,92,246,.08);border:1px solid rgba(139,92,246,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:#a855f7;font-weight:600">📊 Every job. Every stage. Every dollar. Live.</div>
|
||||
<h2>Every Job on Your Board<br>Deserves a <em style="color:#a855f7">Live Number.</em><br>Not a Gut Feeling.</h2>
|
||||
<p>See the full Projects module - stages, estimate tracking, and live budget breakdown - in a free 30-minute demo.</p>
|
||||
<div class="cta-form"><a href="https://book.lynkeduppro.com/" class="btn-primary" onclick="event.preventDefault()">See Projects Module →</a></div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day guarantee</span><span>✅ Free onboarding</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer style="padding:32px 24px;border-top:1px solid var(--border);text-align:center"><p style="font-size:13px;color:var(--muted)">© 2026 LynkedUp Pro · <a href="lynkeduppro_final.html" style="color:var(--muted)">Full Platform →</a></p><p style="font-size:11px;color:var(--muted);opacity:.7;margin-top:10px;max-width:640px;margin-left:auto;margin-right:auto">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p></footer>
|
||||
<div class="mob-sticky"><a href="https://book.lynkeduppro.com/" class="p">Book a demo</a><a href="lynkeduppro_final.html" class="s">See All Features</a></div>
|
||||
<script>const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add('visible')})},{threshold:.1});document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,560 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:stretch;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.shot{display:block;width:100%;height:100%;border-radius:18px; object-fit:cover;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
/* ── background image helpers ── */
|
||||
.has-bg{position:relative;overflow:hidden}
|
||||
.has-bg>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.has-bg>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.has-bg>.container,.has-bg>.hero-bg,.has-bg>.eyebrow,.has-bg>h1,.has-bg>.hero-sub,.has-bg>.hero-actions,.has-bg>.micro-row,.has-bg>.stat-row{position:relative;z-index:2}
|
||||
/* ── bigger, punchier headings ── */
|
||||
.s-title{font-size:clamp(36px,5.2vw,64px);line-height:.96}
|
||||
/* ── CTA strip component ── */
|
||||
.cta-strip{padding:86px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.cta-strip>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.cta-strip>.glow{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:720px;height:380px;border-radius:50%;z-index:1;pointer-events:none}
|
||||
.cta-strip>.wrap{position:relative;z-index:2;max-width:700px;margin:0 auto}
|
||||
.strip-tag{display:inline-flex;align-items:center;gap:7px;padding:5px 16px;border-radius:100px;margin-bottom:22px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.strip-tdot{width:6px;height:6px;border-radius:50%;display:inline-block;animation:blink 2s ease infinite}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5.2vw,62px);font-weight:900;line-height:.96;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.75;max-width:520px;margin:0 auto 30px}
|
||||
.strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.strip-trust{display:flex;gap:18px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.strip-trust b{color:var(--green)}
|
||||
/* ── comparison / stat cards in strips ── */
|
||||
.s-compare{display:grid;grid-template-columns:1fr auto 1fr;gap:14px;align-items:center;max-width:560px;margin:0 auto 30px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:22px 26px}
|
||||
.sc-side{text-align:center}
|
||||
.sc-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px}
|
||||
.sc-val{font-family:'Barlow Condensed',sans-serif;font-size:30px;font-weight:900;line-height:1}
|
||||
.sc-sub{font-size:11px;color:var(--muted);margin-top:5px}
|
||||
.sc-div{font-size:20px;color:var(--border2);text-align:center;padding:0 4px}
|
||||
.mpills{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-bottom:30px}
|
||||
.mpill{background:var(--card);border-radius:12px;padding:16px 20px;text-align:center;min-width:128px}
|
||||
.mpill-v{font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;line-height:1}
|
||||
.mpill-l{font-size:11px;color:var(--muted);margin-top:5px;text-transform:uppercase;letter-spacing:.4px;line-height:1.4}
|
||||
@media(max-width:1280px){section{padding:80px 24px}.cta-section{padding:80px 24px}.cta-strip{padding:76px 24px}}
|
||||
@media(max-width:1024px){nav{padding:14px 24px}section{padding:72px 22px}.cta-section{padding:72px 22px}.cta-strip{padding:68px 22px}.pain-grid{grid-template-columns:repeat(2,1fr)}.sol-split{gap:36px}h1{font-size:clamp(44px,7vw,76px)}.hero{padding:108px 20px 64px}.proof-card{padding:30px 26px}.proof-result{top:18px;right:18px}.stat-row{gap:28px;margin-top:48px}}
|
||||
@media(max-width:768px){nav{padding:12px 18px}section{padding:56px 18px}.cta-section{padding:56px 18px}.cta-strip{padding:56px 18px}.hero{min-height:auto;padding:96px 16px 56px}h1{font-size:clamp(40px,9vw,64px);line-height:.95}.hero-sub{font-size:15px}.s-title{font-size:clamp(30px,7vw,46px)}.stat-row{gap:18px;margin-top:40px}.stat+.stat::before{display:none}.stat-num{font-size:32px}.proof-card{padding:26px 22px;margin-top:32px}.proof-result{position:static;display:inline-block;margin-bottom:14px}.proof-quote{font-size:15px}.cta-section h2{font-size:clamp(30px,7vw,46px)}.cta-strip h2{font-size:clamp(28px,7vw,46px)}.sol-points li{font-size:14px}.p-item{padding:22px 18px}.mpills{gap:8px}.mpill{min-width:108px;padding:14px 16px}.mpill-v{font-size:22px}.s-compare{padding:18px 18px}.sc-val{font-size:24px}}
|
||||
@media(max-width:480px){nav{padding:10px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}section{padding:48px 16px}.cta-section{padding:48px 16px}.cta-strip{padding:48px 16px}.hero{padding:88px 14px 48px}h1{font-size:clamp(34px,10vw,52px);letter-spacing:-.5px}.hero-sub{font-size:14px;margin-top:18px}.s-title{font-size:clamp(28px,8vw,40px);letter-spacing:0}.s-sub{font-size:15px}.micro{font-size:12px}.micro-row{gap:10px}.stat-row{flex-direction:column;gap:18px;margin-top:32px}.stat-num{font-size:28px}.pain-grid{margin-top:30px}.p-item h3{font-size:15px}.p-item p{font-size:13px}.proof-card{padding:22px 18px;border-radius:14px}.proof-quote{font-size:14px;line-height:1.7}.proof-author{flex-wrap:wrap;gap:10px}.cta-section h2,.cta-strip h2{font-size:clamp(26px,8vw,38px)}.cta-section p,.cta-strip p{font-size:14px}.btn-primary,.btn-ghost{padding:12px 22px;font-size:15px}.sol-stats{grid-template-columns:1fr 1fr;gap:8px}.sol-stat{padding:12px}.sol-stat-num{font-size:22px}.mock-card{border-radius:14px}.mpills{flex-wrap:wrap}.mpill{min-width:86px;padding:12px 14px}.mpill-v{font-size:20px}.cta-micro{gap:8px;font-size:11px}.strip-tag{font-size:10px;padding:4px 12px;letter-spacing:.8px}footer{padding:32px 16px 24px}footer p{font-size:12px}.mob-sticky{padding:10px 12px}.mob-sticky a{font-size:13px;padding:11px}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}}
|
||||
@media(max-width:360px){h1{font-size:32px}.s-title{font-size:26px}.cta-section h2,.cta-strip h2{font-size:24px}.btn-primary,.btn-ghost{padding:11px 18px;font-size:14px}.sol-stats{grid-template-columns:1fr}}
|
||||
@media(max-width:860px){.mob-sticky{display:flex}body{padding-bottom:72px}.sol-split{grid-template-columns:1fr!important}.pain-grid{grid-template-columns:1fr!important}.s-compare{grid-template-columns:1fr;gap:8px}.sc-div{display:none}.shot{height:auto}}
|
||||
@media(max-width:768px){.stat+.stat::before{display:none}.stat-row{gap:18px}}
|
||||
@media(max-width:560px){.sol-stats{grid-template-columns:1fr 1fr}.stat-row{flex-direction:column;gap:18px}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin:24px auto 0}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}}
|
||||
@media(max-width:360px){.sol-stats{grid-template-columns:1fr}}</style>
|
||||
<title>LynkedUp Pro - Stop Losing Jobs to Double-Bookings and No-Shows</title>
|
||||
<meta name="description" content="AI-powered smart scheduling syncs with Google, iOS, and Calendly. Suggests optimal windows, prevents double-bookings, and gets your field team on time every time."/>
|
||||
</head><body>
|
||||
<nav>
|
||||
<a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a>
|
||||
<a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a>
|
||||
</nav>
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=1800&q=80');filter:brightness(.12) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(5,8,15,.5) 0%,rgba(5,8,15,.82) 55%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(245,158,11,.1),transparent 65%)"></div></div>
|
||||
<div class="eyebrow"><span class="dot" style="background:var(--orange)"></span> Smart Calendar · AI Scheduling · Field Intelligence</div>
|
||||
<h1>The Appointment<br>Was There. Your Rep<br><span style="color:var(--orange)">Wasn't. Job Gone.</span></h1>
|
||||
<p class="hero-sub">Double-bookings. Late arrivals. Reps walking in blind. <strong>Every scheduling failure hands a job to your competitor</strong>, and the homeowner never calls you back. It's your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Stop Losing Appointments →</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row" style="margin-top:18px">
|
||||
<span class="micro" style="color:var(--muted)"><span style="color:var(--orange)">4.9★</span> · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>✅</span> Syncs iOS, Google, Calendly</span>
|
||||
<span class="micro"><span>✅</span> AI suggests best times</span>
|
||||
<span class="micro"><span>✅</span> 98% on-track rate</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">98%</div><div class="stat-label">On-track rate this week</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">3</div><div class="stat-label">Calendars synced in real-time</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">0</div><div class="stat-label">Double-bookings since launch</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">AI</div><div class="stat-label">Suggests optimal windows</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?w=1800&q=80');filter:brightness(.1) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(11,18,32,.98) 0%,rgba(11,18,32,.92) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">2-3 Lost Jobs a Month.<br>Same Cause <span style="color:var(--red)">Every Time.</span></h2>
|
||||
<p class="s-sub">It's never the product. It's never the price. It's the rep who was late, double-booked, or walked in with nothing to say. Scheduling failures are silent revenue killers, and they're completely preventable.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item"><div class="p-icon">📅</div><h3>Double-Bookings</h3><p>Rep A and Rep B both confirm the same homeowner for different times. The homeowner gets two calls, loses confidence in your operation, and calls someone else.</p><span class="p-cost">Cost: 1-2 leads lost per week</span></div>
|
||||
<div class="p-item"><div class="p-icon">👻</div><h3>No-Shows and Late Arrivals</h3><p>Rep doesn't check traffic, gets stuck, shows up 30 minutes late with no warning. Homeowner already moved on. You don't find out until the job's gone.</p><span class="p-cost">Cost: Referral reputation damage</span></div>
|
||||
<div class="p-item"><div class="p-icon">🗒️</div><h3>Reps Without Context</h3><p>Rep shows up to a homeowner meeting with no notes on the property, the previous conversation, or the damage assessment. Starts from scratch in front of the customer.</p><span class="p-cost">Cost: Lower close rate on every visit</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ══ CTA STRIP 1 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1508739773434-c26b3d09e071?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(16,8,2,.97) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(245,158,11,.09),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.28);color:var(--orange)">
|
||||
<span class="strip-tdot" style="background:var(--orange)"></span>
|
||||
Every missed appointment has a price tag
|
||||
</div>
|
||||
<h2>That Missed Appointment<br>You Didn't Follow Up On?<br><span style="color:var(--orange)">It Was Worth $12,000.</span></h2>
|
||||
<p>At an average job size of $12,000, losing 2-3 appointments a month isn't a scheduling inconvenience. It's $24,000-$36,000 in annual revenue walking out the door. Let's close that gap in one demo.</p>
|
||||
<!-- Revenue loss calc pills -->
|
||||
<div class="mpills">
|
||||
<div class="mpill" style="border:1px solid rgba(239,68,68,.22)">
|
||||
<div class="mpill-v" style="color:var(--red)">2-3</div>
|
||||
<div class="mpill-l">Jobs lost per month to scheduling</div>
|
||||
</div>
|
||||
<div class="mpill" style="border:1px solid rgba(245,158,11,.22)">
|
||||
<div class="mpill-v" style="color:var(--orange)">$12K</div>
|
||||
<div class="mpill-l">Avg roofing job value</div>
|
||||
</div>
|
||||
<div class="mpill" style="border:1px solid rgba(239,68,68,.22)">
|
||||
<div class="mpill-v" style="color:var(--red)">$36K</div>
|
||||
<div class="mpill-l">Lost revenue per year</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Show Me How to Fix This →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> Free 30-min demo</span>
|
||||
<span><b>✅</b> No credit card</span>
|
||||
<span><b>✅</b> Set up same day</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1800&q=80');filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(90deg,rgba(5,8,15,1) 44%,rgba(5,8,15,.72) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--accent)">The Fix</div>
|
||||
<h2 class="s-title">On Time. Every Time.<br><span style="color:var(--accent)">With Everything</span><br>They Need to Close.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro's smart calendar syncs your whole team, prevents conflicts automatically, and nudges reps with AI suggestions, traffic, timing, job context, before they even ask.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">🔄</span><span><strong>Real-time two-way sync</strong> - iOS Calendar, Google Calendar, and Calendly all stay in sync automatically. One source of truth for your entire team.</span></li>
|
||||
<li><span class="ico">🧠</span><span><strong>AI-suggested windows</strong> - suggests optimal times, avoids back-to-back gaps, and checks traffic before recommending when your rep should leave.</span></li>
|
||||
<li><span class="ico">📋</span><span><strong>Full context per appointment</strong> - every rep sees the homeowner notes, damage photos, previous conversations, and property details before they arrive.</span></li>
|
||||
<li><span class="ico">🛡️</span><span><strong>You stay in control</strong> - every AI suggestion is a nudge, not an override. You approve every change. No automation you didn't ask for.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">98%</div><div class="sol-stat-label">On-track rate in beta</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">3</div><div class="sol-stat-label">Calendar integrations</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">AI</div><div class="sol-stat-label">Traffic-aware suggestions</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:#fff">0</div><div class="sol-stat-label">Double-bookings</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card">
|
||||
<img src="/page/assets/calendar.png" alt="LynkedUp Pro Overview Dashboard" style="display:block;width:100%;height:auto" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ══ CTA STRIP 2 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(2,14,10,.97) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(0,200,255,.07),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(0,200,255,.08);border:1px solid rgba(0,200,255,.22);color:var(--accent)">
|
||||
<span class="strip-tdot" style="background:var(--accent)"></span>
|
||||
The rep who shows up prepared wins the job
|
||||
</div>
|
||||
<h2>Context Closes.<br><span style="color:var(--accent)">Showing Up Blind</span><br>Doesn't.</h2>
|
||||
<p>LynkedUp Pro feeds every rep the homeowner's damage history, previous conversations, and property notes before they knock. When your rep walks in knowing more than the homeowner expects, they close.</p>
|
||||
<!-- Before / After comparison -->
|
||||
<div class="s-compare">
|
||||
<div class="sc-side">
|
||||
<div class="sc-label">Without LynkedUp</div>
|
||||
<div class="sc-val" style="color:var(--red)">Walks in blind</div>
|
||||
<div class="sc-sub">No notes. Starts from scratch.<br>Homeowner senses it.</div>
|
||||
</div>
|
||||
<div class="sc-div">vs</div>
|
||||
<div class="sc-side">
|
||||
<div class="sc-label">With LynkedUp Pro</div>
|
||||
<div class="sc-val" style="color:var(--green)">Walks in ready</div>
|
||||
<div class="sc-sub">Full context. Feels like a<br>trusted advisor. Closes.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">See the Calendar in Action →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> iOS, Google & Calendly sync</span>
|
||||
<span><b>✅</b> AI-suggested windows</span>
|
||||
<span><b>✅</b> Full context per appointment</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="proof-section has-bg reveal" id="proof">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.88) 55%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--orange);text-align:center">Real Result</div>
|
||||
<h2 class="s-title" style="text-align:center">His Reps Stopped<br>Missing Appointments.<br><span style="color:var(--orange)">Close Rate Up 43%.</span></h2>
|
||||
<div class="proof-card" style="border-color:rgba(245,158,11,.2)">
|
||||
<div class="proof-result" style="border-color:rgba(245,158,11,.18);background:rgba(245,158,11,.06)"><div class="pr-num" style="color:var(--orange)">+43%</div><div class="pr-label">Close Rate</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"The AI calendar suggestions changed everything for my team. Reps never miss appointments, they always show up with context about the property, and the route nudges actually help them plan smarter days. Within 60 days our close rate jumped 43%. The calendar alone drove most of that."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#0ea5e9,#00e5a0)">JR</div>
|
||||
<div><div class="proof-name">Jason Rodriguez</div><div class="proof-role">Owner · Dallas, TX · 14-person crew</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(245,158,11,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div style="display:inline-block;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--orange);font-weight:600">📅 The average roofing crew loses 2-3 jobs/month to scheduling failures</div>
|
||||
<h2>Your Next Missed<br>Appointment Is Already<br><em>On the Calendar.</em></h2>
|
||||
<p>See the smart calendar in action - synced, AI-powered, and built to stop your best leads from signing with someone else first.</p>
|
||||
<div class="cta-form">
|
||||
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Book a demo →</a>
|
||||
</div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day guarantee</span><span>✅ Works with your existing calendar</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
<p>© 2026 LynkedUp Pro See Full Platform <a href="https://book.lynkeduppro.com/">Privacy</a> · <a href="https://book.lynkeduppro.com/">Terms</a></p>
|
||||
<p style="margin-top:8px;font-size:12px;opacity:.5">$349/mo flat rate · No credit card required · 30-day money-back guarantee · Built for roofing</p>
|
||||
<p style="margin-top:8px;font-size:11px;opacity:.5">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p>
|
||||
</footer>
|
||||
<div class="mob-sticky">
|
||||
<a href="https://book.lynkeduppro.com/" class="p">Book a demo</a>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add("visible")})},{threshold:.1});
|
||||
document.querySelectorAll(".reveal").forEach(el=>obs.observe(el));
|
||||
document.querySelectorAll("a[href^=\"#\"]").forEach(a=>{a.addEventListener("click",function(e){const t=document.querySelector(this.getAttribute("href"));if(t){e.preventDefault();t.scrollIntoView({behavior:"smooth",block:"start"});}}); });
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,635 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:stretch;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.shot{display:block;width:100%;height:100%;border-radius:18px;object-fit:cover;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
|
||||
@media(max-width:1280px){
|
||||
nav{padding:14px 32px}
|
||||
section{padding:80px 24px}
|
||||
.cta-strip{padding:72px 24px}
|
||||
.cta-section{padding:80px 24px}
|
||||
.container{max-width:100%}
|
||||
}
|
||||
@media(max-width:1024px){
|
||||
nav{padding:14px 24px}
|
||||
.pain-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.sol-split{gap:36px}
|
||||
.hero{min-height:auto;padding:120px 24px 72px}
|
||||
h1{font-size:clamp(40px,7vw,72px)}
|
||||
.s-title{font-size:clamp(32px,5vw,52px)!important}
|
||||
.stat-row{gap:28px}
|
||||
.proof-result{position:static;display:inline-block;margin-bottom:14px}
|
||||
}
|
||||
@media(max-width:860px){
|
||||
nav{padding:14px 20px}
|
||||
.sol-split{grid-template-columns:1fr!important}
|
||||
.pain-grid{grid-template-columns:1fr!important}
|
||||
.sol-stats{grid-template-columns:1fr 1fr}
|
||||
.mob-sticky{display:flex}
|
||||
body{padding-bottom:72px}
|
||||
section{padding:64px 20px}
|
||||
.cta-strip{padding:60px 20px}
|
||||
.cta-section{padding:64px 20px}
|
||||
.hero{padding:100px 16px 60px}
|
||||
.shot{height:auto}
|
||||
}
|
||||
@media(max-width:768px){
|
||||
nav{padding:12px 16px}
|
||||
.nav-cta{padding:8px 16px;font-size:13px}
|
||||
.logo-text{font-size:16px}
|
||||
.logo-mark{width:28px;height:28px;font-size:14px}
|
||||
section{padding:56px 18px}
|
||||
.cta-strip{padding:56px 18px}
|
||||
.cta-section{padding:56px 18px}
|
||||
.hero{padding:100px 16px 56px}
|
||||
.btn-primary,.btn-ghost{padding:12px 22px;font-size:15px}
|
||||
.stat-row{gap:20px;margin-top:44px}
|
||||
.stat+.stat::before{display:none}
|
||||
.stat-num{font-size:32px}
|
||||
.proof-quote{font-size:15px;line-height:1.7}
|
||||
.proof-card{padding:24px 20px}
|
||||
.cta-section h2{font-size:clamp(28px,7vw,48px)}
|
||||
.smet-row{gap:8px}
|
||||
.smet{min-width:0;flex:1 0 calc(50% - 8px);padding:12px}
|
||||
.smet-v{font-size:22px}
|
||||
.strip-trust{gap:12px}
|
||||
}
|
||||
@media(max-width:560px){
|
||||
.stat-row{gap:16px}
|
||||
h1{font-size:clamp(36px,11vw,64px);line-height:.96}
|
||||
.cta-form{flex-direction:column}
|
||||
.cta-form input{min-width:100%;width:100%}
|
||||
}
|
||||
@media(max-width:480px){
|
||||
nav{padding:10px 14px}
|
||||
.logo-text{font-size:15px}
|
||||
.nav-cta{padding:7px 13px;font-size:12px}
|
||||
section{padding:44px 16px}
|
||||
.cta-strip{padding:48px 16px}
|
||||
.cta-section{padding:48px 16px}
|
||||
.hero{padding:90px 14px 48px}
|
||||
h1{font-size:clamp(32px,11vw,56px);line-height:.96}
|
||||
.hero-sub{font-size:15px;margin-top:18px}
|
||||
.hero-actions{flex-direction:column;align-items:stretch;margin-top:28px}
|
||||
.hero-actions a{width:100%;text-align:center}
|
||||
.s-title{font-size:clamp(26px,8vw,42px)!important}
|
||||
.s-sub{font-size:15px}
|
||||
.stat-row{gap:14px;margin-top:32px}
|
||||
.stat-num{font-size:24px}
|
||||
.stat-label{font-size:11px}
|
||||
.sol-stats{grid-template-columns:1fr}
|
||||
.cta-form .btn-primary{width:100%;text-align:center}
|
||||
.cta-section h2{font-size:clamp(24px,8vw,38px)}
|
||||
.cta-section p{font-size:15px}
|
||||
.cta-micro{gap:8px;font-size:11px}
|
||||
.strip-btns{flex-direction:column;align-items:stretch}
|
||||
.strip-btns a{width:100%;text-align:center}
|
||||
.strip-trust{flex-direction:column;align-items:center;gap:8px}
|
||||
.smet{flex:1 0 calc(50% - 8px)}
|
||||
.smet-v{font-size:20px}
|
||||
.smet-l{font-size:10px}
|
||||
.proof-card{padding:22px 16px;margin-top:32px}
|
||||
.proof-quote{font-size:14px}
|
||||
.p-item{padding:22px 18px}
|
||||
.mock-card{box-shadow:0 14px 28px rgba(0,0,0,.4)}
|
||||
.mob-sticky{padding:10px 12px;gap:8px}
|
||||
.mob-sticky a{padding:11px 8px;font-size:13px}
|
||||
footer{padding:32px 16px 24px}
|
||||
footer p{font-size:12px}
|
||||
.eyebrow{font-size:11px;padding:5px 14px}
|
||||
}
|
||||
@media(max-width:360px){
|
||||
h1{font-size:clamp(28px,11vw,48px)}
|
||||
.s-title{font-size:clamp(22px,9vw,36px)!important}
|
||||
.btn-primary,.btn-ghost{padding:11px 16px;font-size:14px}
|
||||
.stat-num{font-size:20px}
|
||||
.smet{flex:1 0 100%}
|
||||
}
|
||||
|
||||
/* ── bg helpers (proven v3) ── */
|
||||
.bg-img{position:absolute!important;inset:0!important;z-index:0!important;background-size:cover!important;background-position:center!important;pointer-events:none}
|
||||
.bg-ov{position:absolute!important;inset:0!important;z-index:1!important;pointer-events:none}
|
||||
.has-bg{position:relative!important;overflow:hidden!important}
|
||||
.has-bg>.container,.has-bg>.hero-bg,.has-bg>.eyebrow,
|
||||
.has-bg>h1,.has-bg>.hero-sub,.has-bg>.hero-actions,
|
||||
.has-bg>.micro-row,.has-bg>.stat-row,.has-bg>.wrap{position:relative!important;z-index:2!important}
|
||||
/* ── CTA strip ── */
|
||||
.cta-strip{padding:84px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip>.wrap{position:relative;z-index:2;max-width:700px;margin:0 auto}
|
||||
.strip-tag{display:inline-flex;align-items:center;gap:7px;padding:5px 16px;border-radius:100px;margin-bottom:22px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.strip-tdot{width:6px;height:6px;border-radius:50%;display:inline-block;animation:blink 2s ease infinite}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5vw,60px);font-weight:900;line-height:.97;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.72;max-width:520px;margin:0 auto 28px}
|
||||
.strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.strip-trust{display:flex;gap:18px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.strip-trust b{color:var(--green)}
|
||||
.smet-row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-bottom:28px}
|
||||
.smet{background:var(--card);border-radius:12px;padding:14px 20px;text-align:center;min-width:118px}
|
||||
.smet-v{font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;line-height:1}
|
||||
.smet-l{font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.4px;line-height:1.4}
|
||||
/* ── bigger s-title ── */
|
||||
.s-title{font-size:clamp(36px,5vw,62px)!important;line-height:.97!important}
|
||||
|
||||
</style>
|
||||
<title>LynkedUp Pro - Stop Losing Jobs When Hail Hits</title>
|
||||
<meta name="description" content="Real-time hail alerts route your reps to the highest-damage doors within minutes of a storm. Beat competitors before they know it rained."/>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a>
|
||||
<a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504309092620-4d0ec726efa4?w=1800&q=80');filter:brightness(.12) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(5,8,15,.45) 0%,rgba(5,8,15,.8) 55%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg">
|
||||
<div class="hero-grid"></div>
|
||||
<div class="hero-glow" style="background:radial-gradient(ellipse,rgba(239,68,68,.1),transparent 65%)"></div>
|
||||
</div>
|
||||
<div class="eyebrow"><span class="dot" style="background:var(--red)"></span> Storm Intelligence · Real-Time Hail Alerts</div>
|
||||
<h1>While You Check<br>the Weather App,<br><span style="color:var(--red)">They Sign Contracts.</span></h1>
|
||||
<p class="hero-sub">By the time you hear about a hail event, the best crews in your market have knocked 40 doors and signed 10 contracts. <strong>LynkedUp Pro puts you there first</strong> - your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">See It Stop a Storm Loss →</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row" style="margin-top:18px">
|
||||
<span class="micro" style="color:var(--orange)">4.9★ · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>⚡</span> Sub-hour hail alerts</span>
|
||||
<span class="micro"><span>🗺️</span> AI address scoring</span>
|
||||
<span class="micro"><span>✅</span> 30-day guarantee</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--red)">17 min</div><div class="stat-label">Storm alert → reps on doors</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">89</div><div class="stat-label">Addresses scored per event</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">11</div><div class="stat-label">Contracts signed same day</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">1st</div><div class="stat-label">On scene before competition</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- THE PAIN -->
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1527689368864-3a821dbccc34?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(11,18,32,.98) 0%,rgba(11,18,32,.93) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">Second Place<br>After a Storm Is<br><span style="color:var(--red)">Worth $0.</span></h2>
|
||||
<p class="s-sub">It's not about effort. It's about intelligence. The crews winning after every storm have one thing you don't. They know about it first.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item">
|
||||
<div class="p-icon">🌩️</div>
|
||||
<h3>You Hear About It Late</h3>
|
||||
<p>Weather apps, neighbor texts, news alerts. By the time you piece it together, the storm swath is already being worked. You're not slow, you're uninformed.</p>
|
||||
<span class="p-cost">Cost: 3-5 lost jobs per storm</span>
|
||||
</div>
|
||||
<div class="p-item">
|
||||
<div class="p-icon">🗺️</div>
|
||||
<h3>No Map, No Priority</h3>
|
||||
<p>Even when you know it hailed, which street do you hit first? Without damage scoring, your reps knock random doors while competitors work the worst-hit blocks.</p>
|
||||
<span class="p-cost">Cost: Wasted canvassing hours</span>
|
||||
</div>
|
||||
<div class="p-item">
|
||||
<div class="p-icon">🚗</div>
|
||||
<h3>Reps Route Themselves</h3>
|
||||
<p>No dispatch. No territory assignment. Reps show up wherever they feel like, overlap each other, miss entire neighborhoods, and leave money on the ground.</p>
|
||||
<span class="p-cost">Cost: Hundreds of doors unmissed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- THE SOLUTION -->
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
<!-- ══ CTA STRIP ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1449824913935-59a10b8d2000?w=1800&q=80');filter:brightness(.09) saturate(.38)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(16,4,4,.97) 100%)"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:360px;border-radius:50%;background:radial-gradient(ellipse,rgba(239,68,68,.07),transparent 70%);z-index:1;pointer-events:none"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.28);color:var(--red)">
|
||||
<span class="strip-tdot" style="background:var(--red)"></span>
|
||||
Right now, somewhere in your territory
|
||||
</div>
|
||||
<h2>Hail Fell.<br><span style="color:var(--red)">Is Anyone</span><br><span style="color:var(--orange)">Moving?</span></h2>
|
||||
<p>A competitor using LynkedUp Pro already has the swath mapped, the top addresses scored, and three reps auto-routed to the highest-damage doors. The window to be first is 17 minutes. Not hours.</p>
|
||||
<div class="smet-row"><div class="smet" style="border:1px solid rgba(239,68,68,.22)"><div class="smet-v" style="color:var(--red)"><1hr</div><div class="smet-l">Alert to reps deployed</div></div><div class="smet" style="border:1px solid rgba(245,158,11,.22)"><div class="smet-v" style="color:var(--orange)">17 min</div><div class="smet-l">Avg storm → dispatch</div></div><div class="smet" style="border:1px solid rgba(0,229,160,.22)"><div class="smet-v" style="color:var(--green)">11</div><div class="smet-l">Contracts same day</div></div><div class="smet" style="border:1px solid rgba(0,200,255,.22)"><div class="smet-v" style="color:var(--accent)">89</div><div class="smet-l">Addresses scored</div></div></div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Show Me How to Be First →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> No credit card</span>
|
||||
<span><b>✅</b> Free 30-min demo</span>
|
||||
<span><b>✅</b> 30-day guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(90deg,rgba(5,8,15,1) 42%,rgba(5,8,15,.75) 100%)"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div class="s-label" style="color:var(--accent)">The Fix</div>
|
||||
<h2 class="s-title">17 Minutes.<br><span style="color:var(--accent)">Reps Deployed.</span><br>Doors Getting Knocked.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro pulls storm data from HailTrace and HailWatch the moment a hail event is detected. Every address in the impact zone gets an AI damage score. Your reps get their route.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">📡</span><span><strong>Dual-source hail data.</strong> HailTrace + HailWatch fusion means you never miss an event in your territory, day or night.</span></li>
|
||||
<li><span class="ico">🎯</span><span><strong>AI address scoring.</strong> Every home ranked HIGH / MED / LOW by hail severity. Your reps hit the best doors first, every time.</span></li>
|
||||
<li><span class="ico">📲</span><span><strong>Auto-dispatch to field.</strong> Optimized canvassing routes pushed directly to your reps' phones within minutes of a storm ending.</span></li>
|
||||
<li><span class="ico">📎</span><span><strong>LiDAR scan on arrival.</strong> Rep deploys drone, AI builds digital twin and flags every impact zone in under 4 minutes. Proposal ready before competitors arrive.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--red)"><1 hr</div><div class="sol-stat-label">Alert to reps deployed</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">89</div><div class="sol-stat-label">Addresses scored per storm</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">2</div><div class="sol-stat-label">Storm data sources fused</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">$0</div><div class="sol-stat-label">Extra cost for hail intel</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- HAIL MAP MOCK -->
|
||||
<div class="mock-card">
|
||||
<img src="/page/assets/storm.png" alt="LynkedUp Pro Overview Dashboard" style="display:block;width:100%;height:auto" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROOF -->
|
||||
<section class="proof-section has-bg reveal">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.88) 60%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--green);text-align:center">Real Result</div>
|
||||
<h2 class="s-title" style="text-align:center">Alert at 2:14PM.<br><span style="color:var(--green)">11 Contracts</span><br>Before Dark.</h2>
|
||||
<div class="proof-card">
|
||||
<div class="proof-result"><div class="pr-num">17 min</div><div class="pr-label">Storm→Doors</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"Alert fired at 2:14 PM. My three reps were knocking doors by 2:31. By end of day we had 11 signed contracts. Our closest competitor showed up the next morning. That is literally the difference this makes, and that was week one."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#a855f7,#0ea5e9)">SA</div>
|
||||
<div><div class="proof-name">Sarah Alonzo</div><div class="proof-role">Operations Manager · Houston, TX</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(245,158,11,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:1">
|
||||
<div style="display:inline-block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--red);font-weight:600">⚡ The next storm in your territory could hit today</div>
|
||||
<h2>The Next Storm<br>Could Hit <em>Today.</em><br>Will You Be First?</h2>
|
||||
<p>Stop losing jobs to crews who just got lucky with timing. LynkedUp Pro makes luck irrelevant.</p>
|
||||
<div class="cta-form">
|
||||
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Book a demo →</a>
|
||||
</div>
|
||||
<div class="cta-micro">
|
||||
<span>✅ No credit card</span><span>✅ 30-day money-back guarantee</span><span>✅ Free onboarding</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
@keyframes hpulse{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.35);opacity:.45}}
|
||||
@keyframes hblink{0%,100%{opacity:1}50%{opacity:.55}}
|
||||
h1 .red{color:var(--red)}
|
||||
</style>
|
||||
<footer>
|
||||
<p>© 2026 LynkedUp Pro See Full Platform →<a href="https://book.lynkeduppro.com/">Privacy</a> · <a href="https://book.lynkeduppro.com/">Terms</a></p>
|
||||
<p style="margin-top:8px;font-size:12px;opacity:.5">$2000/mo flat rate · No credit card required · 30-day money-back guarantee · Built for roofing</p>
|
||||
<p style="margin-top:8px;font-size:11px;opacity:.5">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p>
|
||||
</footer>
|
||||
<div class="mob-sticky">
|
||||
<a href="https://book.lynkeduppro.com/" class="p">Book a demo</a>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add("visible")})},{threshold:.1});
|
||||
document.querySelectorAll(".reveal").forEach(el=>obs.observe(el));
|
||||
document.querySelectorAll("a[href^=\"#\"]").forEach(a=>{a.addEventListener("click",function(e){const t=document.querySelector(this.getAttribute("href"));if(t){e.preventDefault();t.scrollIntoView({behavior:"smooth",block:"start"});}}); });
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,426 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
/* ── background image helpers ── */
|
||||
.has-bg{position:relative;overflow:hidden}
|
||||
.has-bg>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.has-bg>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.has-bg>.container,.has-bg>.hero-bg,.has-bg>.eyebrow,.has-bg>h1,.has-bg>.hero-sub,.has-bg>.hero-actions,.has-bg>.micro-row,.has-bg>.stat-row{position:relative;z-index:2}
|
||||
/* ── bigger s-title ── */
|
||||
.s-title{font-size:clamp(36px,5vw,62px);line-height:.97}
|
||||
/* ── CTA strip ── */
|
||||
.cta-strip{padding:84px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip>.bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0;pointer-events:none}
|
||||
.cta-strip>.bg-ov{position:absolute;inset:0;z-index:1;pointer-events:none}
|
||||
.cta-strip>.glow{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:360px;border-radius:50%;z-index:1;pointer-events:none}
|
||||
.cta-strip>.wrap{position:relative;z-index:2;max-width:680px;margin:0 auto}
|
||||
.strip-tag{display:inline-flex;align-items:center;gap:7px;padding:5px 16px;border-radius:100px;margin-bottom:22px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.strip-tag .tdot{width:6px;height:6px;border-radius:50%;display:inline-block;animation:blink 2s ease infinite}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5vw,60px);font-weight:900;line-height:.97;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.72;max-width:500px;margin:0 auto 28px}
|
||||
.strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.strip-trust{display:flex;gap:18px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.strip-trust span b{color:var(--green)}
|
||||
/* ── savings compare cards ── */
|
||||
.save-cards{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:30px}
|
||||
.save-card{background:var(--card);border-radius:12px;padding:16px 22px;text-align:center;min-width:120px}
|
||||
.save-card .sv{font-family:'Barlow Condensed',sans-serif;font-size:30px;font-weight:900;line-height:1}
|
||||
.save-card .sl{font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px}
|
||||
@media(max-width:1280px){section{padding:80px 24px}.cta-section{padding:80px 24px}.cta-strip{padding:76px 24px}}
|
||||
@media(max-width:1024px){nav{padding:14px 24px}section{padding:72px 22px}.cta-section{padding:72px 22px}.cta-strip{padding:68px 22px}.pain-grid{grid-template-columns:repeat(2,1fr)}.sol-split{gap:36px}h1{font-size:clamp(44px,7vw,76px)}.hero{padding:108px 20px 64px}.proof-card{padding:30px 26px}.proof-result{top:18px;right:18px}.stat-row{gap:28px;margin-top:48px}}
|
||||
@media(max-width:768px){nav{padding:12px 18px}section{padding:56px 18px}.cta-section{padding:56px 18px}.cta-strip{padding:56px 18px}.hero{min-height:auto;padding:96px 16px 56px}h1{font-size:clamp(40px,9vw,64px);line-height:.95}.hero-sub{font-size:15px}.s-title{font-size:clamp(30px,7vw,46px)}.stat-row{gap:18px;margin-top:40px}.stat+.stat::before{display:none}.stat-num{font-size:32px}.proof-card{padding:26px 22px;margin-top:32px}.proof-result{position:static;display:inline-block;margin-bottom:14px}.proof-quote{font-size:15px}.cta-section h2{font-size:clamp(30px,7vw,46px)}.cta-strip h2{font-size:clamp(28px,7vw,46px)}.sol-points li{font-size:14px}.p-item{padding:22px 18px}.save-cards{gap:8px}.save-card{min-width:96px;padding:14px 16px}.save-card .sv{font-size:24px}}
|
||||
@media(max-width:480px){nav{padding:10px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}section{padding:48px 16px}.cta-section{padding:48px 16px}.cta-strip{padding:48px 16px}.hero{padding:88px 14px 48px}h1{font-size:clamp(34px,10vw,52px);letter-spacing:-.5px}.hero-sub{font-size:14px;margin-top:18px}.s-title{font-size:clamp(28px,8vw,40px);letter-spacing:0}.s-sub{font-size:15px}.micro{font-size:12px}.micro-row{gap:10px}.stat-row{flex-direction:column;gap:18px;margin-top:32px}.stat-num{font-size:28px}.pain-grid{margin-top:30px}.p-item h3{font-size:15px}.p-item p{font-size:13px}.proof-card{padding:22px 18px;border-radius:14px}.proof-quote{font-size:14px;line-height:1.7}.proof-author{flex-wrap:wrap;gap:10px}.cta-section h2,.cta-strip h2{font-size:clamp(26px,8vw,38px)}.cta-section p,.cta-strip p{font-size:14px}.btn-primary,.btn-ghost{padding:12px 22px;font-size:15px}.sol-stats{grid-template-columns:1fr 1fr;gap:8px}.sol-stat{padding:12px}.sol-stat-num{font-size:22px}.mock-card{border-radius:14px}.save-cards{flex-wrap:wrap}.save-card{min-width:80px;padding:12px 14px}.save-card .sv{font-size:22px}.cta-micro{gap:8px;font-size:11px}.strip-tag{font-size:10px;padding:4px 12px;letter-spacing:.8px}footer{padding:32px 16px 24px}footer p{font-size:12px}.mob-sticky{padding:10px 12px}.mob-sticky a{font-size:13px;padding:11px}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}}
|
||||
@media(max-width:360px){h1{font-size:32px}.s-title{font-size:26px}.cta-section h2,.cta-strip h2{font-size:24px}.btn-primary,.btn-ghost{padding:11px 18px;font-size:14px}.sol-stats{grid-template-columns:1fr}}
|
||||
@media(max-width:860px){.mob-sticky{display:flex}body{padding-bottom:72px}.sol-split{grid-template-columns:1fr!important}.pain-grid{grid-template-columns:1fr!important}}
|
||||
@media(max-width:768px){.stat+.stat::before{display:none}.stat-row{gap:18px}}
|
||||
@media(max-width:560px){.sol-stats{grid-template-columns:1fr 1fr}.stat-row{flex-direction:column;gap:18px}.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin:24px auto 0}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;align-items:stretch;max-width:300px;margin-left:auto;margin-right:auto}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}.cta-form{flex-direction:column}.cta-form input{width:100%;min-width:100%}.cta-form .btn-primary{width:100%;text-align:center}}
|
||||
@media(max-width:360px){.sol-stats{grid-template-columns:1fr}}</style>
|
||||
<title>LynkedUp Pro - Kill Your EagleView Bill. Scan in Minutes.</title>
|
||||
<meta name="description" content="Stop paying $15-$50 per EagleView report and waiting 48 hours. LynkedUp Pro AI measurements scan any roof in minutes for $0 per report, included in your flat $349/mo."/>
|
||||
</head><body>
|
||||
<nav>
|
||||
<a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a>
|
||||
<a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a>
|
||||
</nav>
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1473968512647-3e447244af8f?w=1800&q=80');filter:brightness(.12) saturate(.45)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(5,8,15,.55) 0%,rgba(5,8,15,.82) 55%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(0,200,255,.11),transparent 65%)"></div></div>
|
||||
<div class="eyebrow" style="position:relative;z-index:2"><span class="dot"></span> AI Measurements · LiDAR · $0/Report</div>
|
||||
<h1 style="position:relative;z-index:2">While You Wait<br><span style="color:var(--accent)">48 Hours,</span><br>They Sign Someone Else.</h1>
|
||||
<p class="hero-sub" style="position:relative;z-index:2">EagleView makes you pay <strong>$15-$50 per report</strong> and wait two days. That's two days for a competitor to fly their own drone, hand the homeowner a quote, and close the job that was yours. LynkedUp Pro is your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions" style="position:relative;z-index:2">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">See What You'd Save →</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row" style="position:relative;z-index:2">
|
||||
<span class="micro" style="color:var(--muted)">4.9★ · 248 reviews</span>
|
||||
<span class="micro"><span>✅</span> $0 per report - always</span>
|
||||
<span class="micro"><span>✅</span> Scan in minutes on-site</span>
|
||||
<span class="micro"><span>✅</span> Insurance-grade accuracy</span>
|
||||
</div>
|
||||
<div class="stat-row" style="position:relative;z-index:2">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">$0</div><div class="stat-label">Per report fee. Ever.</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">10×</div><div class="stat-label">Faster than EagleView</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">$700+</div><div class="stat-label">Avg monthly EagleView bill</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">48 hrs</div><div class="stat-label">Wait time eliminated</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=1800&q=80');filter:brightness(.1) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(11,18,32,.98) 0%,rgba(11,18,32,.9) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">$700 a Month<br>to <span style="color:var(--red)">Lose Jobs Faster.</span><br>That's the EagleView Deal.</h2>
|
||||
<p class="s-sub">You're funding a per-report model built for a world before drones and AI existed. Every slow turnaround and every invoice is a self-inflicted wound on your close rate.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item"><div class="p-icon">💸</div><h3>$700+ a Month in Report Fees</h3><p>20 reports at $35 each. 30 reports at $50 each. Active storm crews easily spend $700-$2,000 per month before any other software cost.</p><span class="p-cost">Cost: $8,400-$24,000/year</span></div>
|
||||
<div class="p-item"><div class="p-icon">⏳</div><h3>48-Hour Turnaround</h3><p>You order the report, wait two days, then call the homeowner back. By then, two other roofers have already been on the roof and one has a signed contract.</p><span class="p-cost">Cost: 1 in 3 leads lost to speed</span></div>
|
||||
<div class="p-item"><div class="p-icon">🔗</div><h3>Disconnected from Your Workflow</h3><p>EagleView report arrives as a PDF. You manually copy measurements into your estimate tool, then into your proposal. Three steps that should be zero.</p><span class="p-cost">Cost: 45+ min per job wasted</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ══ CTA STRIP 1 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(18,8,2,.96) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(245,158,11,.08),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.25);color:var(--orange)">
|
||||
<span class="tdot" style="background:var(--orange)"></span>
|
||||
Your money is leaving every time you order a report
|
||||
</div>
|
||||
<h2>Every Job You Quote<br>Costs <span style="color:var(--orange)">$35 Before You</span><br>Say a Word. <span style="color:var(--red)">Stop.</span></h2>
|
||||
<p>Run 20 jobs a month? That's $700 gone before a single shingle is sold. LynkedUp Pro scans the same roof in 4 minutes for exactly $0.00, and the report auto-fills your estimate.</p>
|
||||
<div class="save-cards">
|
||||
<div class="save-card" style="border:1px solid rgba(239,68,68,.2)">
|
||||
<div class="sv" style="color:var(--red)">$700+</div>
|
||||
<div class="sl">EagleView / mo</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--muted);padding:0 4px">→</div>
|
||||
<div class="save-card" style="border:1px solid rgba(0,229,160,.2)">
|
||||
<div class="sv" style="color:var(--green)">$0.00</div>
|
||||
<div class="sl">LynkedUp Pro / mo</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--muted);padding:0 4px">=</div>
|
||||
<div class="save-card" style="border:1px solid rgba(0,200,255,.2)">
|
||||
<div class="sv" style="color:var(--accent)">$8.4K+</div>
|
||||
<div class="sl">Back in your pocket / yr</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Calculate My Exact Savings →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> No per-report fees ever</span>
|
||||
<span><b>✅</b> Free 30-min demo</span>
|
||||
<span><b>✅</b> Switch in one day</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?w=1800&q=80');filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(90deg,rgba(5,8,15,1) 42%,rgba(5,8,15,.7) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--green)">The Fix</div>
|
||||
<h2 class="s-title">Scan It. Quote It.<br>Close It. <span style="color:var(--green)">Before You</span><br><span style="color:var(--accent)">Leave the Driveway.</span></h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro's AI measurement engine runs on your DJI drone. Fly the property, get centimeter-accurate measurements, and generate an insurance-grade report - all before you leave the driveway.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">🎯</span><span><strong>Sub-centimeter precision</strong> - AI + LiDAR fusion delivers measurement accuracy insurance adjusters accept on first submission.</span></li>
|
||||
<li><span class="ico">⚡</span><span><strong>Minutes on-site, not 48 hours later</strong> - fly the drone, get the numbers, hand the homeowner a proposal while you're still standing there.</span></li>
|
||||
<li><span class="ico">🔄</span><span><strong>Auto-fills your estimate</strong> - measurements feed directly into Canvas proposals. No manual re-entry. No errors. No wasted hour.</span></li>
|
||||
<li><span class="ico">📎</span><span><strong>Xactimate-style output</strong> - reports formatted exactly how adjusters expect them, with full photo documentation and storm data included.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">$0</div><div class="sol-stat-label">Per-report cost</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">~4 min</div><div class="sol-stat-label">Avg flight + scan time</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">10×</div><div class="sol-stat-label">Faster than waiting on EagleView</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:#fff">1st</div><div class="sol-stat-label">Submission approval rate</div></div>
|
||||
</div>
|
||||
<div style="margin-top:28px;background:rgba(0,229,160,.05);border:1px solid rgba(0,229,160,.18);border-radius:12px;padding:20px 22px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px">Your Monthly Savings</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
|
||||
<div style="text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;color:var(--red)">$700+</div><div style="font-size:11px;color:var(--muted)">EagleView/mo</div></div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;font-size:20px;color:var(--muted)">→</div>
|
||||
<div style="text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;color:var(--green)">$0</div><div style="font-size:11px;color:var(--muted)">LynkedUp Pro/mo</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card">
|
||||
<div class="mock-bar"><span class="mock-title">Project #1286 - AI Measurement</span><span class="mock-badge" style="background:rgba(0,200,255,.1);color:var(--accent);border:1px solid rgba(0,200,255,.25)">AI-POWERED</span></div>
|
||||
<div style="padding:14px;background:#070d18">
|
||||
<svg width="100%" viewBox="0 0 300 185" fill="none">
|
||||
<rect width="300" height="185" fill="#070d18"/>
|
||||
<polygon points="150,18 268,105 210,105 210,165 90,165 90,105 32,105" fill="rgba(0,200,255,.05)" stroke="rgba(0,200,255,.5)" stroke-width="1.2"/>
|
||||
<line x1="32" y1="110" x2="268" y2="110" stroke="rgba(0,200,255,.3)" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<text x="150" y="122" fill="rgba(0,200,255,.9)" font-size="9" text-anchor="middle" font-family="monospace" font-weight="bold">27ft 6in</text>
|
||||
<rect x="168" y="20" width="122" height="85" rx="5" fill="rgba(7,13,24,.92)" stroke="rgba(0,200,255,.15)" stroke-width="1"/>
|
||||
<text x="229" y="35" fill="rgba(255,255,255,.4)" font-size="7" text-anchor="middle" font-family="monospace">TOTAL AREA</text>
|
||||
<text x="229" y="52" fill="#00c8ff" font-size="14" text-anchor="middle" font-family="monospace" font-weight="bold">2,347 sq ft</text>
|
||||
<text x="229" y="63" fill="rgba(255,255,255,.3)" font-size="6.5" text-anchor="middle" font-family="monospace">23.47 SQUARES</text>
|
||||
<text x="168" y="76" fill="rgba(255,255,255,.35)" font-size="6.5" font-family="monospace">PLANE 1</text><text x="282" y="76" fill="rgba(255,255,255,.6)" font-size="6.5" font-family="monospace" text-anchor="end">425 SQ FT</text>
|
||||
<text x="168" y="87" fill="rgba(255,255,255,.35)" font-size="6.5" font-family="monospace">PLANE 2</text><text x="282" y="87" fill="rgba(255,255,255,.6)" font-size="6.5" font-family="monospace" text-anchor="end">362 SQ FT</text>
|
||||
<text x="168" y="98" fill="rgba(255,255,255,.35)" font-size="6.5" font-family="monospace">PLANE 3+</text><text x="282" y="98" fill="#00c8ff" font-size="6.5" font-family="monospace" text-anchor="end" font-weight="bold">1,560 SQ FT</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="padding:12px 14px;border-top:1px solid var(--border)">
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border)"><span style="color:var(--muted)">Scan Time</span><span style="font-weight:600;color:#fff">~4 min</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border)"><span style="color:var(--muted)">Planes Measured</span><span style="font-weight:600;color:#fff">14</span></div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13px;padding:8px 0;font-family:'Barlow Condensed',sans-serif;font-weight:800"><span style="color:var(--muted)">Report Cost</span><span style="color:var(--green)">$0.00</span></div>
|
||||
</div>
|
||||
<a href="https://book.lynkeduppro.com/" style="display:block;margin:0 12px 12px;background:var(--accent);color:#05080f;text-align:center;padding:9px;border-radius:7px;font-weight:700;font-size:13px;text-decoration:none">Generate Xactimate-Style Report →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ══ CTA STRIP 2 ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.96) 0%,rgba(2,14,10,.96) 100%)"></div>
|
||||
<div class="glow" style="background:radial-gradient(ellipse,rgba(0,229,160,.07),transparent 70%)"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.22);color:var(--green)">
|
||||
<span class="tdot" style="background:var(--green)"></span>
|
||||
The homeowner signs with whoever shows up first with numbers
|
||||
</div>
|
||||
<h2>Your Competitor<br>Is Already <span style="color:var(--green)">On That Roof.</span><br><span style="color:var(--accent)">Are You?</span></h2>
|
||||
<p>The job doesn't go to the best roofer. It goes to the roofer who hands over a professional proposal while still standing in the driveway. That's LynkedUp Pro, on every single job.</p>
|
||||
<!-- Speed comparison -->
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;gap:12px;align-items:center;max-width:540px;margin:0 auto 30px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:20px 24px">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px">EagleView</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:32px;font-weight:900;color:var(--red);line-height:1">48 hrs</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">to get measurements</div>
|
||||
</div>
|
||||
<div style="font-size:22px;color:var(--border2);text-align:center">vs</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;margin-bottom:8px">LynkedUp Pro</div>
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:32px;font-weight:900;color:var(--green);line-height:1">4 min</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">on-site, before you leave</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Start Closing Jobs Faster →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> $0 per report</span>
|
||||
<span><b>✅</b> Insurance-grade accuracy</span>
|
||||
<span><b>✅</b> Live in 24 hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="proof-section has-bg reveal" id="proof">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.9) 55%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--green);text-align:center">Real Result</div>
|
||||
<h2 class="s-title" style="text-align:center">He Ditched EagleView<br>on a Tuesday. <span style="color:var(--green)">Saved<br>$2,100 by Friday.</span></h2>
|
||||
<div class="proof-card">
|
||||
<div class="proof-result"><div class="pr-num">$2.1K</div><div class="pr-label">Saved/Month</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"Cut EagleView, JobNimbus, and SalesRabbit in one move. LynkedUp Pro replaced all three for less money - and the measurements are actually better. Every insurance claim gets approved on the first submission now. I don't know why I waited."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#f59e0b,#ef4444)">MT</div>
|
||||
<div><div class="proof-name">Marcus Thompson</div><div class="proof-role">Operations Manager · Houston, TX</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(245,158,11,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div style="display:inline-block;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--green);font-weight:600">💰 EagleView charges per report. We never do.</div>
|
||||
<h2>Your Next Report<br>Costs <em>$0.</em><br>So Does Every One After.</h2>
|
||||
<p>One flat rate. Unlimited scans. See exactly how much you'd save - and how fast you'd close more jobs - in a free 30-minute demo.</p>
|
||||
<div class="cta-form">
|
||||
|
||||
|
||||
</div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day money-back guarantee</span><span>✅ Free onboarding call</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- LEGAL: Founders / pricing Terms & Conditions - no pricing section on this page, placed before footer -->
|
||||
<div class="legal-terms" style="max-width:820px;margin:26px auto 0;padding:16px 20px;border:1px solid rgba(255,255,255,0.10);border-radius:12px;background:rgba(255,255,255,0.02);text-align:left">
|
||||
<details>
|
||||
<summary style="cursor:pointer;font-size:12px;font-weight:700;letter-spacing:0.05em;color:#9aa4b2;list-style:none">Disclaimer, Terms & Conditions</summary>
|
||||
<div style="margin-top:12px;font-size:11px;line-height:1.65;color:#8b95a3">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<footer>
|
||||
<p>© 2026 LynkedUp Pro · <a href="lynkeduppro_final.html">See Full Platform →</a> · <a href="https://book.lynkeduppro.com/">Privacy</a> · <a href="https://book.lynkeduppro.com/">Terms</a></p>
|
||||
<p style="margin-top:8px;font-size:12px;opacity:.5">$349/mo flat rate · No credit card required · 30-day money-back guarantee · Built for roofing</p>
|
||||
<p style="margin-top:8px;font-size:11px;opacity:.5">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p>
|
||||
</footer>
|
||||
<div class="mob-sticky">
|
||||
<a href="https://book.lynkeduppro.com/" class="p">Book a demo</a>
|
||||
<a href="lynkeduppro_final.html" class="s">See Full Platform</a>
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add("visible")})},{threshold:.1});
|
||||
document.querySelectorAll(".reveal").forEach(el=>obs.observe(el));
|
||||
document.querySelectorAll("a[href^=\"#\"]").forEach(a=>{a.addEventListener("click",function(e){const t=document.querySelector(this.getAttribute("href"));if(t){e.preventDefault();t.scrollIntoView({behavior:"smooth",block:"start"});}}); });
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,516 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:stretch;margin-top:44px}
|
||||
.sol-split > div:last-child{display:flex;align-items:center;justify-content:center;height:100%}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
@media(max-width:860px){nav{padding:14px 20px}.sol-split{grid-template-columns:1fr!important}.pain-grid{grid-template-columns:1fr!important}.sol-stats{grid-template-columns:1fr 1fr}.mob-sticky{display:flex}body{padding-bottom:72px}section{padding:64px 20px}.hero{padding:100px 16px 60px}}
|
||||
@media(max-width:560px){.stat-row{gap:24px}h1{font-size:clamp(44px,13vw,80px)}.cta-form{flex-direction:column}.cta-form input{min-width:100%}}
|
||||
@media(max-width:1280px){nav{padding:16px 32px}section{padding:80px 24px}.cta-strip{padding:72px 24px}.cta-section{padding:80px 24px}}
|
||||
@media(max-width:1024px){nav{padding:14px 24px}section{padding:72px 20px}.sol-split{grid-template-columns:1fr;gap:40px}.pain-grid{grid-template-columns:repeat(2,1fr)}.hero{padding:110px 20px 70px}h1{font-size:clamp(44px,7vw,76px)}.stat-row{gap:28px}.proof-card{padding:30px}}
|
||||
@media(max-width:768px){nav{padding:12px 16px}.nav-cta{padding:8px 16px;font-size:13px}section{padding:56px 18px}.cta-strip{padding:56px 18px}.cta-section{padding:56px 18px}.hero{padding:96px 16px 56px;min-height:auto}h1{font-size:clamp(40px,9vw,64px)}.s-title{font-size:clamp(30px,6vw,48px)!important}.stat-row{gap:20px;margin-top:44px}.stat+.stat::before{display:none}.stat-num{font-size:32px}.proof-card{padding:24px;margin-top:32px}.proof-quote{font-size:15px}.proof-result{position:static;display:inline-block;margin-bottom:14px}.cta-section h2{font-size:clamp(30px,6vw,46px)}.cta-strip h2{font-size:clamp(28px,6vw,46px)}.smet{min-width:100px;padding:12px 14px}.smet-v{font-size:24px}}
|
||||
@media(max-width:480px){body{font-size:15px}nav{padding:10px 14px}.logo-text{font-size:16px}.nav-cta{padding:7px 14px;font-size:12px}section{padding:48px 16px}.cta-strip{padding:48px 16px}.cta-section{padding:48px 16px}.hero{padding:88px 14px 48px}h1{font-size:clamp(36px,11vw,56px);max-width:100%}.hero-sub{font-size:15px}.s-title{font-size:clamp(26px,7vw,42px)!important}.s-sub{font-size:15px}.stat-row{gap:18px;margin-top:36px}.stat-num{font-size:28px}.proof-card{padding:20px}.proof-quote{font-size:14.5px;line-height:1.7}.cta-section h2{font-size:clamp(26px,8vw,40px)}.cta-strip h2{font-size:clamp(26px,8vw,40px)}.btn-primary,.btn-ghost{padding:12px 22px;font-size:15px}.pain-grid{margin-top:32px}.p-item{padding:22px 18px}.smet-row{gap:8px}.smet{min-width:calc(50% - 4px);padding:10px 12px}.mob-sticky{padding:10px 12px;gap:8px}.mob-sticky a{padding:10px;font-size:13px}.cta-micro{flex-direction:column;gap:6px}.strip-trust{flex-direction:column;gap:8px}.micro-row{gap:10px}.hero-actions{flex-direction:column;width:100%}.hero-actions .btn-primary,.hero-actions .btn-ghost{width:100%;text-align:center}.strip-btns{flex-direction:column;width:100%}.strip-btns .btn-primary,.strip-btns .btn-ghost{width:100%;text-align:center}.cta-form .btn-primary{width:100%;text-align:center}}
|
||||
@media(max-width:380px){h1{font-size:clamp(30px,12vw,48px)}.s-title{font-size:clamp(24px,8vw,38px)!important}.stat-num{font-size:24px}.sol-stats{grid-template-columns:1fr}.smet{min-width:100%}}
|
||||
/* ── bg helpers (proven v3) ── */
|
||||
.bg-img{position:absolute!important;inset:0!important;z-index:0!important;background-size:cover!important;background-position:center!important;pointer-events:none}
|
||||
.bg-ov{position:absolute!important;inset:0!important;z-index:1!important;pointer-events:none}
|
||||
.has-bg{position:relative!important;overflow:hidden!important}
|
||||
.has-bg>.container,.has-bg>.hero-bg,.has-bg>.eyebrow,
|
||||
.has-bg>h1,.has-bg>.hero-sub,.has-bg>.hero-actions,
|
||||
.has-bg>.micro-row,.has-bg>.stat-row,.has-bg>.wrap{position:relative!important;z-index:2!important}
|
||||
/* ── CTA strip ── */
|
||||
.cta-strip{padding:84px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip>.wrap{position:relative;z-index:2;max-width:700px;margin:0 auto}
|
||||
.strip-tag{display:inline-flex;align-items:center;gap:7px;padding:5px 16px;border-radius:100px;margin-bottom:22px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.strip-tdot{width:6px;height:6px;border-radius:50%;display:inline-block;animation:blink 2s ease infinite}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5vw,60px);font-weight:900;line-height:.97;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.72;max-width:520px;margin:0 auto 28px}
|
||||
.strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.strip-trust{display:flex;gap:18px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.strip-trust b{color:var(--green)}
|
||||
.smet-row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-bottom:28px}
|
||||
.smet{background:var(--card);border-radius:12px;padding:14px 20px;text-align:center;min-width:118px}
|
||||
.smet-v{font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;line-height:1}
|
||||
.smet-l{font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.4px;line-height:1.4}
|
||||
/* ── bigger s-title ── */
|
||||
.s-title{font-size:clamp(36px,5vw,62px)!important;line-height:.97!important}
|
||||
|
||||
</style>
|
||||
<title>LynkedUp Pro - Replace 5 Roofing Tools With One $349/mo Platform</title>
|
||||
<meta name="description" content="Replace EagleView, JobNimbus, SalesRabbit, and more. One flat $349/mo. No per-user fees. No per-report charges. All 6 core systems built for roofing."/>
|
||||
</head><body>
|
||||
<nav>
|
||||
<a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a>
|
||||
<a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a>
|
||||
</nav>
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=1800&q=80');filter:brightness(.11) saturate(.4)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(5,8,15,.45) 0%,rgba(5,8,15,.8) 55%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(0,229,160,.08),transparent 65%)"></div></div>
|
||||
<div class="eyebrow"><span class="dot" style="background:var(--green)"></span> All-In-One Platform · Flat $349/mo · 6 Core Systems</div>
|
||||
<h1>Count Your<br><span style="color:var(--red)">Subscriptions.</span><br><span style="color:var(--green)">Now Cut Them.</span></h1>
|
||||
<p class="hero-sub">EagleView. JobNimbus. SalesRabbit. A billing tool. A proposal tool. <strong>None of them talk to each other.</strong> LynkedUp Pro replaces all of them for $349/mo flat, and they all work together. It's your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Book a demo</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro" style="color:var(--muted)"><span style="color:var(--orange)">4.9★</span> · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>✅</span> 6 core systems, one login</span>
|
||||
<span class="micro"><span>✅</span> No per-user fees</span>
|
||||
<span class="micro"><span>✅</span> No per-report charges</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">$349</div><div class="stat-label">Flat rate - everything included</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">6</div><div class="stat-label">Core systems in one platform</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">$800+</div><div class="stat-label">Avg monthly savings vs. stack</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">1</div><div class="stat-label">Login. One source of truth.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(11,18,32,.98) 0%,rgba(11,18,32,.93) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Real Cost</div>
|
||||
<h2 class="s-title">$1,300 a Month.<br>5 Logins. Zero<br><span style="color:var(--red)">Working Together.</span></h2>
|
||||
<p class="s-sub">You're not just overpaying. You're also wasting hours every week copying data between tools that were never designed to work together.</p>
|
||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:14px;padding:28px;margin-top:40px">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--red);text-transform:uppercase;letter-spacing:1px;margin-bottom:20px">What most Texas roofing crews pay right now</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:20px">
|
||||
<div style="background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.15);border-radius:10px;padding:16px;text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--red)">$700+</div><div style="font-size:12px;color:var(--muted);margin-top:4px">EagleView reports/mo</div></div>
|
||||
<div style="background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.15);border-radius:10px;padding:16px;text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--red)">$300+</div><div style="font-size:12px;color:var(--muted);margin-top:4px">JobNimbus or AccuLynx</div></div>
|
||||
<div style="background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.15);border-radius:10px;padding:16px;text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--red)">$200+</div><div style="font-size:12px;color:var(--muted);margin-top:4px">SalesRabbit canvassing</div></div>
|
||||
<div style="background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.15);border-radius:10px;padding:16px;text-align:center"><div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--red)">$100+</div><div style="font-size:12px;color:var(--muted);margin-top:4px">Billing + proposal tools</div></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 0;border-top:1px solid var(--border);flex-wrap:wrap;gap:12px">
|
||||
<div><div style="font-family:'Barlow Condensed',sans-serif;font-size:36px;font-weight:900;color:var(--red)">$1,300+</div><div style="font-size:13px;color:var(--muted)">Total monthly tool stack</div></div>
|
||||
<div style="font-size:24px;color:var(--muted)">→</div>
|
||||
<div style="text-align:right"><div style="font-family:'Barlow Condensed',sans-serif;font-size:36px;font-weight:900;color:var(--green)">$349</div><div style="font-size:13px;color:var(--muted)">LynkedUp Pro - all-in</div></div>
|
||||
<div style="text-align:right"><div style="font-family:'Barlow Condensed',sans-serif;font-size:36px;font-weight:900;color:var(--orange)">$951</div><div style="font-size:13px;color:var(--muted)">Saved every month</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pain-grid" style="margin-top:16px">
|
||||
<div class="p-item"><div class="p-icon">🔀</div><h3>Data Lives in 5 Places</h3><p>Lead is in JobNimbus. Measurement is in EagleView. Route is in SalesRabbit. Invoice is in QuickBooks. Nothing syncs. Your team copies and pastes all day.</p><span class="p-cost">Cost: 2+ hours/day wasted</span></div>
|
||||
<div class="p-item"><div class="p-icon">💳</div><h3>Hidden Fees Everywhere</h3><p>EagleView per report. JobNimbus per user. SalesRabbit per user. AccuLynx add-ons. Your "software budget" doubles every time you add a rep.</p><span class="p-cost">Cost: Unpredictable monthly bill</span></div>
|
||||
<div class="p-item"><div class="p-icon">🧩</div><h3>None of It Was Built for Roofing</h3><p>JobNimbus serves landscapers, plumbers, HVAC. SalesRabbit serves solar and insurance. You're adapting general tools to a specialized trade. That's the real problem.</p><span class="p-cost">Cost: Constant workarounds</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ CTA STRIP ══ -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1800&q=80');filter:brightness(.09) saturate(.38)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(2,10,5,.97) 100%)"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:360px;border-radius:50%;background:radial-gradient(ellipse,rgba(239,68,68,.06),transparent 70%);z-index:1;pointer-events:none"></div>
|
||||
<div class="wrap">
|
||||
<div class="strip-tag" style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.28);color:var(--red)">
|
||||
<span class="strip-tdot" style="background:var(--red)"></span>
|
||||
Your tool stack bill arrives again next month
|
||||
</div>
|
||||
<h2>$951 Back<br>in Your Pocket.<br><span style="color:var(--green)">Every. Single. Month.</span></h2>
|
||||
<p>The math is simple. You're paying over $1,300/mo for tools that were never designed to work together. LynkedUp Pro does all of it for $349 flat, and the data flows between every module automatically.</p>
|
||||
<div class="smet-row"><div class="smet" style="border:1px solid rgba(239,68,68,.22)"><div class="smet-v" style="color:var(--red)">$1,300+</div><div class="smet-l">Current tool stack /mo</div></div><div class="smet" style="border:1px solid rgba(0,229,160,.22)"><div class="smet-v" style="color:var(--green)">$349</div><div class="smet-l">LynkedUp Pro flat rate</div></div><div class="smet" style="border:1px solid rgba(245,158,11,.22)"><div class="smet-v" style="color:var(--orange)">$951</div><div class="smet-l">Monthly savings</div></div><div class="smet" style="border:1px solid rgba(0,200,255,.22)"><div class="smet-v" style="color:var(--accent)">$11.4K</div><div class="smet-l">Annual back in pocket</div></div></div>
|
||||
<div class="strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Show Me My Savings →</a>
|
||||
|
||||
</div>
|
||||
<div class="strip-trust">
|
||||
<span><b>✅</b> No credit card</span>
|
||||
<span><b>✅</b> Free 30-min demo</span>
|
||||
<span><b>✅</b> 30-day guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(90deg,rgba(5,8,15,1) 42%,rgba(5,8,15,.75) 100%)"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div class="s-label" style="color:var(--green)">The Fix</div>
|
||||
<h2 class="s-title">Cancel 4 Bills.<br><span style="color:var(--green)">Keep One.</span><br>Run It All Better.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">Every tool your roofing business needs - built to work together from day one. One login, one source of truth, one flat price that doesn't change when you hire your next rep.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:20px">
|
||||
<div style="background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">👥</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">CRM</div><div style="font-size:12px;color:var(--muted)">Pipeline, leads, AI insights</div></div>
|
||||
<div style="background:var(--card);border:1px solid rgba(245,158,11,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">🎨</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">Canvas</div><div style="font-size:12px;color:var(--muted)">Estimates, proposals, e-sign</div></div>
|
||||
<div style="background:var(--card);border:1px solid rgba(0,229,160,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">📐</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">Measurements</div><div style="font-size:12px;color:var(--muted)">AI + LiDAR, $0/report</div></div>
|
||||
<div style="background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">⭐</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">Client Experience</div><div style="font-size:12px;color:var(--muted)">Portals, reviews, follow-ups</div></div>
|
||||
<div style="background:var(--card);border:1px solid rgba(245,158,11,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">💳</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">Billing</div><div style="font-size:12px;color:var(--muted)">Invoices, payments, cash flow</div></div>
|
||||
<div style="background:var(--card);border:1px solid rgba(0,229,160,.15);border-radius:10px;padding:16px"><div style="font-size:18px;margin-bottom:8px">🧮</div><div style="font-family:'Barlow Condensed',sans-serif;font-size:15px;font-weight:700;color:#fff;margin-bottom:4px;text-transform:uppercase">Estimating</div><div style="font-size:12px;color:var(--muted)">Auto pricing, templates</div></div>
|
||||
</div>
|
||||
<div style="margin-top:16px;background:rgba(0,229,160,.04);border:1px solid rgba(0,229,160,.18);border-radius:10px;padding:16px;font-size:14px;color:var(--muted);line-height:1.65">
|
||||
<strong style="color:var(--green)">Plus hail alerts, smart calendar, adjuster reports, LiDAR scanning, and GPS rep tracking</strong>. All included. No add-ons. No surprises. No per-user math.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-card">
|
||||
<img src="/page/assets/overview13 (1).png" alt="LynkedUp Pro Overview Dashboard" style="display:block;width:100%;height:auto" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="proof-section has-bg reveal">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=1800&q=80');filter:brightness(.09) saturate(.35)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.88) 60%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--green);text-align:center">Real Result</div>
|
||||
<h2 class="s-title" style="text-align:center">He Cut Three Tools<br>on a Tuesday. <span style="color:var(--green)">Saved<br>$2,100 by Friday.</span></h2>
|
||||
<div class="proof-card">
|
||||
<div class="proof-result"><div class="pr-num">$2.1K</div><div class="pr-label">Saved/Month</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"Cut EagleView, JobNimbus, and SalesRabbit in one move. LynkedUp Pro replaced all three for less, and the data actually flows between them. My team doesn't copy and paste anything anymore. It's a completely different way of running the business."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#f59e0b,#ef4444)">MT</div>
|
||||
<div><div class="proof-name">Marcus Thompson</div><div class="proof-role">Operations Manager · Houston, TX</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-ov" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(245,158,11,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:1">
|
||||
<div style="display:inline-block;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--green);font-weight:600">💰 Most crews save $800-$1,100/mo by switching to LynkedUp Pro</div>
|
||||
<h2>Your Stack Bill<br>Is Due Next Month.<br><em>Make It the Last One.</em></h2>
|
||||
<p>Cancel your EagleView, JobNimbus, and SalesRabbit subscriptions. Book a 30-minute demo and we'll show you exactly how.</p>
|
||||
<div class="cta-form">
|
||||
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary" onclick="event.preventDefault()">Book a demo</a>
|
||||
</div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day money-back guarantee</span><span>✅ Free onboarding + data migration help</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
<p>© 2026 LynkedUp Pro · <a href="lynkeduppro_final.html">See Full Platform →</a> · <a href="https://book.lynkeduppro.com/">Privacy</a> · <a href="https://book.lynkeduppro.com/">Terms</a></p>
|
||||
<p style="margin-top:8px;font-size:12px;opacity:.5">$349/mo flat rate · No credit card required · 30-day money-back guarantee · Built for roofing</p>
|
||||
<p style="margin-top:8px;font-size:11px;opacity:.5">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p>
|
||||
</footer>
|
||||
<div class="mob-sticky">
|
||||
<a href="https://book.lynkeduppro.com/" class="p">Book a demo</a>
|
||||
<a href="lynkeduppro_final.html" class="s">See Full Platform</a>
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add("visible")})},{threshold:.1});
|
||||
document.querySelectorAll(".reveal").forEach(el=>obs.observe(el));
|
||||
document.querySelectorAll("a[href^=\"#\"]").forEach(a=>{a.addEventListener("click",function(e){const t=document.querySelector(this.getAttribute("href"));if(t){e.preventDefault();t.scrollIntoView({behavior:"smooth",block:"start"});}}); });
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,481 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>LynkedUp Pro - Know Your Conversion Rate. Know Who's Performing.</title>
|
||||
<meta name="description" content="Track verified leads, unverified leads, conversion rate, agent revenue volume, and deal count. Know who's performing and where every lead stands."/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
.shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 36px 72px rgba(0, 0, 0, .5);
|
||||
}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
@media(max-width:1280px){section{padding:80px 24px}nav{padding:16px 32px}}
|
||||
@media(max-width:1024px){section{padding:72px 24px}.pain-grid{grid-template-columns:repeat(2,1fr)}.sol-split{grid-template-columns:1fr;gap:40px}h1{font-size:clamp(42px,7vw,72px)}.s-title{font-size:clamp(30px,4.5vw,48px)}.cta-section h2{font-size:clamp(32px,5vw,52px)}}
|
||||
@media(max-width:860px){nav{padding:14px 20px}.sol-split{grid-template-columns:1fr!important;gap:36px}.pain-grid{grid-template-columns:1fr!important}.sol-stats{grid-template-columns:1fr 1fr}.mob-sticky{display:flex}body{padding-bottom:72px}section{padding:64px 20px}.hero{padding:100px 16px 60px;min-height:auto}.hero-actions{flex-direction:column;width:100%;max-width:320px;margin-left:auto;margin-right:auto}.hero-actions .btn-primary,.hero-actions .btn-ghost{text-align:center;width:100%}.stat-row{gap:28px}.stat-num{font-size:32px}.cta-section{padding:64px 20px}.proof-card{padding:28px 22px}.proof-result{position:static;display:inline-block;margin:0 0 14px}}
|
||||
@media(max-width:560px){.stat-row{gap:20px;flex-direction:column}.stat+.stat::before{display:none}h1{font-size:clamp(36px,11vw,64px)}.s-title{font-size:clamp(26px,7.5vw,40px)}.cta-section h2{font-size:clamp(28px,8vw,44px)}.cta-form{flex-direction:column}.cta-form input{min-width:100%;width:100%}.cta-form .btn-primary{width:100%;text-align:center}.sol-stats{grid-template-columns:1fr}.hero-sub{font-size:15px}.micro-row{gap:10px}section{padding:56px 16px}.hero{padding:96px 16px 48px}.eyebrow{font-size:11px;padding:5px 12px}.proof-quote{font-size:15px}.cta-section p{font-size:15px}}
|
||||
@media(max-width:380px){nav{padding:12px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}h1{font-size:clamp(32px,11vw,48px)}.s-title{font-size:24px}.btn-primary,.btn-ghost{padding:12px 22px;font-size:14px}.cta-section h2{font-size:28px}.proof-card{padding:22px 18px}}</style></head><body>
|
||||
<nav><a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a><a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a></nav>
|
||||
|
||||
<section class="hero" id="home">
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(0,200,255,.09),transparent 65%)"></div></div>
|
||||
<div class="eyebrow"><span class="dot"></span> Pipeline Metrics · Conversion Tracking · Agent Performance</div>
|
||||
<h1>You Don't Know<br>Your Conversion Rate.<br><span style="color:var(--accent)">That's the Problem.</span></h1>
|
||||
<p class="hero-sub"><strong>How many leads are verified? How many converted?</strong> Which rep closes and which one stalls? Without metrics you're managing on vibes, and losing revenue you don't even know you're losing. LynkedUp Pro is your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">See Pipeline Metrics →</a>
|
||||
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro" style="color:var(--muted)">4.9★ · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>✅</span> Verified vs unverified leads</span>
|
||||
<span class="micro"><span>✅</span> Live conversion rate</span>
|
||||
<span class="micro"><span>✅</span> Per-agent revenue tracking</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">4</div><div class="stat-label">Lead status categories</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">Live</div><div class="stat-label">Conversion rate dashboard</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">Per</div><div class="stat-label">Agent revenue & deal count</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">Role</div><div class="stat-label">Based access control</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pain-section reveal" id="problem">
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">Your Pipeline Is Full.<br><span style="color:var(--red)">But You're Still Missing Numbers.</span></h2>
|
||||
<p class="s-sub">A big pipeline looks great. But if you don't know your conversion rate, you can't fix it. If you don't track per-agent volume, your best rep is carrying your weakest one.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item"><div class="p-icon">❓</div><h3>No Lead Verification</h3><p>Is this a real homeowner ready to buy, or a tire-kicker your rep spent 3 hours on? Unverified leads pollute your pipeline and inflate your numbers.</p><span class="p-cost">Cost: Wasted rep hours daily</span></div>
|
||||
<div class="p-item"><div class="p-icon">📉</div><h3>Conversion Rate Invisible</h3><p>You know how many leads came in. You know how many jobs closed. But no one has calculated the rate in between, and that's where all the coaching opportunity lives.</p><span class="p-cost">Cost: Uncoached underperformers</span></div>
|
||||
<div class="p-item"><div class="p-icon">👤</div><h3>No Per-Agent Visibility</h3><p>One rep closes 70% of their deals. Another closes 20%. Both look the same on the surface because nobody's tracking deal count and revenue volume per person.</p><span class="p-cost">Cost: Wrong reps in wrong territories</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== CTA STRIP 1 ===== -->
|
||||
<section style="padding:72px 24px;background:linear-gradient(135deg,#080e1a 0%,#0a1220 50%,#06090f 100%);border-top:1px solid rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.12);text-align:center;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:600px;height:300px;background:radial-gradient(ellipse,rgba(239,68,68,.05),transparent 70%);pointer-events:none"></div>
|
||||
<div style="max-width:600px;margin:0 auto;position:relative;z-index:1">
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:100px;padding:5px 14px;margin-bottom:20px">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:var(--red);display:inline-block;animation:blink 2s ease infinite"></span>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--red);letter-spacing:1px;text-transform:uppercase">You're Bleeding Revenue Right Now</span>
|
||||
</div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif;font-size:clamp(32px,4.5vw,52px);font-weight:900;color:#fff;line-height:1.05;text-transform:uppercase;letter-spacing:-.5px;margin-bottom:14px">You Can't Fix<br><span style="color:var(--red)">What You Can't See.</span></h2>
|
||||
<p style="font-size:16px;color:var(--muted);line-height:1.7;max-width:460px;margin:0 auto 28px">Right now, one of your reps is closing 20% while another closes 70%. Same leads. Same territory. You just don't know who. <strong style="color:var(--text)">Let's fix that in 30 minutes.</strong></p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||
|
||||
<a href="#solution" class="btn-ghost">See How It Works</a>
|
||||
</div>
|
||||
<div style="display:flex;gap:20px;justify-content:center;margin-top:16px;flex-wrap:wrap">
|
||||
<span style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span> Free 30-min demo</span>
|
||||
<span style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span> No credit card required</span>
|
||||
<span style="font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span> Live pipeline data</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section reveal visible" id="solution">
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--accent)">The Fix</div>
|
||||
<h2 class="s-title">Every Metric<br>That <span style="color:var(--accent)">Actually</span><br>Matters.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro tracks every lead through a verified status pipeline and gives every manager and owner live conversion data, per-agent breakdowns, and deal volume, all in one dashboard.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">✅</span><span><strong>4 lead status categories</strong>: Verified, Unverified, Converted, and Lost. Every lead correctly categorized from first contact.</span></li>
|
||||
<li><span class="ico">📊</span><span><strong>Live conversion rate</strong>: per team, per rep, per territory. Updated in real time as leads move through the pipeline.</span></li>
|
||||
<li><span class="ico">💰</span><span><strong>Per-agent revenue volume</strong>: total revenue attributed to each rep, plus deal count, close rate, and average deal size.</span></li>
|
||||
<li><span class="ico">🔐</span><span><strong>Role-based access</strong>: reps see their own numbers, managers see their team, admins assign leads, owners see everything. No data leakage.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">Real-Time</div><div class="sol-stat-label">Conversion rate tracking</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">4</div><div class="sol-stat-label">Lead verification statuses</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">Per Rep</div><div class="sol-stat-label">Revenue & deal count</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:#fff">Admin</div><div class="sol-stat-label">Lead assignment controls</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PIPELINE METRICS DASHBOARD SCREENSHOT -->
|
||||
<img src="/page/assets/dashboard.png" alt="LynkedUp Pro pipeline metrics and agent performance dashboard" class="shot" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<!-- ===== CTA STRIP 2 ===== -->
|
||||
<section style="padding:80px 24px;background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border);text-align:center;position:relative;overflow:hidden">
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:400px;background:radial-gradient(ellipse,rgba(0,200,255,.05),transparent 70%);pointer-events:none"></div>
|
||||
<div style="max-width:680px;margin:0 auto;position:relative;z-index:1">
|
||||
<div style="display:inline-block;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);border-radius:8px;padding:7px 18px;margin-bottom:22px;font-size:12px;color:var(--accent);font-weight:600;letter-spacing:.3px;text-transform:uppercase">📊 Live for 500+ Roofing Companies</div>
|
||||
<h2 style="font-family:'Barlow Condensed',sans-serif;font-size:clamp(32px,4.5vw,54px);font-weight:900;color:#fff;line-height:1;text-transform:uppercase;letter-spacing:-.5px;margin-bottom:14px">Stop Managing<br>on <span style="color:var(--orange)">Gut Feeling.</span><br><span style="color:var(--accent)">Start With Real Numbers.</span></h2>
|
||||
<p style="font-size:16px;color:var(--muted);line-height:1.7;max-width:480px;margin:0 auto 32px">The dashboard is live in 24 hours. You'll see your conversion rate, your verified vs unverified lead split, and every rep's numbers, before your next morning huddle.</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:32px">
|
||||
<div style="background:var(--card);border:1px solid rgba(0,200,255,.18);border-radius:10px;padding:14px 20px;min-width:130px;text-align:center">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;color:var(--accent);line-height:1">38.2%</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">Conversion Rate</div>
|
||||
</div>
|
||||
<div style="background:var(--card);border:1px solid rgba(0,229,160,.18);border-radius:10px;padding:14px 20px;min-width:130px;text-align:center">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;color:var(--green);line-height:1">89</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">Verified Leads</div>
|
||||
</div>
|
||||
<div style="background:var(--card);border:1px solid rgba(245,158,11,.18);border-radius:10px;padding:14px 20px;min-width:130px;text-align:center">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;color:var(--orange);line-height:1">$9K</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">Top Rep Revenue</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Get the Dashboard Free →</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="proof-section reveal">
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--accent);text-align:center">Real Impact</div>
|
||||
<h2 class="s-title" style="text-align:center">Found the Problem.<br><span style="color:var(--accent)">Fixed the Team. Fast.</span></h2>
|
||||
<div class="proof-card">
|
||||
<div class="proof-result"><div class="pr-num">+43%</div><div class="pr-label">Close Rate</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"When I saw the agent breakdown for the first time, I realized one rep had an 18% close rate while another had 68%. Same leads, same territory. I moved assignments around and the lower performer jumped to 34% in six weeks. I would never have known without the data."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#0ea5e9,#00e5a0)">JR</div>
|
||||
<div><div class="proof-name">Jason Rodriguez</div><div class="proof-role">Owner · Dallas, TX · 14-person crew</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section" id="cta">
|
||||
<div class="container" style="position:relative;z-index:1">
|
||||
<div style="display:inline-block;background:rgba(0,200,255,.08);border:1px solid rgba(0,200,255,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--accent);font-weight:600">📊 Know your conversion rate before your next storm season</div>
|
||||
<h2>Know Who's Performing.<br><em>Fix What Isn't.</em></h2>
|
||||
<p>See the full pipeline metrics dashboard, with verified leads, conversion rate, and per-agent revenue, in a free 30-minute demo.</p>
|
||||
<div class="cta-form"><a href="https://book.lynkeduppro.com/" class="btn-primary" onclick="event.preventDefault()">See Metrics Dashboard →</a></div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day guarantee</span><span>✅ Free onboarding</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer style="padding:32px 24px;border-top:1px solid var(--border);text-align:center"><p style="font-size:13px;color:var(--muted)">© 2026 LynkedUp Pro Full Platform →</p><p style="font-size:11px;color:var(--muted);opacity:.7;margin-top:10px">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p></footer>
|
||||
<div class="mob-sticky"><a href="https://book.lynkeduppro.com/" class="p">Book a demo</a><a href="lynkeduppro_final.html" class="s">See All Features</a></div>
|
||||
<script>const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add('visible')})},{threshold:.1});document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,470 @@
|
||||
<!DOCTYPE html><html lang="en"><head>
|
||||
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<title>LynkedUp Pro - Everything You Need in 60 Seconds</title>
|
||||
<meta name="description" content="Replace EagleView, JobNimbus & SalesRabbit. AI measurements. Hail alerts. Smart calendar. Flat 49/mo. 30-day guarantee."/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
@media(max-width:860px){nav{padding:14px 20px}.sol-split{grid-template-columns:1fr!important}.pain-grid{grid-template-columns:1fr!important}.sol-stats{grid-template-columns:1fr 1fr}.mob-sticky{display:flex}body{padding-bottom:72px}section{padding:64px 20px}.hero{padding:100px 16px 60px}}
|
||||
@media(max-width:560px){.stat-row{gap:24px}h1{font-size:clamp(44px,13vw,80px)}.cta-form{flex-direction:column}.cta-form input{min-width:100%}}
|
||||
.quick-hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:80px 24px;position:relative;overflow:hidden}
|
||||
.quick-bg{inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 80% 80% at 50% 50%,black,transparent)}
|
||||
.glow-c{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:1000px;height:700px;background:radial-gradient(ellipse,rgba(0,200,255,.07),transparent 65%);pointer-events:none}
|
||||
.q-eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:28px;letter-spacing:.3px}
|
||||
.q-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:blink 1.5s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
||||
.q-h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(52px,9vw,110px);font-weight:900;line-height:.9;letter-spacing:-2px;color:#fff;text-transform:uppercase;max-width:900px}
|
||||
.q-h1 .blue{color:var(--accent)}.q-h1 .orange{color:var(--orange)}
|
||||
.q-sub{font-size:clamp(16px,2vw,20px);color:var(--muted);max-width:560px;margin:20px auto 0;line-height:1.6}
|
||||
.q-sub strong{color:#fff}
|
||||
.q-actions{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.q-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;max-width:900px;margin:60px auto 0}
|
||||
.q-stat{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px 16px;text-align:center;transition:border-color .25s}
|
||||
.q-stat:hover{border-color:var(--border2)}
|
||||
.q-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:42px;font-weight:900;line-height:1;margin-bottom:6px}
|
||||
.q-stat-label{font-size:13px;color:var(--muted);line-height:1.4}
|
||||
.q-strip{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:24px;margin-top:0}
|
||||
.q-strip-inner{max-width:900px;margin:0 auto;display:flex;gap:0;flex-wrap:wrap}
|
||||
.q-strip-item{flex:1;min-width:160px;padding:0 20px;text-align:center;border-right:1px solid var(--border)}
|
||||
.q-strip-item:last-child{border-right:none}
|
||||
.q-strip-icon{font-size:24px;margin-bottom:6px}
|
||||
.q-strip-title{font-family:'Barlow Condensed',sans-serif;font-size:14px;font-weight:700;color:#fff;text-transform:uppercase;margin-bottom:4px}
|
||||
.q-strip-desc{font-size:12px;color:var(--muted);line-height:1.5}
|
||||
.q-vs{background:var(--bg);padding:48px 24px}
|
||||
.q-vs-inner{max-width:860px;margin:0 auto;display:grid;grid-template-columns:1fr auto 1fr;gap:24px;align-items:center}
|
||||
.q-vs-col{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px}
|
||||
.q-vs-col.win{border-color:rgba(0,229,160,.3);background:linear-gradient(160deg,rgba(0,229,160,.04),var(--card))}
|
||||
.q-vs-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:14px}
|
||||
.q-vs-items{display:flex;flex-direction:column;gap:10px}
|
||||
.q-vs-item{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted)}
|
||||
.q-vs-item .ico{font-size:14px;min-width:20px;text-align:center}
|
||||
.q-vs-sep{font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--muted);text-align:center}
|
||||
.q-cta{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:72px 24px;text-align:center}
|
||||
.q-price{font-family:'Barlow Condensed',sans-serif;font-size:clamp(60px,8vw,100px);font-weight:900;color:var(--orange);line-height:1;letter-spacing:-2px}
|
||||
.q-price sub{font-size:24px;font-weight:500;color:var(--muted);letter-spacing:0}
|
||||
.q-price-note{font-size:17px;color:var(--muted);margin:10px 0 32px}
|
||||
@media(max-width:680px){.q-stats{grid-template-columns:1fr 1fr}.q-strip-item{min-width:50%;border-bottom:1px solid var(--border);border-right:none;padding:12px 16px}.q-vs-inner{grid-template-columns:1fr}.q-vs-sep{display:none}}
|
||||
@media(max-width:1280px){section{padding:80px 24px}nav{padding:16px 32px}.q-cta{padding:64px 24px}.q-vs{padding:40px 24px}}
|
||||
@media(max-width:1024px){section{padding:72px 24px}.q-stats{grid-template-columns:repeat(2,1fr);max-width:600px}.q-strip-inner{flex-wrap:wrap}.q-strip-item{flex:1 1 33.33%;min-width:33.33%;border-bottom:1px solid var(--border)}.q-h1{font-size:clamp(46px,8vw,84px)}.q-vs-inner{gap:18px}}
|
||||
@media(max-width:860px){nav{padding:14px 20px}.sol-split{grid-template-columns:1fr!important;gap:36px}.pain-grid{grid-template-columns:1fr!important}.sol-stats{grid-template-columns:1fr 1fr}.mob-sticky{display:flex}body{padding-bottom:72px}section{padding:64px 20px}.hero{padding:100px 16px 60px}.quick-hero{padding:96px 20px 56px;min-height:auto}.q-actions{flex-direction:column;width:100%;max-width:320px;margin-left:auto;margin-right:auto}.q-actions .btn-primary,.q-actions .btn-ghost{text-align:center;width:100%}.q-h1{font-size:clamp(40px,9vw,72px)}.q-strip{padding:20px 16px}.q-strip-inner{gap:0}.q-vs{padding:48px 20px}.q-vs-col{padding:20px}.q-cta{padding:56px 20px}}
|
||||
@media(max-width:560px){.stat-row{gap:24px}h1{font-size:clamp(44px,13vw,80px)}.cta-form{flex-direction:column}.cta-form input{min-width:100%}.q-h1{font-size:clamp(36px,11vw,60px);letter-spacing:-1px}.q-sub{font-size:15px}.q-stats{grid-template-columns:1fr 1fr;gap:10px}.q-stat{padding:18px 12px}.q-stat-num{font-size:32px}.q-strip-item{flex:1 1 50%;min-width:50%}.q-vs-col{padding:18px}.q-vs-item{font-size:13px}.q-price{font-size:clamp(48px,14vw,72px)}.q-price-note{font-size:15px}section{padding:56px 16px}.eyebrow,.q-eyebrow{font-size:11px;padding:5px 12px}}
|
||||
@media(max-width:380px){nav{padding:12px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}.q-h1{font-size:clamp(32px,11vw,52px)}.q-stats{grid-template-columns:1fr}.q-strip-item{flex:1 1 100%;min-width:100%}.btn-primary,.btn-ghost{padding:12px 22px;font-size:14px}.q-actions .btn-primary{font-size:16px!important;padding:14px 28px!important}}
|
||||
</style></head><body>
|
||||
<nav><a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a><a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a></nav>
|
||||
|
||||
<section class="quick-hero" id="home">
|
||||
<div class="quick-bg"></div><div class="glow-c" aria-hidden="true"></div>
|
||||
<div class="q-eyebrow"><span class="q-dot"></span> Built for Roofing · Flat 49/mo · 30-Day Guarantee</div>
|
||||
<h1 class="q-h1">One Tool.<br><span class="blue">Every Job.</span><br><span class="orange">Done.</span></h1>
|
||||
<p class="q-sub">Replace <strong>EagleView, JobNimbus & SalesRabbit</strong> with one AI-powered roofing platform. Hail alerts. AI measurements. Smart calendar. All in. It's your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="q-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary" style="font-size:18px;padding:16px 40px">Book a demo →</a>
|
||||
<a href="lynkeduppro_final.html" class="btn-ghost" style="font-size:16px;padding:16px 28px">See All Features</a>
|
||||
</div>
|
||||
<div style="font-size:14px;color:var(--muted);margin-top:16px">4.9★ · 248 reviews</div>
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:16px;margin-top:18px;flex-wrap:wrap">
|
||||
<span style="font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span>No credit card</span>
|
||||
<span style="font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span>30-day guarantee</span>
|
||||
<span style="font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px"><span style="color:var(--green)">✅</span>Cancel anytime</span>
|
||||
</div>
|
||||
<div class="q-stats">
|
||||
<div class="q-stat"><div class="q-stat-num" style="color:var(--orange)">$0</div><div class="q-stat-label">Per-report fee. Ever.</div></div>
|
||||
<div class="q-stat"><div class="q-stat-num" style="color:var(--accent)">17 min</div><div class="q-stat-label">Storm alert to reps on doors</div></div>
|
||||
<div class="q-stat"><div class="q-stat-num" style="color:var(--green)">+43%</div><div class="q-stat-label">Close rate lift (beta crews)</div></div>
|
||||
<div class="q-stat"><div class="q-stat-num" style="color:#fff">49</div><div class="q-stat-label">All 6 systems. Flat rate.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="q-strip">
|
||||
<div class="q-strip-inner">
|
||||
<div class="q-strip-item"><div class="q-strip-icon">📅</div><div class="q-strip-title">Smart Calendar</div><div class="q-strip-desc">AI scheduling + iOS/Google/Calendly sync</div></div>
|
||||
<div class="q-strip-item"><div class="q-strip-icon">📐</div><div class="q-strip-title">AI Measurements</div><div class="q-strip-desc">LiDAR scan in minutes. $0 per report.</div></div>
|
||||
<div class="q-strip-item"><div class="q-strip-icon">⚡</div><div class="q-strip-title">Hail Alerts</div><div class="q-strip-desc">Sub-hour storm alerts + address scoring</div></div>
|
||||
<div class="q-strip-item"><div class="q-strip-icon">📋</div><div class="q-strip-title">Adjuster Reports</div><div class="q-strip-desc">1-click. Insurance-accepted. Auto-generated.</div></div>
|
||||
<div class="q-strip-item"><div class="q-strip-icon">📊</div><div class="q-strip-title">Pipeline CRM</div><div class="q-strip-desc">Leads, stages, estimates, proposals</div></div>
|
||||
<div class="q-strip-item"><div class="q-strip-icon">🏠</div><div class="q-strip-title">Owner's Box</div><div class="q-strip-desc">Revenue, KPIs, team performance, live</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-vs">
|
||||
<div style="text-align:center;margin-bottom:32px">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:clamp(28px,4vw,44px);font-weight:900;color:#fff;text-transform:uppercase">Your Current Stack vs. LynkedUp Pro</div>
|
||||
<p style="font-size:16px;color:var(--muted);margin-top:8px">The math is simple.</p>
|
||||
</div>
|
||||
<div class="q-vs-inner">
|
||||
<div class="q-vs-col">
|
||||
<div class="q-vs-label" style="color:var(--red)">❌ What you're paying now</div>
|
||||
<div class="q-vs-items">
|
||||
<div class="q-vs-item"><span class="ico">💸</span><span>EagleView reports - <strong style="color:#fff">$700+/mo</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">💸</span><span>JobNimbus CRM - <strong style="color:#fff">00+/mo</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">💸</span><span>SalesRabbit - <strong style="color:#fff">9+/user/mo</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">💸</span><span>Billing + proposal tools - <strong style="color:#fff">00+/mo</strong></span></div>
|
||||
<div style="border-top:1px solid var(--border);margin-top:12px;padding-top:12px;display:flex;justify-content:space-between">
|
||||
<span style="font-size:14px;color:var(--muted)">Total monthly</span>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:22px;font-weight:800;color:var(--red)">,150+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-vs-sep">→</div>
|
||||
<div class="q-vs-col win">
|
||||
<div class="q-vs-label" style="color:var(--green)">✅ LynkedUp Pro</div>
|
||||
<div class="q-vs-items">
|
||||
<div class="q-vs-item"><span class="ico">✅</span><span>AI Measurements - <strong style="color:#fff">$0/report</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">✅</span><span>Roofing CRM - <strong style="color:#fff">included</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">✅</span><span>Hail alerts + canvassing - <strong style="color:#fff">included</strong></span></div>
|
||||
<div class="q-vs-item"><span class="ico">✅</span><span>Billing + proposals - <strong style="color:#fff">included</strong></span></div>
|
||||
<div style="border-top:1px solid rgba(0,229,160,.2);margin-top:12px;padding-top:12px;display:flex;justify-content:space-between">
|
||||
<span style="font-size:14px;color:var(--muted)">Total monthly</span>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:22px;font-weight:800;color:var(--green)">49</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width:860px;margin:24px auto 0;background:rgba(0,229,160,.05);border:1px solid rgba(0,229,160,.2);border-radius:12px;padding:18px 28px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px">
|
||||
<span style="font-size:16px;color:var(--muted)">Your monthly savings:</span>
|
||||
<span style="font-family:'Barlow Condensed',sans-serif;font-size:40px;font-weight:900;color:var(--green)">00+ / month</span>
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Claim These Savings →</a>
|
||||
</div>
|
||||
</div>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<section class="q-cta" id="cta">
|
||||
<div style="position:relative;z-index:1">
|
||||
<div style="display:inline-block;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:8px;padding:8px 20px;margin-bottom:20px;font-size:13px;color:var(--orange);font-weight:600">🔥 Only 12 Founding Member Spots Remaining</div>
|
||||
<div class="q-price"><sup style="font-size:32px;color:var(--muted)">$</sup>2000<sub>/mo</sub></div>
|
||||
<div class="q-price-note">Everything included. No per-user fees. No per-report charges. 30-day money-back.</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:16px">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary" style="font-size:18px;padding:16px 40px">Book a demo →</a>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--muted);display:flex;gap:20px;justify-content:center;flex-wrap:wrap">
|
||||
<span>✅ No credit card required</span><span>✅ Free onboarding</span><span>✅ 30-day money-back guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer style="padding:32px 24px;border-top:1px solid var(--border);text-align:center">
|
||||
<p style="font-size:13px;color:var(--muted)">© 2026 LynkedUp Pro · <a href="lynkeduppro_final.html" style="color:var(--muted)">Full Platform →</a> · <a href="https://book.lynkeduppro.com/" style="color:var(--muted)">Privacy</a></p>
|
||||
</footer>
|
||||
<div class="mob-sticky" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px">
|
||||
<a href="https://book.lynkeduppro.com/" style="flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:15px;text-decoration:none;background:var(--orange);color:#080c14">Book a demo</a>
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add('visible')})},{threshold:.1});
|
||||
document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));
|
||||
if(window.innerWidth<=640)document.querySelector('.mob-sticky').style.display='flex';
|
||||
window.addEventListener('resize',()=>{document.querySelector('.mob-sticky').style.display=window.innerWidth<=640?'flex':'none'});
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800;900&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
.shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 36px 72px rgba(0, 0, 0, .5);
|
||||
}
|
||||
:root{--bg:#05080f;--bg2:#0b1220;--bg3:#101928;--accent:#00c8ff;--orange:#f59e0b;--green:#00e5a0;--red:#ef4444;--border:rgba(255,255,255,.07);--border2:rgba(255,255,255,.13);--text:#e8edf5;--muted:#8a9bb5;--card:#0d1725;--card2:#111c2e}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;font-size:16px;line-height:1.7;overflow-x:hidden}
|
||||
nav{position:fixed;top:0;left:0;right:0;z-index:200;display:flex;align-items:center;justify-content:space-between;padding:16px 48px;background:rgba(5,8,15,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--border)}
|
||||
.logo{display:flex;align-items:center;gap:10px;text-decoration:none}
|
||||
.logo-mark{width:32px;height:32px;background:linear-gradient(135deg,#0ea5e9,#00e5a0);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:900;font-size:16px;color:#fff}
|
||||
.logo-text{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:18px;color:#fff}
|
||||
.logo-text span{color:var(--orange)}
|
||||
.nav-cta{background:var(--orange);color:#080c14;padding:9px 22px;border-radius:6px;font-weight:600;font-size:14px;text-decoration:none;transition:opacity .2s}
|
||||
.nav-cta:hover{opacity:.85}
|
||||
section{padding:88px 24px}
|
||||
.container{max-width:1080px;margin:0 auto}
|
||||
.s-label{font-size:11px;text-transform:uppercase;letter-spacing:3px;font-weight:600;margin-bottom:12px}
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,4.5vw,56px);font-weight:900;line-height:1;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
.s-sub{font-size:17px;color:var(--muted);margin-top:14px;max-width:500px;line-height:1.65}
|
||||
.btn-primary{background:var(--orange);color:#080c14;padding:14px 32px;border-radius:8px;font-weight:600;text-decoration:none;font-size:16px;display:inline-block;transition:transform .15s,box-shadow .15s;font-family:'Outfit',sans-serif;border:none;cursor:pointer}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 10px 36px rgba(245,158,11,.35)}
|
||||
.btn-ghost{background:transparent;color:#fff;padding:14px 28px;border-radius:8px;font-weight:500;text-decoration:none;font-size:16px;border:1px solid var(--border2);display:inline-block;transition:border-color .2s}
|
||||
.btn-ghost:hover{border-color:rgba(255,255,255,.3)}
|
||||
.reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.visible{opacity:1;transform:none}
|
||||
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative;overflow:hidden}
|
||||
.hero-bg{position:absolute;inset:0;pointer-events:none}
|
||||
.hero-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.022) 1px,transparent 1px);background-size:64px 64px;mask-image:radial-gradient(ellipse 70% 70% at 50% 40%,black,transparent)}
|
||||
.hero-glow{position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:900px;height:600px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;background:rgba(0,200,255,.07);border:1px solid rgba(0,200,255,.18);color:var(--accent);font-size:12px;font-weight:500;padding:5px 16px;border-radius:100px;margin-bottom:24px;letter-spacing:.3px}
|
||||
.dot{width:6px;height:6px;border-radius:50%;animation:blink 2s ease infinite}
|
||||
@keyframes blink{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.7)}}
|
||||
h1{font-family:'Barlow Condensed',sans-serif;font-size:clamp(48px,8vw,92px);font-weight:900;line-height:.93;letter-spacing:-1px;color:#fff;max-width:860px;text-transform:uppercase}
|
||||
.hero-sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:520px;margin:24px auto 0;line-height:1.65}
|
||||
.hero-sub strong{color:var(--text)}
|
||||
.hero-actions{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:36px}
|
||||
.micro-row{display:flex;align-items:center;justify-content:center;gap:20px;margin-top:16px;flex-wrap:wrap}
|
||||
.micro{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
.micro span:first-child{color:var(--green)}
|
||||
.stat-row{display:flex;gap:40px;justify-content:center;margin-top:64px;flex-wrap:wrap}
|
||||
.stat{text-align:center;position:relative}
|
||||
.stat+.stat::before{content:'';position:absolute;left:-20px;top:20%;bottom:20%;width:1px;background:var(--border)}
|
||||
.stat-num{font-family:'Barlow Condensed',sans-serif;font-size:38px;font-weight:900;color:#fff;line-height:1;letter-spacing:-1px}
|
||||
.stat-label{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
.pain-section{background:var(--bg2);border-top:1px solid var(--border);border-bottom:1px solid var(--border)}
|
||||
.pain-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:14px;overflow:hidden;margin-top:44px}
|
||||
.p-item{background:var(--card);padding:28px 22px}
|
||||
.p-icon{font-size:24px;margin-bottom:12px}
|
||||
.p-item h3{font-family:'Barlow Condensed',sans-serif;font-size:16px;font-weight:700;color:#fff;margin-bottom:7px;text-transform:uppercase}
|
||||
.p-item p{font-size:14px;color:var(--muted);line-height:1.7}
|
||||
.p-cost{display:inline-block;background:rgba(239,68,68,.1);color:var(--red);font-size:11px;font-weight:700;padding:2px 8px;border-radius:4px;margin-top:8px;border:1px solid rgba(239,68,68,.2)}
|
||||
.solution-section{background:var(--bg)}
|
||||
.sol-split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center;margin-top:44px}
|
||||
.sol-points{list-style:none;margin-top:22px;display:flex;flex-direction:column;gap:14px}
|
||||
.sol-points li{display:flex;align-items:flex-start;gap:12px;font-size:15px;color:var(--text)}
|
||||
.sol-points li .ico{font-size:18px;min-width:26px;margin-top:1px}
|
||||
.sol-points li strong{color:#fff}
|
||||
.sol-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
|
||||
.sol-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px}
|
||||
.sol-stat-num{font-family:'Barlow Condensed',sans-serif;font-size:26px;font-weight:900;line-height:1;margin-bottom:4px}
|
||||
.sol-stat-label{font-size:12px;color:var(--muted)}
|
||||
.mock-card{background:var(--card);border:1px solid var(--border);border-radius:18px;overflow:hidden;box-shadow:0 36px 72px rgba(0,0,0,.5)}
|
||||
.mock-bar{background:var(--bg2);padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.mock-title{font-size:13px;font-weight:600;color:#fff}
|
||||
.mock-badge{font-size:9px;font-weight:700;padding:3px 9px;border-radius:100px}
|
||||
.mock-body{padding:14px}
|
||||
.proof-section{background:var(--bg2)}
|
||||
.proof-card{background:var(--card);border:1px solid rgba(0,200,255,.15);border-radius:18px;padding:36px;max-width:720px;margin:44px auto 0;position:relative}
|
||||
.proof-stars{color:var(--orange);font-size:13px;letter-spacing:2px;margin-bottom:14px}
|
||||
.proof-quote{font-size:17px;color:var(--text);line-height:1.8;font-style:italic;margin-bottom:22px}
|
||||
.proof-author{display:flex;align-items:center;gap:14px;border-top:1px solid var(--border);padding-top:18px}
|
||||
.proof-av{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:'Barlow Condensed',sans-serif;font-weight:800;font-size:15px;color:#05080f;flex-shrink:0}
|
||||
.proof-name{font-weight:600;font-size:15px;color:#fff}
|
||||
.proof-role{font-size:12px;color:var(--muted)}
|
||||
.proof-result{position:absolute;top:24px;right:24px;background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.18);border-radius:8px;padding:8px 12px;text-align:center}
|
||||
.pr-num{font-family:'Barlow Condensed',sans-serif;font-size:20px;font-weight:800;color:var(--green);line-height:1}
|
||||
.pr-label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.3px}
|
||||
.cta-section{background:linear-gradient(135deg,rgba(0,200,255,.05),rgba(0,229,160,.04),rgba(245,158,11,.04));border-top:1px solid var(--border);padding:88px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-section::before{content:'';position:absolute;top:-200px;left:50%;transform:translateX(-50%);width:700px;height:450px;background:radial-gradient(ellipse,rgba(0,200,255,.06),transparent 70%);pointer-events:none}
|
||||
.cta-section h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,60px);font-weight:900;color:#fff;line-height:1;max-width:640px;margin:0 auto 14px;text-transform:uppercase;letter-spacing:-1px}
|
||||
.cta-section h2 em{font-style:normal;color:var(--orange)}
|
||||
.cta-section p{font-size:17px;color:var(--muted);max-width:420px;margin:0 auto 28px}
|
||||
.cta-form{display:flex;gap:10px;max-width:400px;margin:0 auto;flex-wrap:wrap;justify-content:center}
|
||||
.cta-form input{flex:1;min-width:190px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;padding:12px 16px;color:#fff;font-size:15px;font-family:'Outfit',sans-serif;outline:none;transition:border-color .2s}
|
||||
.cta-form input::placeholder{color:var(--muted)}
|
||||
.cta-form input:focus{border-color:rgba(0,200,255,.4)}
|
||||
.cta-micro{font-size:12px;color:var(--muted);margin-top:12px;display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap}
|
||||
footer{padding:40px 24px 28px;border-top:1px solid var(--border);text-align:center}
|
||||
footer p{font-size:13px;color:var(--muted)}
|
||||
footer a{color:var(--muted);text-decoration:none}
|
||||
footer a:hover{color:#fff}
|
||||
.mob-sticky{display:none;position:fixed;bottom:0;left:0;right:0;z-index:150;background:var(--bg2);border-top:1px solid var(--border);padding:12px 16px;gap:10px}
|
||||
.mob-sticky a{flex:1;text-align:center;padding:12px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none}
|
||||
.mob-sticky .p{background:var(--orange);color:#080c14}
|
||||
.mob-sticky .s{border:1px solid var(--border2);color:#fff}
|
||||
/* ---- Background image helpers ---- */
|
||||
.has-bg{position:relative;overflow:hidden}
|
||||
.has-bg .bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0}
|
||||
.has-bg .bg-overlay{position:absolute;inset:0;z-index:1}
|
||||
.has-bg .container,.has-bg>*:not(.bg-img):not(.bg-overlay):not(.hero-bg){position:relative;z-index:2}
|
||||
/* ---- Enhanced headings ---- */
|
||||
.s-title{font-family:'Barlow Condensed',sans-serif;font-size:clamp(36px,5vw,62px);font-weight:900;line-height:.97;letter-spacing:-.5px;color:#fff;text-transform:uppercase}
|
||||
/* ---- Mid-page CTA strips ---- */
|
||||
.cta-strip{padding:80px 24px;text-align:center;position:relative;overflow:hidden}
|
||||
.cta-strip .bg-img{position:absolute;inset:0;background-size:cover;background-position:center;z-index:0}
|
||||
.cta-strip .bg-overlay{position:absolute;inset:0;z-index:1}
|
||||
.cta-strip .inner{position:relative;z-index:2;max-width:660px;margin:0 auto}
|
||||
.cta-strip-eyebrow{display:inline-flex;align-items:center;gap:7px;padding:5px 14px;border-radius:100px;margin-bottom:20px;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase}
|
||||
.cta-strip h2{font-family:'Barlow Condensed',sans-serif;font-size:clamp(34px,5vw,58px);font-weight:900;line-height:.97;letter-spacing:-.5px;text-transform:uppercase;color:#fff;margin-bottom:16px}
|
||||
.cta-strip p{font-size:16px;color:var(--muted);line-height:1.7;max-width:480px;margin:0 auto 28px}
|
||||
.cta-strip-btns{display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
|
||||
.cta-strip-trust{display:flex;gap:20px;justify-content:center;margin-top:16px;flex-wrap:wrap}
|
||||
.cta-strip-trust span{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:5px}
|
||||
/* ---- Proof section bg ---- */
|
||||
.proof-section{position:relative;overflow:hidden}
|
||||
/* ---- Pain section heading accent line ---- */
|
||||
.accent-line{display:inline-block;width:40px;height:3px;border-radius:2px;margin-bottom:16px;vertical-align:middle}
|
||||
@media(max-width:1280px){section{padding:80px 24px}nav{padding:16px 32px}.cta-strip{padding:72px 24px}}
|
||||
@media(max-width:1024px){section{padding:72px 24px}.pain-grid{grid-template-columns:repeat(2,1fr)}.sol-split{grid-template-columns:1fr;gap:40px}h1{font-size:clamp(42px,7vw,72px)}.s-title{font-size:clamp(30px,4.5vw,48px)}.cta-section h2{font-size:clamp(32px,5vw,52px)}.cta-strip h2{font-size:clamp(30px,5vw,48px)}}
|
||||
@media(max-width:860px){nav{padding:14px 20px}.sol-split{grid-template-columns:1fr!important;gap:36px}.pain-grid{grid-template-columns:1fr!important}.sol-stats{grid-template-columns:1fr 1fr}.mob-sticky{display:flex}body{padding-bottom:72px}section{padding:64px 20px}.hero{padding:100px 16px 60px;min-height:auto}.hero-actions{flex-direction:column;width:100%;max-width:320px;margin-left:auto;margin-right:auto}.hero-actions .btn-primary,.hero-actions .btn-ghost{text-align:center;width:100%}.stat-row{gap:28px}.stat-num{font-size:32px}.cta-section{padding:64px 20px}.cta-strip{padding:64px 20px}.cta-strip-btns{flex-direction:column;width:100%;max-width:320px;margin-left:auto;margin-right:auto}.cta-strip-btns .btn-primary,.cta-strip-btns .btn-ghost{text-align:center;width:100%}.proof-card{padding:28px 22px}.proof-result{position:static;display:inline-block;margin:0 0 14px}}
|
||||
@media(max-width:560px){.stat-row{gap:20px;flex-direction:column}.stat+.stat::before{display:none}h1{font-size:clamp(36px,11vw,64px)}.s-title{font-size:clamp(26px,7.5vw,40px)}.cta-section h2{font-size:clamp(28px,8vw,44px)}.cta-strip h2{font-size:clamp(26px,7.5vw,40px)}.cta-form{flex-direction:column}.cta-form input{min-width:100%;width:100%}.cta-form .btn-primary{width:100%;text-align:center}.sol-stats{grid-template-columns:1fr}.hero-sub{font-size:15px}.micro-row{gap:10px}section{padding:56px 16px}.cta-strip{padding:56px 16px}.hero{padding:96px 16px 48px}.eyebrow{font-size:11px;padding:5px 12px}.proof-quote{font-size:15px}.cta-section p,.cta-strip p{font-size:15px}.cta-strip-trust{gap:10px}}
|
||||
@media(max-width:380px){nav{padding:12px 14px}.nav-cta{padding:8px 14px;font-size:13px}.logo-text{font-size:16px}h1{font-size:clamp(32px,11vw,48px)}.s-title{font-size:24px}.btn-primary,.btn-ghost{padding:12px 22px;font-size:14px}.cta-section h2,.cta-strip h2{font-size:26px}.proof-card{padding:22px 18px}}</style>
|
||||
<title>LynkedUp Pro - Stop Getting Your Insurance Claims Underpaid</title>
|
||||
<meta name="description" content="AI-annotated hail impact documentation and LiDAR digital twins get your insurance claims approved on the first submission. Stop leaving money on the table."/>
|
||||
</head><body>
|
||||
<nav>
|
||||
<a href="https://book.lynkeduppro.com/" class="logo"><img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro" class="brand-logo-img" style="height:34px;width:auto;display:block;object-fit:contain"></a>
|
||||
<a href="https://launch.lynkeduppro.com/" class="nav-cta">Watch a demo</a> <a href="https://book.lynkeduppro.com/" class="nav-cta">Book a demo</a>
|
||||
</nav>
|
||||
<section class="hero has-bg" id="home">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1800&q=80');filter:brightness(.13) saturate(.5)"></div>
|
||||
<div class="bg-overlay" style="background:linear-gradient(180deg,rgba(5,8,15,.6) 0%,rgba(5,8,15,.85) 60%,rgba(5,8,15,1) 100%)"></div>
|
||||
<div class="hero-bg"><div class="hero-grid"></div><div class="hero-glow" style="background:radial-gradient(ellipse,rgba(245,158,11,.12),transparent 65%)"></div></div>
|
||||
<div class="eyebrow"><span class="dot" style="background:var(--orange)"></span> Adjuster-Ready Reports · Insurance Claims</div>
|
||||
<h1>The Insurance Company<br>Isn't Cheating You -<br><span style="color:var(--orange)">Your Docs Are.</span></h1>
|
||||
<p class="hero-sub">Adjusters need <strong>precise, annotated evidence</strong> to pay full value. Without AI-documented hail impacts and LiDAR data, you hand them every reason to underpay, and they take it. It's your assistant that never sleeps or takes a day off.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Fix My Claims Process →</a>
|
||||
</div>
|
||||
<div class="micro-row" style="margin-top:14px">
|
||||
<span class="micro" style="color:var(--muted)">4.9★ · 248 reviews</span>
|
||||
</div>
|
||||
<div class="micro-row">
|
||||
<span class="micro"><span>✅</span> First-submission approval</span>
|
||||
<span class="micro"><span>✅</span> AI-annotated hail impacts</span>
|
||||
<span class="micro"><span>✅</span> Xactimate-style output</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><div class="stat-num" style="color:var(--green)">1st</div><div class="stat-label">Submission approval rate</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--orange)">$0</div><div class="stat-label">Manual documentation time</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:var(--accent)">1-click</div><div class="stat-label">Generate full adjuster report</div></div>
|
||||
<div class="stat"><div class="stat-num" style="color:#fff">100%</div><div class="stat-label">Insurance-accepted format</div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="pain-section has-bg reveal" id="problem">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504309092620-4d0ec726efa4?w=1800&q=80');filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-overlay" style="background:linear-gradient(135deg,rgba(11,18,32,.97) 0%,rgba(11,18,32,.88) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--red)">The Problem</div>
|
||||
<h2 class="s-title">Every Rejected Claim Is<br><span style="color:var(--red)">Money You've Already Earned.</span></h2>
|
||||
<p class="s-sub">You did the work. You climbed the roof. You filed the report. And the adjuster still came back with a low number, or nothing at all. It's not the damage that's the problem. It's the paper trail.</p>
|
||||
<div class="pain-grid">
|
||||
<div class="p-item"><div class="p-icon">📋</div><h3>Vague Reports Get Rejected</h3><p>Adjuster sees "hail damage noted" with a few phone photos. They write back for more evidence, delay the claim, or simply underpay. You have no leverage.</p><span class="p-cost">Cost: Weeks of delays, underpayment</span></div>
|
||||
<div class="p-item"><div class="p-icon">🔍</div><h3>You Miss Impact Zones</h3><p>Manual roof inspections miss impacts on complex roof geometries. AI catches what the human eye skips, and insurance adjusters know the difference.</p><span class="p-cost">Cost: Unclaimed damage per job</span></div>
|
||||
<div class="p-item"><div class="p-icon">📂</div><h3>Hours of Manual Documentation</h3><p>You do the roof work in 4 minutes. Then you spend 3 hours compiling photos, measurements, storm data and formatting a report adjusters may still reject.</p><span class="p-cost">Cost: 3+ hours per claim</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ===== CTA STRIP 1 ===== -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1527689368864-3a821dbccc34?w=1800&q=80');position:absolute;inset:0;background-size:cover;background-position:center;filter:brightness(.08) saturate(.4)"></div>
|
||||
<div class="bg-overlay" style="position:absolute;inset:0;background:linear-gradient(135deg,rgba(5,8,15,.97) 0%,rgba(15,8,5,.95) 100%)"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:350px;background:radial-gradient(ellipse,rgba(239,68,68,.07),transparent 70%);pointer-events:none;z-index:1"></div>
|
||||
<div class="inner">
|
||||
<div class="cta-strip-eyebrow" style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:var(--red)">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:var(--red);animation:blink 2s ease infinite;display:inline-block"></span>
|
||||
Right Now, This Storm Season
|
||||
</div>
|
||||
<h2>You're Handing Adjusters<br>a <span style="color:var(--red)">Reason to Say No.</span><br><span style="color:var(--orange)">Stop It Today.</span></h2>
|
||||
<p>Every claim you file without AI-annotated hail data is a claim the adjuster has permission to shrink. The good news? One 30-minute demo changes the entire outcome of your next job.</p>
|
||||
<div class="cta-strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Show Me How to Fix It →</a>
|
||||
|
||||
</div>
|
||||
<div class="cta-strip-trust">
|
||||
<span><span style="color:var(--green)">✅</span> No credit card</span>
|
||||
<span><span style="color:var(--green)">✅</span> 30-min live demo</span>
|
||||
<span><span style="color:var(--green)">✅</span> Real claims results</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solution-section has-bg reveal" id="solution">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504307651254-35680f356dfd?w=1800&q=80');filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-overlay" style="background:linear-gradient(90deg,rgba(5,8,15,1) 45%,rgba(5,8,15,.75) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--orange)">The Fix</div>
|
||||
<h2 class="s-title">One Scan.<br><span style="color:var(--orange)">Full Payout.</span><br>Zero Resubmissions.</h2>
|
||||
<div class="sol-split">
|
||||
<div>
|
||||
<p class="s-sub">LynkedUp Pro combines LiDAR drone scanning, AI hail impact annotation, and historical storm data into a single adjuster-ready PDF. Automatically. Every time.</p>
|
||||
<ul class="sol-points">
|
||||
<li><span class="ico">🤖</span><span><strong>AI auto-annotates every impact.</strong> The digital twin flags hail strikes, granule loss, and ridge damage with exact GPS coordinates. Nothing gets missed.</span></li>
|
||||
<li><span class="ico">🌩️</span><span><strong>Storm data auto-attached.</strong> HailTrace and NOAA historical records pull automatically into every report. Dated, sourced, irrefutable.</span></li>
|
||||
<li><span class="ico">📄</span><span><strong>Xactimate-style output.</strong> One click generates a formatted adjuster report with the exact structure insurance companies require. No resubmissions.</span></li>
|
||||
<li><span class="ico">🔗</span><span><strong>Wired into your CRM.</strong> Report links to the job file, the homeowner, the estimate, and the signed contract. Everything auditors want, one click away.</span></li>
|
||||
</ul>
|
||||
<div class="sol-stats">
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--green)">1-Click</div><div class="sol-stat-label">Report generation</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--orange)">0 hrs</div><div class="sol-stat-label">Manual documentation time</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:var(--accent)">100%</div><div class="sol-stat-label">Insurance-accepted format</div></div>
|
||||
<div class="sol-stat"><div class="sol-stat-num" style="color:#fff">Auto</div><div class="sol-stat-label">Storm data attached</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<img src="/page/assets/esitimate.png" alt="LynkedUp Pro AI-annotated adjuster report" class="shot" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- ===== CTA STRIP 2 ===== -->
|
||||
<section class="cta-strip">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1800&q=80');position:absolute;inset:0;background-size:cover;background-position:center;filter:brightness(.1) saturate(.4)"></div>
|
||||
<div class="bg-overlay" style="position:absolute;inset:0;background:linear-gradient(135deg,rgba(5,8,15,.96) 0%,rgba(5,12,8,.95) 100%)"></div>
|
||||
<div style="position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:700px;height:350px;background:radial-gradient(ellipse,rgba(0,229,160,.06),transparent 70%);pointer-events:none;z-index:1"></div>
|
||||
<div class="inner">
|
||||
<div class="cta-strip-eyebrow" style="background:rgba(0,229,160,.08);border:1px solid rgba(0,229,160,.2);color:var(--green)">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:var(--green);animation:blink 2s ease infinite;display:inline-block"></span>
|
||||
Your Adjuster Wants a Reason to Say Yes
|
||||
</div>
|
||||
<h2>Give Them <span style="color:var(--green)">Irrefutable</span><br>Evidence - or Keep<br><span style="color:var(--orange)">Fighting for Every Dollar.</span></h2>
|
||||
<p>LynkedUp Pro gives adjusters exactly what they need to approve on the first pass: GPS-pinned impacts, NOAA storm data, and Xactimate-ready formatting. Generated automatically. Every job.</p>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-bottom:28px">
|
||||
<div style="background:var(--card);border:1px solid rgba(0,229,160,.18);border-radius:10px;padding:14px 22px;text-align:center;min-width:120px">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--green);line-height:1">100%</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">First-Pass Rate</div>
|
||||
</div>
|
||||
<div style="background:var(--card);border:1px solid rgba(245,158,11,.18);border-radius:10px;padding:14px 22px;text-align:center;min-width:120px">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--orange);line-height:1">$2.1K</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">Saved / Month</div>
|
||||
</div>
|
||||
<div style="background:var(--card);border:1px solid rgba(0,200,255,.18);border-radius:10px;padding:14px 22px;text-align:center;min-width:120px">
|
||||
<div style="font-family:'Barlow Condensed',sans-serif;font-size:28px;font-weight:900;color:var(--accent);line-height:1">0 hrs</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px">Manual Doc Time</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta-strip-btns">
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary">Get the Dashboard Free →</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="proof-section has-bg reveal" id="proof">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=1800&q=80');filter:brightness(.09) saturate(.4)"></div>
|
||||
<div class="bg-overlay" style="background:linear-gradient(180deg,rgba(11,18,32,1) 0%,rgba(11,18,32,.92) 60%,rgba(11,18,32,1) 100%)"></div>
|
||||
<div class="container">
|
||||
<div class="s-label" style="color:var(--orange);text-align:center">Real Result</div>
|
||||
<h2 class="s-title" style="text-align:center">Our Clients Don't<br>Resubmit. <span style="color:var(--orange)">They Get Paid.</span></h2>
|
||||
<div class="proof-card" style="border-color:rgba(245,158,11,.2)">
|
||||
<div class="proof-result" style="border-color:rgba(245,158,11,.18);background:rgba(245,158,11,.06)"><div class="pr-num" style="color:var(--orange)">$2.1K</div><div class="pr-label">Saved/Month</div></div>
|
||||
<div class="proof-stars">★★★★★</div>
|
||||
<p class="proof-quote">"We used to resubmit claims 2-3 times per job. Adjusters kept asking for more evidence we didn't have. With LynkedUp Pro, the AI-annotated reports have the exact documentation they need. Every claim gets approved on the first submission now. That alone is worth the entire subscription."</p>
|
||||
<div class="proof-author">
|
||||
<div class="proof-av" style="background:linear-gradient(135deg,#f59e0b,#ef4444)">MT</div>
|
||||
<div><div class="proof-name">Marcus Thompson</div><div class="proof-role">Operations Manager · Fort Worth, TX</div></div>
|
||||
</div>
|
||||
<span style="position:absolute;right:10px;bottom:8px;font-size:11px;opacity:.5">*</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="roofing-offer-section" id="pricing">
|
||||
<div class="container">
|
||||
|
||||
<!-- TOP HEADING -->
|
||||
<div class="text-center roofing-heading mb-5">
|
||||
<span class="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<!-- MAIN GRID -->
|
||||
<div class="row g-4 align-items-stretch">
|
||||
|
||||
<!-- LEFT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="lifetime-card h-100">
|
||||
<div class="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p class="small-text">One payment. Unlimited operational control.</p>
|
||||
<div class="price-box">
|
||||
<h2>$2000</h2>
|
||||
<span>PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" class="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT CARD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="roofing-info-card h-100">
|
||||
<span class="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p class="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div class="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TERMS (native <details> accordion - no JS needed) -->
|
||||
<div class="terms-wrapper mt-4">
|
||||
<details class="custom-accordion-item">
|
||||
<summary class="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div class="accordion-body">
|
||||
<div class="deal-terms-legal" style="font-size:12px;line-height:1.65;color:#444">
|
||||
<p style="margin:0 0 10px">All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p style="margin:0 0 10px">Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p style="margin:0 0 10px">The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p style="margin:0 0 10px">SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p style="margin:0 0 10px">Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style="margin:0">Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div class="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" class="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://www.lynkeduppro.com/contact" class="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
/* ===== Roofing Offer / Pricing - compact, self-contained ===== */
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<section class="cta-section has-bg" id="cta">
|
||||
<div class="bg-img" style="background-image:url('https://images.unsplash.com/photo-1504309092620-4d0ec726efa4?w=1800&q=80');filter:brightness(.08) saturate(.3)"></div>
|
||||
<div class="bg-overlay" style="background:linear-gradient(135deg,rgba(0,200,255,.04),rgba(0,229,160,.03),rgba(245,158,11,.04))"></div>
|
||||
<div class="container" style="position:relative;z-index:2">
|
||||
<div style="display:inline-block;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.2);border-radius:8px;padding:8px 18px;margin-bottom:24px;font-size:13px;color:var(--orange);font-weight:600">📋 Stop resubmitting claims that should've been approved the first time</div>
|
||||
<h2>Every Dollar Is Yours.<br><em>Start Claiming It.</em></h2>
|
||||
<p>See how LynkedUp Pro generates bulletproof adjuster reports automatically, in a 30-minute demo.</p>
|
||||
<div class="cta-form">
|
||||
|
||||
<a href="https://book.lynkeduppro.com/" class="btn-primary" onclick="event.preventDefault()">Fix My Claims Process →</a>
|
||||
</div>
|
||||
<div class="cta-micro"><span>✅ No credit card</span><span>✅ 30-day money-back guarantee</span><span>✅ Free onboarding</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<footer>
|
||||
<p>© 2026 LynkedUp Pro · See Full Platform →</p>
|
||||
<p style="margin-top:8px;font-size:12px;opacity:.5">$349/mo flat rate · No credit card required · 30-day money-back guarantee · Built for roofing</p>
|
||||
<p style="margin-top:8px;font-size:11px;opacity:.5">* Testimonials and figures shown are illustrative and reflect beta-period results; individual results vary.</p>
|
||||
</footer>
|
||||
<div class="mob-sticky">
|
||||
<a href="https://book.lynkeduppro.com/" class="p">Book a demo</a>
|
||||
<a href="lynkeduppro_final.html" class="s">See Full Platform</a>
|
||||
</div>
|
||||
<script>
|
||||
const obs=new IntersectionObserver(e=>{e.forEach(el=>{if(el.isIntersecting)el.target.classList.add("visible")})},{threshold:.1});
|
||||
document.querySelectorAll(".reveal").forEach(el=>obs.observe(el));
|
||||
document.querySelectorAll("a[href^=\"#\"]").forEach(a=>{a.addEventListener("click",function(e){const t=document.querySelector(this.getAttribute("href"));if(t){e.preventDefault();t.scrollIntoView({behavior:"smooth",block:"start"});}}); });
|
||||
</script>
|
||||
<!-- FLOATING LIFETIME DEAL BUTTON -->
|
||||
<a href="#pricing" id="floatingDealBtn">
|
||||
🔥 Founders Lifetime Deal - $2000
|
||||
</a>
|
||||
|
||||
<style>
|
||||
#floatingDealBtn {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
background: linear-gradient(135deg,#0ea5e9,#2563eb);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 10px 30px rgba(14,165,233,0.35);
|
||||
z-index: 9999;
|
||||
transition: all 0.3s ease;
|
||||
animation: floatPulse 2s infinite;
|
||||
}
|
||||
|
||||
#floatingDealBtn:hover {
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 16px 40px rgba(14,165,233,0.45);
|
||||
}
|
||||
|
||||
@keyframes floatPulse {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px) {
|
||||
#floatingDealBtn {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Hosted checkout (Stripe) for the "Claim Founders Lifetime Deal" button -->
|
||||
<script src="/square-checkout.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 1009 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 7.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* <image-slot> — user-fillable image placeholder.
|
||||
*
|
||||
* Drop this into a deck, mockup, or page wherever you want the user to
|
||||
* supply an image. You control the slot's shape and size; the user fills it
|
||||
* by dragging an image file onto it (or clicking to browse). The dropped
|
||||
* image persists across reloads via a .image-slots.state.json sidecar —
|
||||
* same read-via-fetch / write-via-window.omelette pattern as
|
||||
* design_canvas.jsx, so the filled slot shows on share links, downloaded
|
||||
* zips, and PPTX export. Outside the omelette runtime the slot is read-only.
|
||||
*
|
||||
* The host bridge only allows sidecar writes at the project root, so the
|
||||
* HTML that uses this component is assumed to live at the project root too
|
||||
* (same constraint as design_canvas.jsx).
|
||||
*
|
||||
* Attributes:
|
||||
* id Persistence key. REQUIRED for the drop to survive reload —
|
||||
* every slot on the page needs a distinct id.
|
||||
* shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
|
||||
* 'circle' applies 50% border-radius; on a non-square slot
|
||||
* that's an ellipse — set equal width and height for a true
|
||||
* circle.
|
||||
* radius Corner radius in px for 'rounded'. (default 12)
|
||||
* mask Any CSS clip-path value. Overrides `shape` — use this for
|
||||
* hexagons, blobs, arbitrary polygons.
|
||||
* fit object-fit: cover | contain | fill. (default 'cover')
|
||||
* With cover (the default) double-clicking the filled slot
|
||||
* enters a reframe mode: the whole image spills past the mask
|
||||
* (translucent outside, opaque inside), drag to reposition,
|
||||
* corner-drag to scale. The crop persists alongside the image
|
||||
* in the sidecar. contain/fill stay static.
|
||||
* position object-position for fit=contain|fill. (default '50% 50%')
|
||||
* placeholder Empty-state caption. (default 'Drop an image')
|
||||
* src Optional initial/fallback image URL. A user drop overrides
|
||||
* it; clearing the drop reveals src again.
|
||||
*
|
||||
* Size and layout come from ordinary CSS on the element — width/height
|
||||
* inline or from a parent grid — so it composes with any layout.
|
||||
*
|
||||
* Usage:
|
||||
* <script src="image-slot.js"></script>
|
||||
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
|
||||
* placeholder="Drop a hero image"></image-slot>
|
||||
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
|
||||
* <image-slot id="kite" style="width:300px;height:300px"
|
||||
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const STATE_FILE = '.image-slots.state.json';
|
||||
// 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
|
||||
// sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
|
||||
const MAX_DIM = 1200;
|
||||
// Raster formats only. SVG is excluded (can carry script; createImageBitmap
|
||||
// on SVG blobs is inconsistent). GIF is excluded because the canvas
|
||||
// re-encode keeps only the first frame, so an animated GIF would silently
|
||||
// go still — better to reject than surprise.
|
||||
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
|
||||
|
||||
// ── Shared sidecar store ────────────────────────────────────────────────
|
||||
// One fetch + immediate write-on-change for every <image-slot> on the
|
||||
// page. Reads via fetch() so viewing works anywhere the HTML and sidecar
|
||||
// are served together; writes go through window.omelette.writeFile, which
|
||||
// the host allowlists to *.state.json basenames only.
|
||||
const subs = new Set();
|
||||
let slots = {};
|
||||
// ids explicitly cleared before the sidecar fetch resolved — otherwise
|
||||
// the merge below can't tell "never set" from "just deleted" and would
|
||||
// resurrect the sidecar's stale value.
|
||||
const tombstones = new Set();
|
||||
let loaded = false;
|
||||
let loadP = null;
|
||||
|
||||
function load() {
|
||||
if (loadP) return loadP;
|
||||
loadP = fetch(STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
// Merge: sidecar loses to any in-memory change that raced ahead of
|
||||
// the fetch (drop or clear) so neither is clobbered by hydration.
|
||||
if (j && typeof j === 'object') {
|
||||
const merged = Object.assign({}, j, slots);
|
||||
// A framing-only write that raced ahead of hydration must not
|
||||
// drop a user image that's only on disk — inherit u from the
|
||||
// sidecar for any in-memory entry that lacks one.
|
||||
for (const k in slots) {
|
||||
if (merged[k] && !merged[k].u && j[k]) {
|
||||
merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
|
||||
}
|
||||
}
|
||||
for (const id of tombstones) delete merged[id];
|
||||
slots = merged;
|
||||
}
|
||||
tombstones.clear();
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => { loaded = true; subs.forEach((fn) => fn()); });
|
||||
return loadP;
|
||||
}
|
||||
|
||||
// Serialize writes so two near-simultaneous drops on different slots
|
||||
// can't reorder at the backend and leave the sidecar with only the
|
||||
// first. A save requested mid-flight just marks dirty and re-fires on
|
||||
// completion with the then-current slots.
|
||||
let saving = false;
|
||||
let saveDirty = false;
|
||||
function save() {
|
||||
if (saving) { saveDirty = true; return; }
|
||||
const w = window.omelette && window.omelette.writeFile;
|
||||
if (!w) return;
|
||||
saving = true;
|
||||
Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
|
||||
.catch(() => {})
|
||||
.then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
|
||||
}
|
||||
|
||||
const S_MAX = 5;
|
||||
const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
|
||||
|
||||
// Normalize a stored slot value. Pre-reframe sidecars stored a bare
|
||||
// data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
|
||||
function getSlot(id) {
|
||||
const v = slots[id];
|
||||
if (!v) return null;
|
||||
return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
|
||||
}
|
||||
|
||||
function setSlot(id, val) {
|
||||
if (!id) return;
|
||||
if (val) { slots[id] = val; tombstones.delete(id); }
|
||||
else { delete slots[id]; if (!loaded) tombstones.add(id); }
|
||||
subs.forEach((fn) => fn());
|
||||
// A drop is rare + high-value — write immediately so nav-away can't lose
|
||||
// it. Gate on the initial read so we don't overwrite a sidecar we haven't
|
||||
// merged yet; the merge in load() keeps this change once the read lands.
|
||||
if (loaded) save(); else load().then(save);
|
||||
}
|
||||
|
||||
// ── Image downscale ─────────────────────────────────────────────────────
|
||||
// Encode through a canvas so the sidecar carries resized bytes, not the
|
||||
// raw upload. Longest side is capped at 2× the slot's rendered width
|
||||
// (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
|
||||
// for photos, so there's no need for per-image format picking.
|
||||
async function toDataUrl(file, targetW) {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
|
||||
const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
|
||||
return canvas.toDataURL('image/webp', 0.85);
|
||||
} finally {
|
||||
bitmap.close && bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom element ──────────────────────────────────────────────────────
|
||||
const stylesheet =
|
||||
':host{display:inline-block;position:relative;vertical-align:top;' +
|
||||
' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
|
||||
'.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
|
||||
// .frame img (clipped) and .spill (unclipped ghost + handles) share the
|
||||
// same left/top/width/height in frame-%, computed by _applyView(), so the
|
||||
// inside-mask crop and the outside-mask spill stay pixel-aligned.
|
||||
'.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
|
||||
' -webkit-user-drag:none;user-select:none;touch-action:none}' +
|
||||
// Reframe mode (double-click): the full image spills past the mask. The
|
||||
// spill layer is sized to the IMAGE bounds so its corners are where the
|
||||
// resize handles belong. The ghost <img> inside is translucent; the real
|
||||
// clipped <img> underneath shows the opaque in-mask crop.
|
||||
'.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
|
||||
' cursor:grab;touch-action:none}' +
|
||||
':host([data-panning]) .spill{cursor:grabbing}' +
|
||||
'.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
|
||||
' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
|
||||
' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
|
||||
'.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
|
||||
' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
|
||||
' transform:translate(-50%,-50%)}' +
|
||||
'.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
|
||||
'.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
|
||||
':host([data-reframe]){z-index:10}' +
|
||||
':host([data-reframe]) .spill{display:block}' +
|
||||
':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
|
||||
'.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
|
||||
' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
|
||||
' cursor:pointer;user-select:none}' +
|
||||
'.empty svg{opacity:.45}' +
|
||||
'.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
|
||||
'.empty .sub{font-size:11px}' +
|
||||
'.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
|
||||
'.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
|
||||
':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
|
||||
' background:rgba(201,100,66,.10)}' +
|
||||
'.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
|
||||
' transition:border-color .12s}' +
|
||||
':host([data-over]) .ring{border-color:#c96442}' +
|
||||
':host([data-filled]) .ring{display:none}' +
|
||||
// Controls sit BELOW the mask (top:100%), absolutely positioned so the
|
||||
// author-declared slot height is unaffected. The gap is padding, not a
|
||||
// top offset, so the hover target stays contiguous with the frame.
|
||||
'.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
|
||||
' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
|
||||
' white-space:nowrap}' +
|
||||
':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
|
||||
' {opacity:1;pointer-events:auto}' +
|
||||
'.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
|
||||
' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
|
||||
' backdrop-filter:blur(6px)}' +
|
||||
'.ctl button:hover{background:rgba(0,0,0,.8)}' +
|
||||
'.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
|
||||
' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
|
||||
|
||||
const icon =
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
|
||||
'<path d="m21 15-5-5L5 21"/></svg>';
|
||||
|
||||
class ImageSlot extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
// .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
|
||||
// on the frame (circle, pill, rounded) can't clip them.
|
||||
root.innerHTML =
|
||||
'<style>' + stylesheet + '</style>' +
|
||||
'<div class="frame" part="frame">' +
|
||||
' <img part="image" alt="" draggable="false" style="display:none">' +
|
||||
' <div class="empty" part="empty">' + icon +
|
||||
' <div class="cap"></div>' +
|
||||
' <div class="sub">or <u>browse files</u></div></div>' +
|
||||
' <div class="ring" part="ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="spill">' +
|
||||
' <img class="ghost" alt="" draggable="false">' +
|
||||
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
|
||||
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
|
||||
'</div>' +
|
||||
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
|
||||
' <button data-act="clear" title="Remove image">Remove</button></div>' +
|
||||
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
|
||||
this._frame = root.querySelector('.frame');
|
||||
this._ring = root.querySelector('.ring');
|
||||
this._img = root.querySelector('.frame img');
|
||||
this._empty = root.querySelector('.empty');
|
||||
this._cap = root.querySelector('.cap');
|
||||
this._sub = root.querySelector('.sub');
|
||||
this._spill = root.querySelector('.spill');
|
||||
this._ghost = root.querySelector('.ghost');
|
||||
this._err = null;
|
||||
this._input = root.querySelector('input');
|
||||
this._depth = 0;
|
||||
this._gen = 0;
|
||||
this._view = { s: 1, x: 0, y: 0 };
|
||||
this._subFn = () => this._render();
|
||||
// Shadow-DOM listeners live with the shadow DOM — bound once here so
|
||||
// disconnect/reconnect (e.g. React remount) doesn't stack handlers.
|
||||
this._empty.addEventListener('click', () => this._input.click());
|
||||
root.addEventListener('click', (e) => {
|
||||
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
|
||||
if (act === 'replace') { this._exitReframe(true); this._input.click(); }
|
||||
if (act === 'clear') {
|
||||
this._exitReframe(false);
|
||||
this._gen++;
|
||||
this._local = null;
|
||||
if (this.id) setSlot(this.id, null); else this._render();
|
||||
}
|
||||
});
|
||||
this._input.addEventListener('change', () => {
|
||||
const f = this._input.files && this._input.files[0];
|
||||
if (f) this._ingest(f);
|
||||
this._input.value = '';
|
||||
});
|
||||
// naturalWidth/Height aren't known until load — re-apply so the cover
|
||||
// baseline is computed from real dimensions, not the 100%×100% fallback.
|
||||
this._img.addEventListener('load', () => this._applyView());
|
||||
// Gated on editable + fit=cover so share links and contain/fill slots
|
||||
// stay static.
|
||||
this.addEventListener('dblclick', (e) => {
|
||||
if (!this.hasAttribute('data-editable') || !this._reframes()) return;
|
||||
e.preventDefault();
|
||||
if (this.hasAttribute('data-reframe')) this._exitReframe(true);
|
||||
else this._enterReframe();
|
||||
});
|
||||
// Pan + resize both originate on the spill layer. A handle pointerdown
|
||||
// drives an aspect-locked resize anchored at the opposite corner; any
|
||||
// other pointerdown on the spill pans. Offsets are frame-% so a
|
||||
// reframed slot survives responsive resize / PPTX export.
|
||||
this._spill.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._spill.setPointerCapture(e.pointerId);
|
||||
const rect = this.getBoundingClientRect();
|
||||
const fw = rect.width || 1, fh = rect.height || 1;
|
||||
const corner = e.target.getAttribute && e.target.getAttribute('data-c');
|
||||
let move;
|
||||
if (corner) {
|
||||
// Resize about the OPPOSITE corner. Viewport-px throughout (rect
|
||||
// fw/fh, not clientWidth) so the math survives a transform:scale()
|
||||
// ancestor — deck_stage renders slides scaled-to-fit.
|
||||
const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
|
||||
const base = Math.max(fw / iw, fh / ih);
|
||||
const sx = corner.includes('e') ? 1 : -1;
|
||||
const sy = corner.includes('s') ? 1 : -1;
|
||||
const s0 = this._view.s;
|
||||
const w0 = iw * base * s0, h0 = ih * base * s0;
|
||||
const cx0 = (50 + this._view.x) / 100 * fw;
|
||||
const cy0 = (50 + this._view.y) / 100 * fh;
|
||||
const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
|
||||
const diag0 = Math.hypot(w0, h0);
|
||||
const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
|
||||
move = (ev) => {
|
||||
const proj = (ev.clientX - rect.left - ox) * ux +
|
||||
(ev.clientY - rect.top - oy) * uy;
|
||||
const s = clampS(s0 * proj / diag0);
|
||||
const d = diag0 * s / s0;
|
||||
this._view.s = s;
|
||||
this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
|
||||
this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
} else {
|
||||
this.setAttribute('data-panning', '');
|
||||
const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
|
||||
move = (ev) => {
|
||||
this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
|
||||
this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
}
|
||||
const up = () => {
|
||||
try { this._spill.releasePointerCapture(e.pointerId); } catch {}
|
||||
this._spill.removeEventListener('pointermove', move);
|
||||
this._spill.removeEventListener('pointerup', up);
|
||||
this._spill.removeEventListener('pointercancel', up);
|
||||
this.removeAttribute('data-panning');
|
||||
this._dragUp = null;
|
||||
};
|
||||
// Stashed so _exitReframe (Escape / outside-click mid-drag) can
|
||||
// tear the capture + listeners down synchronously.
|
||||
this._dragUp = up;
|
||||
this._spill.addEventListener('pointermove', move);
|
||||
this._spill.addEventListener('pointerup', up);
|
||||
this._spill.addEventListener('pointercancel', up);
|
||||
});
|
||||
// Wheel zoom stays available inside reframe mode as a trackpad nicety —
|
||||
// zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
|
||||
this.addEventListener('wheel', (e) => {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
const r = this.getBoundingClientRect();
|
||||
const cx = (e.clientX - r.left) / r.width * 100 - 50;
|
||||
const cy = (e.clientY - r.top) / r.height * 100 - 50;
|
||||
const prev = this._view.s;
|
||||
const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
|
||||
if (next === prev) return;
|
||||
const k = next / prev;
|
||||
this._view.s = next;
|
||||
this._view.x = cx * (1 - k) + this._view.x * k;
|
||||
this._view.y = cy * (1 - k) + this._view.y * k;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Warn once per page — an id-less slot works for the session but
|
||||
// cannot persist, and two id-less slots would share nothing.
|
||||
if (!this.id && !ImageSlot._warned) {
|
||||
ImageSlot._warned = true;
|
||||
console.warn('<image-slot> without an id will not persist its dropped image.');
|
||||
}
|
||||
this.addEventListener('dragenter', this);
|
||||
this.addEventListener('dragover', this);
|
||||
this.addEventListener('dragleave', this);
|
||||
this.addEventListener('drop', this);
|
||||
subs.add(this._subFn);
|
||||
// width%/height% in _applyView encode the frame aspect at call time —
|
||||
// a host resize (responsive grid, pane divider) would stretch the
|
||||
// image until the next _render. Re-render on size change: _render()
|
||||
// re-seeds _view from stored before clamp/apply, so a shrink→grow
|
||||
// cycle round-trips instead of ratcheting x/y toward the narrower
|
||||
// frame's clamp range.
|
||||
this._ro = new ResizeObserver(() => this._render());
|
||||
this._ro.observe(this);
|
||||
load();
|
||||
this._render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
subs.delete(this._subFn);
|
||||
this.removeEventListener('dragenter', this);
|
||||
this.removeEventListener('dragover', this);
|
||||
this.removeEventListener('dragleave', this);
|
||||
this.removeEventListener('drop', this);
|
||||
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||
this._exitReframe(false);
|
||||
}
|
||||
|
||||
_enterReframe() {
|
||||
if (this.hasAttribute('data-reframe')) return;
|
||||
this.setAttribute('data-reframe', '');
|
||||
this._applyView();
|
||||
// Close on click outside (the spill handler stopPropagation()s so
|
||||
// in-image drags don't reach this) and on Escape. Listeners are held
|
||||
// on the instance so _exitReframe / disconnectedCallback can detach
|
||||
// exactly what was attached.
|
||||
this._outside = (e) => {
|
||||
if (e.composedPath && e.composedPath().includes(this)) return;
|
||||
this._exitReframe(true);
|
||||
};
|
||||
this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
|
||||
document.addEventListener('pointerdown', this._outside, true);
|
||||
document.addEventListener('keydown', this._esc, true);
|
||||
}
|
||||
|
||||
_exitReframe(commit) {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
if (this._dragUp) this._dragUp();
|
||||
this.removeAttribute('data-reframe');
|
||||
this.removeAttribute('data-panning');
|
||||
if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
|
||||
if (this._esc) document.removeEventListener('keydown', this._esc, true);
|
||||
this._outside = this._esc = null;
|
||||
if (commit) this._commitView();
|
||||
}
|
||||
|
||||
attributeChangedCallback() { if (this.shadowRoot) this._render(); }
|
||||
|
||||
// handleEvent — one listener object for all four drag events keeps the
|
||||
// add/remove symmetric and the depth counter correct.
|
||||
handleEvent(e) {
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
// Without preventDefault the browser never fires 'drop'.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
if (e.type === 'dragenter') this._depth++;
|
||||
this.setAttribute('data-over', '');
|
||||
} else if (e.type === 'dragleave') {
|
||||
// dragenter/leave fire for every descendant crossing — count depth
|
||||
// so hovering the icon inside the empty state doesn't flicker.
|
||||
if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
|
||||
} else if (e.type === 'drop') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._depth = 0;
|
||||
this.removeAttribute('data-over');
|
||||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) this._ingest(f);
|
||||
}
|
||||
}
|
||||
|
||||
async _ingest(file) {
|
||||
this._setError(null);
|
||||
if (!file || ACCEPT.indexOf(file.type) < 0) {
|
||||
this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
|
||||
return;
|
||||
}
|
||||
// toDataUrl can take hundreds of ms on a large photo. A Clear or a
|
||||
// newer drop during that window would be clobbered when this await
|
||||
// resumes — bump + capture a generation so stale encodes bail.
|
||||
const gen = ++this._gen;
|
||||
try {
|
||||
const w = this.clientWidth || this.offsetWidth || MAX_DIM;
|
||||
const url = await toDataUrl(file, w);
|
||||
if (gen !== this._gen) return;
|
||||
// Only exit reframe once the new image is in hand — a rejected type
|
||||
// or decode failure leaves the in-progress crop untouched.
|
||||
this._exitReframe(false);
|
||||
const val = { u: url, s: 1, x: 0, y: 0 };
|
||||
setSlot(this.id || '', val);
|
||||
// Keep a session-local copy for id-less slots so the drop still
|
||||
// shows, even though it cannot persist.
|
||||
if (!this.id) { this._local = val; this._render(); }
|
||||
} catch (err) {
|
||||
if (gen !== this._gen) return;
|
||||
this._setError('Could not read that image.');
|
||||
console.warn('<image-slot> ingest failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_setError(msg) {
|
||||
if (this._err) { this._err.remove(); this._err = null; }
|
||||
if (!msg) return;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'err'; d.textContent = msg;
|
||||
this.shadowRoot.appendChild(d);
|
||||
this._err = d;
|
||||
setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
|
||||
}
|
||||
|
||||
// Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
|
||||
// keep the old object-fit path and double-click is a no-op.
|
||||
_reframes() {
|
||||
return this.hasAttribute('data-filled') &&
|
||||
(this.getAttribute('fit') || 'cover') === 'cover';
|
||||
}
|
||||
|
||||
// Cover-baseline geometry, shared by clamp/apply/resize. Null until the
|
||||
// img has loaded (naturalWidth is 0 before that) or when the slot has no
|
||||
// layout box — ResizeObserver fires with a 0×0 rect under display:none,
|
||||
// and clamping against a degenerate 1×1 frame would silently pull the
|
||||
// stored pan toward zero.
|
||||
_geom() {
|
||||
const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
|
||||
const fw = this.clientWidth, fh = this.clientHeight;
|
||||
if (!iw || !ih || !fw || !fh) return null;
|
||||
return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
|
||||
}
|
||||
|
||||
_clampView() {
|
||||
// Pan range on each axis is half the overflow past the frame edge.
|
||||
const g = this._geom();
|
||||
if (!g) return;
|
||||
const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
|
||||
const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
|
||||
this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
|
||||
this._view.y = Math.max(-my, Math.min(my, this._view.y));
|
||||
}
|
||||
|
||||
_applyView() {
|
||||
const g = this._geom();
|
||||
const fit = this.getAttribute('fit') || 'cover';
|
||||
if (fit !== 'cover' || !g) {
|
||||
// Non-cover, or dimensions not known yet (before img load).
|
||||
this._img.style.width = '100%';
|
||||
this._img.style.height = '100%';
|
||||
this._img.style.left = '50%';
|
||||
this._img.style.top = '50%';
|
||||
this._img.style.objectFit = fit;
|
||||
this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
|
||||
return;
|
||||
}
|
||||
// Cover baseline: img fills the frame on its tighter axis at s=1, so
|
||||
// pan works immediately on the overflowing axis without zooming first.
|
||||
// Width/height and left/top are all frame-% — depends only on the
|
||||
// frame aspect ratio, so a responsive resize keeps the same crop. The
|
||||
// spill layer mirrors the same box so its corners = image corners.
|
||||
const k = g.base * this._view.s;
|
||||
const w = (g.iw * k / g.fw * 100) + '%';
|
||||
const h = (g.ih * k / g.fh * 100) + '%';
|
||||
const l = (50 + this._view.x) + '%';
|
||||
const t = (50 + this._view.y) + '%';
|
||||
this._img.style.width = w; this._img.style.height = h;
|
||||
this._img.style.left = l; this._img.style.top = t;
|
||||
this._img.style.objectFit = '';
|
||||
this._spill.style.width = w; this._spill.style.height = h;
|
||||
this._spill.style.left = l; this._spill.style.top = t;
|
||||
}
|
||||
|
||||
_commitView() {
|
||||
const v = { s: this._view.s, x: this._view.x, y: this._view.y };
|
||||
if (this._userUrl) v.u = this._userUrl;
|
||||
// Framing-only (no u) persists too so an author-src slot remembers its
|
||||
// crop; clearing the sidecar still falls through to src=.
|
||||
if (this.id) setSlot(this.id, v);
|
||||
else { this._local = v; }
|
||||
}
|
||||
|
||||
_render() {
|
||||
// Shape / mask. Presets use border-radius so the dashed ring can
|
||||
// follow the rounded outline; clip-path is only applied for an
|
||||
// explicit `mask` (the ring is hidden there since a rectangle
|
||||
// dashed border chopped by an arbitrary polygon looks broken).
|
||||
const mask = this.getAttribute('mask');
|
||||
const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
|
||||
let radius = '';
|
||||
if (shape === 'circle') radius = '50%';
|
||||
else if (shape === 'pill') radius = '9999px';
|
||||
else if (shape === 'rounded') {
|
||||
const n = parseFloat(this.getAttribute('radius'));
|
||||
radius = (Number.isFinite(n) ? n : 12) + 'px';
|
||||
}
|
||||
this._frame.style.borderRadius = mask ? '' : radius;
|
||||
this._frame.style.clipPath = mask || '';
|
||||
this._ring.style.borderRadius = mask ? '' : radius;
|
||||
this._ring.style.display = mask ? 'none' : '';
|
||||
|
||||
// Controls and reframe entry gate on this so share links stay read-only.
|
||||
const editable = !!(window.omelette && window.omelette.writeFile);
|
||||
this.toggleAttribute('data-editable', editable);
|
||||
this._sub.style.display = editable ? '' : 'none';
|
||||
|
||||
// Content. The sidecar is also writable by the agent's write_file
|
||||
// tool, so its value isn't guaranteed canvas-originated — only accept
|
||||
// data:image/ URLs from it. The `src` attribute is author-controlled
|
||||
// (Claude wrote it into the HTML) so it passes through unchanged.
|
||||
let stored = this.id ? getSlot(this.id) : this._local;
|
||||
if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
|
||||
const srcAttr = this.getAttribute('src') || '';
|
||||
this._userUrl = (stored && stored.u) || null;
|
||||
const url = this._userUrl || srcAttr;
|
||||
// Don't clobber an in-flight reframe with a store-triggered re-render.
|
||||
if (!this.hasAttribute('data-reframe')) {
|
||||
this._view = {
|
||||
s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
|
||||
x: stored && Number.isFinite(stored.x) ? stored.x : 0,
|
||||
y: stored && Number.isFinite(stored.y) ? stored.y : 0,
|
||||
};
|
||||
}
|
||||
this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
|
||||
// Toggle via style.display — the [hidden] attribute alone loses to
|
||||
// the display:flex / display:block rules in the stylesheet above.
|
||||
if (url) {
|
||||
if (this._img.getAttribute('src') !== url) {
|
||||
this._img.src = url;
|
||||
this._ghost.src = url;
|
||||
}
|
||||
this._img.style.display = 'block';
|
||||
this._empty.style.display = 'none';
|
||||
this.setAttribute('data-filled', '');
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
} else {
|
||||
this._img.style.display = 'none';
|
||||
this._img.removeAttribute('src');
|
||||
this._ghost.removeAttribute('src');
|
||||
this._empty.style.display = 'flex';
|
||||
this.removeAttribute('data-filled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('image-slot')) {
|
||||
customElements.define('image-slot', ImageSlot);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,239 @@
|
||||
/* LynkedUp Pro v3 — interactivity */
|
||||
|
||||
// ---------- Mobile nav (hamburger toggle) ----------
|
||||
(function() {
|
||||
const toggle = document.getElementById('navToggle');
|
||||
const links = document.getElementById('navLinks');
|
||||
const overlay = document.getElementById('navOverlay');
|
||||
if (!toggle || !links) return;
|
||||
|
||||
function setOpen(open) {
|
||||
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
toggle.classList.toggle('is-open', open);
|
||||
links.classList.toggle('is-open', open);
|
||||
if (overlay) overlay.classList.toggle('is-open', open);
|
||||
document.body.classList.toggle('nav-locked', open);
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = toggle.getAttribute('aria-expanded') !== 'true';
|
||||
setOpen(open);
|
||||
});
|
||||
if (overlay) overlay.addEventListener('click', () => setOpen(false));
|
||||
// Close on any link tap inside the panel
|
||||
links.querySelectorAll('a').forEach(a => {
|
||||
a.addEventListener('click', () => setOpen(false));
|
||||
});
|
||||
// Close when crossing back to desktop width
|
||||
const mq = window.matchMedia('(min-width: 1024px)');
|
||||
mq.addEventListener('change', e => { if (e.matches) setOpen(false); });
|
||||
// Close on Escape
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- Theme toggle (light/dark) ----------
|
||||
(function() {
|
||||
const root = document.documentElement;
|
||||
const key = 'lp-theme';
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
root.setAttribute('data-theme', saved);
|
||||
} else {
|
||||
root.setAttribute('data-theme', 'dark'); // default
|
||||
}
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
const current = root.getAttribute('data-theme') || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
root.setAttribute('data-theme', next);
|
||||
localStorage.setItem(key, next);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ---------- Countdown timer (both final card + sticky bar) ----------
|
||||
(function() {
|
||||
let secs = 42, mins = 27, hours = 14, days = 3;
|
||||
const get = id => document.getElementById(id);
|
||||
const elFinal = { s: get('cdS'), m: get('cdM'), h: get('cdH'), d: get('cdD') };
|
||||
const elStk = { s: get('ssS'), m: get('ssM'), h: get('ssH'), d: get('ssD') };
|
||||
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function render() {
|
||||
const v = { d: pad(days), h: pad(hours), m: pad(mins), s: pad(secs) };
|
||||
for (const k of ['d','h','m','s']) {
|
||||
if (elFinal[k]) elFinal[k].textContent = v[k];
|
||||
if (elStk[k]) elStk[k].textContent = v[k];
|
||||
}
|
||||
}
|
||||
setInterval(() => {
|
||||
secs--;
|
||||
if (secs < 0) { secs = 59; mins--;
|
||||
if (mins < 0) { mins = 59; hours--;
|
||||
if (hours < 0) { hours = 23; days = Math.max(0, days - 1); }
|
||||
}
|
||||
}
|
||||
render();
|
||||
}, 1000);
|
||||
render();
|
||||
})();
|
||||
|
||||
// ---------- Sticky CTA reveal/hide ----------
|
||||
(function() {
|
||||
const el = document.getElementById('stickyCta');
|
||||
const close = document.getElementById('stickyClose');
|
||||
if (!el || !close) return;
|
||||
let dismissed = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (dismissed) return;
|
||||
if (window.scrollY > 700) el.classList.add('show');
|
||||
else el.classList.remove('show');
|
||||
});
|
||||
close.addEventListener('click', () => {
|
||||
dismissed = true;
|
||||
el.classList.remove('show');
|
||||
document.body.style.paddingBottom = '0';
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- Video player ----------
|
||||
(function() {
|
||||
const frame = document.getElementById('videoFrame');
|
||||
const playBtn = document.getElementById('videoPlay');
|
||||
const videoEl = document.getElementById('videoEl');
|
||||
if (!frame || !playBtn || !videoEl) return;
|
||||
|
||||
function play(at) {
|
||||
frame.classList.add('playing');
|
||||
if (typeof at === 'number' && !isNaN(at)) {
|
||||
try { videoEl.currentTime = at; } catch (e) {}
|
||||
}
|
||||
videoEl.play().catch(() => {});
|
||||
}
|
||||
playBtn.addEventListener('click', () => play());
|
||||
document.querySelectorAll('.vc[data-seek]').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
const at = parseFloat(chip.dataset.seek);
|
||||
play(at);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- Dashboard product-view tabs ----------
|
||||
(function() {
|
||||
const wrap = document.getElementById('dashWindow');
|
||||
if (!wrap) return;
|
||||
const tabs = wrap.querySelectorAll('.dw-tab');
|
||||
const shots = wrap.querySelectorAll('.dw-shot');
|
||||
const urlText = document.getElementById('dwUrlText');
|
||||
|
||||
function activate(name) {
|
||||
tabs.forEach(t => {
|
||||
const on = t.dataset.tab === name;
|
||||
t.classList.toggle('is-active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
if (on && urlText && t.dataset.url) urlText.textContent = t.dataset.url;
|
||||
});
|
||||
shots.forEach(s => s.classList.toggle('is-active', s.dataset.shot === name));
|
||||
}
|
||||
|
||||
tabs.forEach(t => {
|
||||
t.addEventListener('click', () => activate(t.dataset.tab));
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------- Smooth in-page nav ----------
|
||||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
a.addEventListener('click', (e) => {
|
||||
const id = a.getAttribute('href');
|
||||
if (id.length < 2) return;
|
||||
const t = document.querySelector(id);
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: t.getBoundingClientRect().top + window.scrollY - 80, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Tweaks panel ----------
|
||||
(function() {
|
||||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
"accentOrange": "#f97316",
|
||||
"accentCyan": "#22d3ee",
|
||||
"bgIntensity": 0.65,
|
||||
"showGrid": true
|
||||
}/*EDITMODE-END*/;
|
||||
|
||||
let state = { ...TWEAK_DEFAULTS };
|
||||
let active = false;
|
||||
const root = document.getElementById('tweaks-root');
|
||||
|
||||
function applyState() {
|
||||
document.documentElement.style.setProperty('--orange', state.accentOrange);
|
||||
document.documentElement.style.setProperty('--cyan', state.accentCyan);
|
||||
document.documentElement.style.setProperty('--cyan-2', state.accentCyan);
|
||||
const orbs = document.querySelectorAll('.bg-orb');
|
||||
orbs.forEach(o => o.style.opacity = state.bgIntensity);
|
||||
const grid = document.querySelector('.bg-grid');
|
||||
if (grid) grid.style.display = state.showGrid ? '' : 'none';
|
||||
}
|
||||
applyState();
|
||||
|
||||
function render() {
|
||||
if (!active) { root.innerHTML = ''; return; }
|
||||
root.innerHTML = `
|
||||
<div style="width:300px; background:rgba(5,15,36,.96); backdrop-filter:blur(20px); border:1px solid rgba(56,189,248,.3); border-radius:16px; box-shadow:0 30px 60px -20px rgba(0,0,0,.8); padding:18px; font-family:'Plus Jakarta Sans'; color:#e6edf6;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
<strong style="font-size:14px; letter-spacing:-.01em; color:#fff;">Tweaks</strong>
|
||||
<button id="tw-close" style="border:0; background:transparent; cursor:pointer; color:#94a3b8; font-size:18px;">×</button>
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px; color:#94a3b8; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">CTA Orange</div>
|
||||
<div style="display:flex; gap:6px; margin-bottom:14px;">
|
||||
${['#f97316','#ef4444','#fbbf24','#ec4899'].map(c => `<button data-orange="${c}" style="flex:1; height:32px; border-radius:8px; border:2px solid ${state.accentOrange===c?'#fff':'transparent'}; background:${c}; cursor:pointer;"></button>`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px; color:#94a3b8; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Accent Cyan</div>
|
||||
<div style="display:flex; gap:6px; margin-bottom:14px;">
|
||||
${['#22d3ee','#38bdf8','#a78bfa','#10b981'].map(c => `<button data-cyan="${c}" style="flex:1; height:32px; border-radius:8px; border:2px solid ${state.accentCyan===c?'#fff':'transparent'}; background:${c}; cursor:pointer;"></button>`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px; color:#94a3b8; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin-bottom:8px;">Background glow · ${Math.round(state.bgIntensity*100)}%</div>
|
||||
<input type="range" min="0" max="1" step="0.05" value="${state.bgIntensity}" id="tw-bg" style="width:100%; margin-bottom:14px;">
|
||||
|
||||
<label style="display:flex; align-items:center; gap:8px; font-size:13px; color:#cbd5e1; cursor:pointer;">
|
||||
<input type="checkbox" id="tw-grid" ${state.showGrid?'checked':''}/>
|
||||
Show background grid
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('#tw-close').onclick = () => {
|
||||
active = false; render();
|
||||
window.parent.postMessage({type:'__edit_mode_dismissed'}, '*');
|
||||
};
|
||||
root.querySelectorAll('[data-orange]').forEach(b => b.onclick = () => {
|
||||
state.accentOrange = b.dataset.orange; applyState(); render();
|
||||
window.parent.postMessage({type:'__edit_mode_set_keys', edits:{accentOrange: state.accentOrange}}, '*');
|
||||
});
|
||||
root.querySelectorAll('[data-cyan]').forEach(b => b.onclick = () => {
|
||||
state.accentCyan = b.dataset.cyan; applyState(); render();
|
||||
window.parent.postMessage({type:'__edit_mode_set_keys', edits:{accentCyan: state.accentCyan}}, '*');
|
||||
});
|
||||
root.querySelector('#tw-bg').oninput = (e) => {
|
||||
state.bgIntensity = parseFloat(e.target.value); applyState(); render();
|
||||
window.parent.postMessage({type:'__edit_mode_set_keys', edits:{bgIntensity: state.bgIntensity}}, '*');
|
||||
};
|
||||
root.querySelector('#tw-grid').onchange = (e) => {
|
||||
state.showGrid = e.target.checked; applyState(); render();
|
||||
window.parent.postMessage({type:'__edit_mode_set_keys', edits:{showGrid: state.showGrid}}, '*');
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data?.type === '__activate_edit_mode') { active = true; render(); }
|
||||
if (e.data?.type === '__deactivate_edit_mode') { active = false; render(); }
|
||||
});
|
||||
window.parent.postMessage({type:'__edit_mode_available'}, '*');
|
||||
})();
|
||||
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,50 +0,0 @@
|
||||
/* 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);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,797 @@
|
||||
/* global React, Reveal, GlowCard, Icon, Sparkline, DonutChart, BlueprintHouse, Counter */
|
||||
// Analytics, Field Intelligence, Testimonials, CTA, Footer
|
||||
|
||||
const { useEffect, useRef, useState } = React;
|
||||
|
||||
// ----------- Animated bar chart ------------
|
||||
function BarChart({ data, max, color = "var(--cyan)", height = 180 }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 8, height, padding: "12px 0" }}>
|
||||
{data.map((d, i) => {
|
||||
const h = (d.v / max) * (height - 30);
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
<div style={{
|
||||
width: "100%",
|
||||
height: h,
|
||||
background: d.highlight
|
||||
? `linear-gradient(180deg, var(--orange) 0%, rgba(255,138,30,0.3) 100%)`
|
||||
: `linear-gradient(180deg, ${color} 0%, ${color}33 100%)`,
|
||||
borderRadius: "6px 6px 2px 2px",
|
||||
boxShadow: d.highlight ? "0 0 18px rgba(255,138,30,0.7)" : `0 0 14px ${color}55`,
|
||||
animation: `growBar 1s ease-out ${i * 0.06}s both`,
|
||||
transformOrigin: "bottom",
|
||||
}}/>
|
||||
<span style={{ fontSize: 10, color: "var(--muted)" }} className="mono">{d.l}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- HUD ring widget --------------
|
||||
function HudRing({ value, label, color = "var(--cyan)", size = 140 }) {
|
||||
const r = (size - 18) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const dash = (value / 100) * c;
|
||||
return (
|
||||
<div style={{ position: "relative", width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)" }}>
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="6"/>
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="6"
|
||||
strokeDasharray={`${dash} ${c}`}
|
||||
strokeLinecap="round"
|
||||
style={{ filter: `drop-shadow(0 0 8px ${color})`, transition: "stroke-dasharray 1s" }}
|
||||
/>
|
||||
{/* tick marks */}
|
||||
{Array.from({ length: 60 }, (_, i) => {
|
||||
const angle = (i / 60) * 2 * Math.PI;
|
||||
const inner = r + 4, outer = r + 10;
|
||||
const x1 = size/2 + inner * Math.cos(angle);
|
||||
const y1 = size/2 + inner * Math.sin(angle);
|
||||
const x2 = size/2 + outer * Math.cos(angle);
|
||||
const y2 = size/2 + outer * Math.sin(angle);
|
||||
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="rgba(0,209,255,0.18)" strokeWidth={i%5===0?"1.2":"0.5"}/>;
|
||||
})}
|
||||
</svg>
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
display: "flex", flexDirection: "column",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
<span className="tabular" style={{ fontFamily: "var(--font-display)", fontSize: 28, fontWeight: 600, color, lineHeight: 1, textShadow: `0 0 14px ${color}88` }}>
|
||||
<Counter to={value} suffix="%"/>
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 9, color: "var(--muted)", letterSpacing: "0.18em", marginTop: 4 }}>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Analytics Section ------------
|
||||
function Analytics() {
|
||||
const bars = [
|
||||
{ l: "MON", v: 32 }, { l: "TUE", v: 48 }, { l: "WED", v: 38 },
|
||||
{ l: "THU", v: 62 }, { l: "FRI", v: 88, highlight: true },
|
||||
{ l: "SAT", v: 54 }, { l: "SUN", v: 42 },
|
||||
];
|
||||
return (
|
||||
<section className="section" id="analytics">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 56, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§08</span>
|
||||
<span>ANALYTICS & CONTROL CENTER</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22 }}>
|
||||
Measure once.<br/>
|
||||
<span className="accent">Use everywhere.</span>
|
||||
</h2>
|
||||
<p className="sub" style={{ marginTop: 18 }}>
|
||||
A cinematic, HUD-style enterprise dashboard built from your live
|
||||
operational data, calibrated for executive decisions and field reality.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-12">
|
||||
<span className="dim-tag">// LIVE STREAM</span>
|
||||
<span className="dim-tag" style={{ color: "var(--green)", borderColor: "rgba(43,212,156,0.4)" }}>● 142 MS</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<GlowCard style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
padding: "20px 26px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
background: "rgba(0,209,255,0.04)",
|
||||
}}>
|
||||
<div className="row gap-16">
|
||||
<span className="mono" style={{ fontSize: 11, color: "var(--cyan-2)", letterSpacing: "0.22em" }}>OWNERS BOX · ANALYTICS</span>
|
||||
<span className="chip green"><span className="live-dot"/> LIVE STREAM</span>
|
||||
</div>
|
||||
<div className="row gap-12" style={{ fontSize: 11, color: "var(--muted)" }} >
|
||||
<span>Last sync: 142 ms ago</span>
|
||||
<span>·</span>
|
||||
<span className="mono" style={{ color: "var(--cyan)" }}>Q2 · 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 0 }}>
|
||||
{/* Left main panel */}
|
||||
<div style={{ padding: 28, borderRight: "1px solid var(--border)" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--muted)", letterSpacing: "0.18em" }}>WEEKLY REVENUE</div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 40, fontWeight: 600, color: "var(--cyan)", textShadow: "0 0 20px rgba(0,209,255,0.5)" }}>
|
||||
$<Counter to={200000} duration={2200}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
||||
<span className="chip orange" style={{ fontSize: 10 }}>▲ +28.4% wow</span>
|
||||
<span style={{ fontSize: 11, color: "var(--muted)", marginTop: 6 }}>vs. $155,763 last week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BarChart data={bars} max={100} height={220}/>
|
||||
|
||||
<div className="row" style={{ gap: 14, marginTop: 14 }}>
|
||||
{[
|
||||
{ label: "GROSS MARGIN", v: 42, color: "var(--cyan)" },
|
||||
{ label: "CYCLE TIME", v: 73, color: "var(--orange)" },
|
||||
{ label: "CREW UTIL.", v: 87, color: "var(--green)" },
|
||||
{ label: "PIPELINE HEALTH", v: 94, color: "var(--violet)" },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ flex: 1 }}>
|
||||
<div className="mono" style={{ fontSize: 9, color: "var(--muted)", letterSpacing: "0.14em" }}>{s.label}</div>
|
||||
<div className="tabular" style={{ fontFamily: "var(--font-display)", fontSize: 22, fontWeight: 600, color: s.color, lineHeight: 1.1 }}>
|
||||
<Counter to={s.v} suffix="%"/>
|
||||
</div>
|
||||
<div style={{ height: 3, background: "rgba(255,255,255,0.06)", borderRadius: 2, marginTop: 6, overflow: "hidden" }}>
|
||||
<div style={{
|
||||
width: `${s.v}%`, height: "100%",
|
||||
background: s.color,
|
||||
boxShadow: `0 0 8px ${s.color}`,
|
||||
animation: "growW 1.2s ease-out",
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right HUD column */}
|
||||
<div style={{ padding: 28, display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<div className="row" style={{ justifyContent: "space-around" }}>
|
||||
<HudRing value={92} label="OPS HEALTH" color="var(--cyan)" size={130}/>
|
||||
<HudRing value={78} label="FORECAST" color="var(--orange)" size={130}/>
|
||||
</div>
|
||||
|
||||
{/* Forecast list */}
|
||||
<div style={{ padding: 16, border: "1px solid var(--border)", borderRadius: 12, background: "rgba(0,209,255,0.03)" }}>
|
||||
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--muted)", letterSpacing: "0.16em" }}>AI FORECAST · NEXT 14 DAYS</span>
|
||||
<Icon name="spark" size={14} stroke="var(--cyan)"/>
|
||||
</div>
|
||||
{[
|
||||
{ l: "Estimated revenue", v: "$430K", c: "var(--cyan)" },
|
||||
{ l: "Crews needed", v: "14", c: "var(--orange)" },
|
||||
{ l: "Weather risk", v: "Low", c: "var(--green)" },
|
||||
{ l: "Material orders", v: "23", c: "var(--violet)" },
|
||||
].map((f, i) => (
|
||||
<div key={i} className="row" style={{ justifyContent: "space-between", padding: "8px 0", borderTop: i ? "1px solid rgba(255,255,255,0.05)" : "none" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--ink-2)" }}>{f.l}</span>
|
||||
<span className="tabular" style={{ fontFamily: "var(--font-display)", fontSize: 15, fontWeight: 600, color: f.c }}>{f.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mini activity */}
|
||||
<div style={{ padding: 16, border: "1px solid var(--border)", borderRadius: 12, background: "rgba(0,209,255,0.03)" }}>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--muted)", letterSpacing: "0.16em", display: "block", marginBottom: 10 }}>LIVE FEED</span>
|
||||
{[
|
||||
{ c: "var(--green)", t: "Payment received", s: "$8,250 · Maple Project" },
|
||||
{ c: "var(--cyan)", t: "Estimate accepted", s: "Pine Rd · $14,800" },
|
||||
{ c: "var(--orange)", t: "Crew dispatched", s: "Diego's crew → 456 Oak" },
|
||||
].map((a, i) => (
|
||||
<div key={i} className="row gap-12" style={{ padding: "6px 0" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: a.c, boxShadow: `0 0 8px ${a.c}` }}/>
|
||||
<div style={{ flex: 1, fontSize: 12 }}>
|
||||
<span style={{ fontWeight: 600 }}>{a.t}</span>{" "}
|
||||
<span style={{ color: "var(--muted)" }}>· {a.s}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlowCard>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Field Intelligence Section ----------
|
||||
function FieldIntelligence() {
|
||||
return (
|
||||
<section className="section" id="intelligence">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 56, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§09</span>
|
||||
<span>FIELD INTELLIGENCE</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22, maxWidth: 880 }}>
|
||||
Field intelligence.<br/>
|
||||
<span className="accent">Not just CRM.</span>
|
||||
</h2>
|
||||
<p className="sub" style={{ marginTop: 18 }}>
|
||||
AI-powered measurements, photo evidence, claim-ready reports,
|
||||
and on-site decisions in minutes, not days.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-12">
|
||||
<span className="dim-tag">// AI & LIDAR</span>
|
||||
<span className="dim-tag" style={{ color: "var(--orange-2)", borderColor: "rgba(255,138,30,0.4)" }}>// PRECISION MODE</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Big wireframe stage */}
|
||||
<Reveal delay={1}>
|
||||
<div style={{
|
||||
position: "relative",
|
||||
height: 620,
|
||||
background: `
|
||||
radial-gradient(900px 500px at 50% 50%, rgba(0,209,255,0.18), transparent 70%),
|
||||
linear-gradient(180deg, rgba(2,6,14,0.4), rgba(2,6,14,0.2))
|
||||
`,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Stage floor grid */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(0,209,255,0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,209,255,0.08) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "40px 40px",
|
||||
maskImage: "radial-gradient(ellipse 80% 60% at 50% 60%, #000 30%, transparent 80%)",
|
||||
}}/>
|
||||
|
||||
{/* Huge centered wireframe house */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "50%", top: "55%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}>
|
||||
<BlueprintHouse size={780} withMeasurements={true} withScan={true}/>
|
||||
</div>
|
||||
|
||||
{/* Floating hex logo above */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "50%", top: "30%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
animation: "floatY 4s ease-in-out infinite",
|
||||
}}>
|
||||
<img src="/uploads/lynkedup-logo.png" alt="LynkedUp Pro"
|
||||
width="140" height="140"
|
||||
style={{ width: 140, height: 140, objectFit: "contain", filter: "drop-shadow(0 0 28px rgba(0,209,255,0.9))" }}/>
|
||||
</div>
|
||||
|
||||
{/* Side data overlays */}
|
||||
<div style={{
|
||||
position: "absolute", top: 40, left: 40,
|
||||
padding: 16, width: 240,
|
||||
background: "rgba(0,209,255,0.07)",
|
||||
border: "1px solid rgba(0,209,255,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(10px)",
|
||||
boxShadow: "0 0 30px -10px rgba(0,209,255,0.6)",
|
||||
}}>
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--cyan-2)", letterSpacing: "0.18em", marginBottom: 8 }}>AI MEASUREMENT</div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 28, fontWeight: 600, color: "var(--cyan)", lineHeight: 1, textShadow: "0 0 14px rgba(0,209,255,0.6)" }}>
|
||||
<Counter to={2347.18} decimals={2}/> <span style={{ fontSize: 12 }}>SQ FT</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>Roof area · 14 planes detected</div>
|
||||
<div style={{ marginTop: 10, padding: "8px 10px", background: "rgba(0,209,255,0.07)", borderRadius: 6, fontSize: 11, color: "var(--cyan-2)" }} className="mono">
|
||||
● SCAN COMPLETE · 24 PHOTOS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: "absolute", top: 40, right: 40,
|
||||
padding: 16, width: 220,
|
||||
background: "rgba(255,138,30,0.07)",
|
||||
border: "1px solid rgba(255,138,30,0.45)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(10px)",
|
||||
boxShadow: "0 0 30px -10px rgba(255,138,30,0.5)",
|
||||
}}>
|
||||
<div className="row gap-8" style={{ marginBottom: 8 }}>
|
||||
<Icon name="brain" size={14} stroke="var(--orange)"/>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--orange-2)", letterSpacing: "0.18em" }}>AI-POWERED</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 17 }}>Precision Mode</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>AI & LiDAR active</div>
|
||||
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", fontSize: 11 }}>
|
||||
<span style={{ color: "var(--muted)" }}>Confidence</span>
|
||||
<span style={{ color: "var(--orange)", fontWeight: 600 }} className="tabular">99.7%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: "absolute", bottom: 40, left: 40,
|
||||
padding: 14, width: 260,
|
||||
background: "rgba(43,212,156,0.07)",
|
||||
border: "1px solid rgba(43,212,156,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(10px)",
|
||||
}}>
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--green)", letterSpacing: "0.16em", marginBottom: 6 }}>CLAIM READY</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Xactimate®-style report</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>Photo evidence attached · Generated in 6m 42s</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: "absolute", bottom: 40, right: 40,
|
||||
padding: 14, width: 220,
|
||||
background: "rgba(138,107,255,0.07)",
|
||||
border: "1px solid rgba(138,107,255,0.4)",
|
||||
borderRadius: 12,
|
||||
backdropFilter: "blur(10px)",
|
||||
}}>
|
||||
<div className="mono" style={{ fontSize: 10, color: "var(--violet)", letterSpacing: "0.16em", marginBottom: 6 }}>PLANES DETECTED</div>
|
||||
<div className="tabular" style={{ fontFamily: "var(--font-display)", fontSize: 28, fontWeight: 600, color: "var(--violet)" }}>
|
||||
<Counter to={14}/>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>Largest: 425.32 sq ft</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Intelligence sub-cards */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginTop: 32 }}>
|
||||
{[
|
||||
{ v: "10×", label: "FASTER", desc: "Measure in minutes, not hours.", color: "var(--cyan)", icon: "clock" },
|
||||
{ v: "98.6%", label: "ACCURATE", desc: "AI & LiDAR precision you can rely on.", color: "var(--orange)", icon: "target" },
|
||||
{ v: "6m 42s", label: "CLAIM READY", desc: "Evidence-backed reports that get paid.", color: "var(--green)", icon: "shield" },
|
||||
{ v: "∞", label: "CLOUD SYNC", desc: "Real-time access across your team.", color: "var(--violet)", icon: "sync" },
|
||||
].map((c, i) => (
|
||||
<Reveal key={c.label} delay={i + 1}>
|
||||
<GlowCard>
|
||||
<div className="pad">
|
||||
<Icon name={c.icon} size={22} stroke={c.color}/>
|
||||
<div className="tabular" style={{ fontFamily: "var(--font-display)", fontSize: 36, fontWeight: 600, color: c.color, lineHeight: 1, marginTop: 16, textShadow: `0 0 14px ${c.color}55` }}>{c.v}</div>
|
||||
<div className="mono" style={{ fontSize: 10, letterSpacing: "0.2em", marginTop: 6 }}>{c.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{c.desc}</div>
|
||||
</div>
|
||||
</GlowCard>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Testimonials -----------
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
quote: "LynkedUp Pro turned our chaos into a clear, connected operation. We close jobs faster and our margins are visible for the first time.",
|
||||
name: "Maya Patel",
|
||||
title: "Operations Manager · Plano, TX",
|
||||
init: "MP",
|
||||
color: "var(--cyan)",
|
||||
metric: "+42% revenue",
|
||||
},
|
||||
{
|
||||
quote: "It's the closest thing to having an extra ops director in the field. The AI Copilot has saved us from at least a dozen costly mistakes.",
|
||||
name: "Daniel Cruz",
|
||||
title: "Founder · Houston, TX",
|
||||
init: "DC",
|
||||
color: "var(--orange)",
|
||||
metric: "−68% admin time",
|
||||
},
|
||||
{
|
||||
quote: "Field teams adopted it in a week. Reports that took our office two days now take 4-5 minutes. It is genuinely transformational software.",
|
||||
name: "Ashley Reed",
|
||||
title: "Operations Manager · Shreveport, LA",
|
||||
init: "AR",
|
||||
color: "var(--green)",
|
||||
metric: "10K+ jobs synced",
|
||||
},
|
||||
];
|
||||
|
||||
function Testimonials() {
|
||||
return (
|
||||
<section className="section" id="customers">
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 56, flexWrap: "wrap", gap: 16 }}>
|
||||
<div>
|
||||
<span className="section-number">
|
||||
<span className="num">§10</span>
|
||||
<span>CUSTOMER VOICES</span>
|
||||
</span>
|
||||
<h2 className="h-section" style={{ marginTop: 22 }}>
|
||||
Operators are betting on <span className="accent">LynkedUp Pro</span>.
|
||||
</h2>
|
||||
</div>
|
||||
<span className="dim-tag" style={{ height: 30, padding: "4px 12px" }}>// 3,000+ TEAMS</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 22 }}>
|
||||
{TESTIMONIALS.map((t, i) => (
|
||||
<Reveal key={t.name} delay={i + 1}>
|
||||
<GlowCard style={{ height: "100%" }}>
|
||||
<div className="pad" style={{ display: "flex", flexDirection: "column", height: "100%", gap: 20 }}>
|
||||
<div style={{ fontSize: 36, fontFamily: "var(--font-display)", color: t.color, lineHeight: 0.5, opacity: 0.7 }}>“</div>
|
||||
<p style={{ fontSize: 15, lineHeight: 1.55, color: "var(--ink)", margin: 0, flex: 1 }}>{t.quote}</p>
|
||||
<div className="row gap-12" style={{ justifyContent: "space-between" }}>
|
||||
<div className="row gap-12">
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: "50%",
|
||||
background: `linear-gradient(135deg, ${t.color}, rgba(255,255,255,0.05))`,
|
||||
border: `1px solid ${t.color}99`,
|
||||
display: "grid", placeItems: "center",
|
||||
fontFamily: "var(--font-display)",
|
||||
fontWeight: 600, fontSize: 13,
|
||||
color: "white",
|
||||
boxShadow: `0 0 18px -6px ${t.color}`,
|
||||
}}>{t.init}</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: "var(--font-display)", fontSize: 14, fontWeight: 600 }}>{t.name}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)" }}>{t.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="chip" style={{ borderColor: `${t.color}55`, color: t.color, background: `${t.color}11` }}>{t.metric}</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlowCard>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Final CTA -------------
|
||||
function FinalCTA() {
|
||||
return (
|
||||
<section className="section" id="demo" style={{ paddingTop: 100, paddingBottom: 100 }}>
|
||||
<div className="beam top"/>
|
||||
<div className="shell">
|
||||
<Reveal>
|
||||
<div style={{
|
||||
position: "relative",
|
||||
padding: "100px 60px",
|
||||
borderRadius: 32,
|
||||
border: "1px solid var(--border-strong)",
|
||||
background: `
|
||||
radial-gradient(700px 400px at 20% 0%, rgba(0,209,255,0.25), transparent 60%),
|
||||
radial-gradient(700px 400px at 80% 100%, rgba(255,138,30,0.18), transparent 65%),
|
||||
linear-gradient(180deg, rgba(4,12,28,0.9), rgba(2,6,14,0.95))
|
||||
`,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 60px 120px -40px rgba(0,209,255,0.55)",
|
||||
}}>
|
||||
{/* Floor grid */}
|
||||
<div style={{
|
||||
position: "absolute", inset: 0,
|
||||
backgroundImage: `linear-gradient(rgba(0,209,255,0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(0,209,255,0.07) 1px, transparent 1px)`,
|
||||
backgroundSize: "44px 44px",
|
||||
maskImage: "radial-gradient(ellipse 60% 60% at 50% 50%, #000 30%, transparent 80%)",
|
||||
}}/>
|
||||
|
||||
{/* Floating light beams */}
|
||||
<div style={{
|
||||
position: "absolute", left: "10%", top: 0, bottom: 0, width: 2,
|
||||
background: "linear-gradient(180deg, transparent, var(--cyan), transparent)",
|
||||
filter: "blur(2px)", opacity: 0.5, animation: "floatY 3s ease-in-out infinite",
|
||||
}}/>
|
||||
<div style={{
|
||||
position: "absolute", right: "12%", top: 0, bottom: 0, width: 2,
|
||||
background: "linear-gradient(180deg, transparent, var(--orange), transparent)",
|
||||
filter: "blur(2px)", opacity: 0.4, animation: "floatY 4s ease-in-out infinite", animationDelay: "1s",
|
||||
}}/>
|
||||
|
||||
<div style={{ position: "relative", zIndex: 2, textAlign: "center", maxWidth: 880, margin: "0 auto" }}>
|
||||
<span className="eyebrow"><span className="dot"/> READY WHEN YOU ARE</span>
|
||||
<h2 className="h-display" style={{ marginTop: 26, fontSize: "clamp(54px, 7.5vw, 104px)" }}>
|
||||
<span className="grad">Run construction</span><br/>
|
||||
<span className="underline">without chaos.</span>
|
||||
</h2>
|
||||
<p className="sub" style={{ margin: "26px auto 40px" }}>
|
||||
Join the operators replacing five tools, four spreadsheets, and a
|
||||
whiteboard with one AI-powered command center built for the
|
||||
modern construction enterprise.
|
||||
</p>
|
||||
<div className="row gap-16" style={{ justifyContent: "center" }}>
|
||||
<a href="https://book.lynkeduppro.com/" className="btn btn-primary" style={{ height: 60, padding: "0 36px", fontSize: 16 }}>
|
||||
Book a demo <span className="arrow">→</span>
|
||||
</a>
|
||||
<a href="https://book.lynkeduppro.com/" className="btn btn-orange" style={{ height: 60, padding: "0 36px", fontSize: 16 }}>
|
||||
Claim Founders Lifetime Deal
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="row gap-24" style={{ justifyContent: "center", marginTop: 40, color: "var(--muted)", fontSize: 12 }}>
|
||||
<span className="row gap-8"><span className="live-dot"/> No credit card</span>
|
||||
<span>·</span>
|
||||
<span>SOC 2 Type II</span>
|
||||
<span>·</span>
|
||||
<span>White-glove onboarding</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Footer ---------------
|
||||
function Footer() {
|
||||
const cols = [
|
||||
{ h: "Product", items: ["Platform overview", "AI Copilot", "Mobile app", "Pricing", "Changelog"] },
|
||||
{ h: "Solutions", items: ["Roofing", "General contractors", "Infrastructure", "Enterprise", "Field teams"] },
|
||||
{ h: "Integrations", items: ["QuickBooks", "Stripe", "Calendly", "iOS / Google", "Zapier"] },
|
||||
{ h: "Resources", items: ["Documentation", "Customer stories", "Security", "Status", "API"] },
|
||||
{ h: "Contact", items: ["Talk to sales", "Support", "Careers", "Partner program", "Press"] },
|
||||
];
|
||||
return (
|
||||
<footer style={{
|
||||
position: "relative",
|
||||
padding: "80px 0 40px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "linear-gradient(180deg, rgba(0,209,255,0.04), transparent)",
|
||||
}}>
|
||||
<div className="shell">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1.4fr repeat(5, 1fr)", gap: 40, marginBottom: 56 }}>
|
||||
<div>
|
||||
<Logo size={36}/>
|
||||
<p style={{ marginTop: 16, fontSize: 14, color: "var(--muted)", maxWidth: 280, lineHeight: 1.55 }}>
|
||||
AI construction operating system built for field teams, modern
|
||||
contractors and infrastructure companies. Built for roofing.
|
||||
Driven by innovation.
|
||||
</p>
|
||||
<div className="row gap-12" style={{ marginTop: 22 }}>
|
||||
{["X","in","gh","yt"].map(s => (
|
||||
<a key={s} href="https://book.lynkeduppro.com/" style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
display: "grid", placeItems: "center",
|
||||
fontFamily: "var(--font-mono)", fontSize: 11,
|
||||
color: "var(--ink-2)", textDecoration: "none",
|
||||
background: "rgba(0,209,255,0.04)",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = "var(--cyan)"; e.currentTarget.style.color = "var(--cyan)"; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.color = "var(--ink-2)"; }}
|
||||
>{s}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{cols.map(c => (
|
||||
<div key={c.h}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--cyan-2)", letterSpacing: "0.2em", marginBottom: 16 }}>{c.h.toUpperCase()}</div>
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{c.items.map(it => (
|
||||
<li key={it}><a href="https://book.lynkeduppro.com/" style={{ fontSize: 13, color: "var(--ink-2)", textDecoration: "none", transition: "color 0.2s" }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = "var(--cyan)"}
|
||||
onMouseLeave={e => e.currentTarget.style.color = "var(--ink-2)"}
|
||||
>{it}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "linear-gradient(90deg, transparent, var(--border-strong), transparent)" }}/>
|
||||
|
||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 24, fontSize: 12, color: "var(--muted)" }}>
|
||||
<span>© 2026 LynkedUp Pro · Built for roofing. Built for you.</span>
|
||||
<div className="row gap-24">
|
||||
<a href="https://book.lynkeduppro.com/" style={{ color: "inherit", textDecoration: "none" }}>Privacy</a>
|
||||
<a href="https://book.lynkeduppro.com/" style={{ color: "inherit", textDecoration: "none" }}>Terms</a>
|
||||
<a href="https://book.lynkeduppro.com/" style={{ color: "inherit", textDecoration: "none" }}>Security</a>
|
||||
<span className="row gap-8"><span className="live-dot"/> All systems normal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------- Pricing / Lifetime Offer ------------
|
||||
function Pricing() {
|
||||
return (
|
||||
<section className="roofing-offer-section" id="pricing">
|
||||
<div className="container">
|
||||
|
||||
<div className="text-center roofing-heading mb-5">
|
||||
<span className="offer-badge">🔥 Limited Founder Offer</span>
|
||||
<h2>Own Your Roofing CRM. Pay Once.</h2>
|
||||
<p>Stop paying monthly for multiple disconnected tools. Get founders lifetime access
|
||||
to LynkedUpPro and scale your roofing business with one intelligent platform.</p>
|
||||
</div>
|
||||
|
||||
<div className="row g-4 align-items-stretch">
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="lifetime-card h-100">
|
||||
<div className="popular-tag">MOST POPULAR</div>
|
||||
<h3>Founders Lifetime Access</h3>
|
||||
<p className="small-text">One payment. Unlimited operational control.</p>
|
||||
<div className="price-box">
|
||||
<div className="price-anchor">
|
||||
<span className="price-was">$25,000</span>
|
||||
<span className="price-save">Save $23,000 · 92% off</span>
|
||||
</div>
|
||||
<h2>$2000</h2>
|
||||
<span>PER LICENSE • PAY ONCE • USE FOREVER</span>
|
||||
</div>
|
||||
<ul className="feature-list">
|
||||
<li>Complete Roofing CRM</li>
|
||||
<li>Lead Management System</li>
|
||||
<li>Canvassing & Field Tracking</li>
|
||||
<li>Insurance Workflow</li>
|
||||
<li>Proposal & Inspection Tools</li>
|
||||
<li>Team Management Dashboard</li>
|
||||
<li>Unlimited Projects</li>
|
||||
<li>Future Feature Updates</li>
|
||||
<li>Priority Support</li>
|
||||
</ul>
|
||||
<a href="https://book.lynkeduppro.com/" className="deal-btn">Claim Founders Lifetime Deal</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-6">
|
||||
<div className="roofing-info-card h-100">
|
||||
<span className="mini-badge">Why Roofers Choose LynkedUpPro</span>
|
||||
<h3>Replace 5 Different Tools With One Roofing OS</h3>
|
||||
<p className="info-para">Most roofing companies spend thousands yearly on disconnected
|
||||
CRMs, canvassing apps, spreadsheets, proposal software, and field coordination tools.</p>
|
||||
<div className="info-box">
|
||||
<h5>More Revenue Visibility</h5>
|
||||
<p>Track leads, conversions, and team performance from one dashboard.</p>
|
||||
</div>
|
||||
<div className="info-box">
|
||||
<h5>Less Operational Chaos</h5>
|
||||
<p>Eliminate scattered communication and disconnected workflows.</p>
|
||||
</div>
|
||||
<div className="info-box">
|
||||
<h5>Built for Roofing Teams</h5>
|
||||
<p>Purpose-built for storm restoration, canvassing, insurance, and roofing operations.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="terms-wrapper mt-4">
|
||||
<details className="custom-accordion-item">
|
||||
<summary className="accordion-button">Deal Terms & Conditions</summary>
|
||||
<div className="accordion-body">
|
||||
<div className="deal-terms-legal" style={{ fontSize: 12, lineHeight: 1.65, color: "#444" }}>
|
||||
<p>All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p>Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p>The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p>SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p>Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p style={{ marginBottom: 0 }}>Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="roofing-cta-box mt-5 text-center">
|
||||
<h2>Roofing Moves Fast. Your CRM Should Too.</h2>
|
||||
<p>Join the next generation of roofing companies scaling with automation,
|
||||
field intelligence, and operational visibility.</p>
|
||||
<div className="cta-btn-group">
|
||||
<a href="https://book.lynkeduppro.com/" className="start-btn">Start Founders Lifetime Access</a>
|
||||
<a href="https://book.lynkeduppro.com/" className="sales-btn">Book a demo</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.roofing-offer-section{
|
||||
padding:64px 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 35%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 35%),
|
||||
#1a120d;
|
||||
overflow:hidden; position:relative;
|
||||
}
|
||||
.roofing-offer-section .container{max-width:1120px;margin:0 auto;padding:0 20px;}
|
||||
.roofing-offer-section .row{display:flex;flex-wrap:wrap;margin:0 -12px;}
|
||||
.roofing-offer-section .row.g-4{margin:-12px;}
|
||||
.roofing-offer-section .row.g-4 > [class*="col-"]{padding:12px;}
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 50%;max-width:50%;padding:0 12px;}
|
||||
.roofing-offer-section .align-items-stretch{align-items:stretch;}
|
||||
.roofing-offer-section .h-100{height:100%;}
|
||||
.roofing-offer-section .text-center{text-align:center;}
|
||||
.roofing-offer-section .mb-5{margin-bottom:36px;}
|
||||
.roofing-offer-section .mt-4{margin-top:20px;}
|
||||
.roofing-offer-section .mt-5{margin-top:36px;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:38px;font-weight:800;line-height:1.15;color:#fff;margin-top:14px;}
|
||||
.roofing-offer-section .roofing-heading p{color:#d7d7d7;max-width:620px;margin:12px auto 0;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .offer-badge{display:inline-block;padding:6px 14px;border-radius:40px;background:rgba(255,255,255,0.08);color:#fff;font-size:12px;font-weight:600;}
|
||||
.roofing-offer-section .lifetime-card{position:relative;background:linear-gradient(180deg,#12121b,#0d0d14);border-radius:20px;padding:28px;border:1px solid rgba(255,255,255,0.08);overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,0.35);}
|
||||
.roofing-offer-section .popular-tag{position:absolute;top:16px;right:-45px;background:linear-gradient(90deg,#ff8b1e,#53c7ff);color:#fff;font-size:10px;font-weight:700;padding:6px 50px;transform:rotate(40deg);}
|
||||
.roofing-offer-section .lifetime-card h3{color:#fff;font-size:22px;font-weight:700;margin:0;}
|
||||
.roofing-offer-section .small-text{color:#b7b7b7;font-size:13.5px;margin:4px 0 18px;}
|
||||
.roofing-offer-section .price-box h2{color:#fff;font-size:48px;font-weight:800;line-height:1;margin-bottom:4px;}
|
||||
.roofing-offer-section .price-box span{color:#8d8d8d;font-size:12px;letter-spacing:1px;}
|
||||
.roofing-offer-section .price-anchor{display:flex;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .price-was{position:relative;color:#c98f6a;font-size:22px;font-weight:700;text-decoration:line-through;text-decoration-color:rgba(255,147,33,.75);text-decoration-thickness:2px;}
|
||||
.roofing-offer-section .price-save{display:inline-block;padding:4px 10px;border-radius:999px;background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#0d0d0f;font-size:11px;font-weight:800;letter-spacing:.02em;white-space:nowrap;}
|
||||
.roofing-offer-section .feature-list{padding:0;margin:24px 0;list-style:none;}
|
||||
.roofing-offer-section .feature-list li{background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.05);border-radius:11px;padding:10px 14px;margin-bottom:9px;color:#fff;font-size:13.5px;position:relative;padding-left:42px;}
|
||||
.roofing-offer-section .feature-list li::before{content:"✓";position:absolute;left:14px;top:50%;transform:translateY(-50%);width:20px;height:20px;border-radius:50%;background:rgba(255,255,255,0.08);display:flex;align-items:center;justify-content:center;font-size:11px;}
|
||||
.roofing-offer-section .deal-btn{display:inline-block;padding:12px 24px;border-radius:12px;text-decoration:none;color:#fff;font-size:14px;font-weight:700;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,0.3);}
|
||||
.roofing-offer-section .roofing-info-card{border-radius:20px;padding:28px;background:linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.03)),linear-gradient(180deg,#d6892d,#0d99f1);backdrop-filter:blur(10px);color:#fff;}
|
||||
.roofing-offer-section .mini-badge{display:inline-block;background:rgba(255,255,255,0.2);padding:6px 14px;border-radius:40px;font-size:12px;font-weight:600;margin-bottom:16px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:26px;font-weight:800;line-height:1.2;margin-bottom:16px;}
|
||||
.roofing-offer-section .info-para{color:rgba(255,255,255,0.85);font-size:14px;line-height:1.55;margin-bottom:20px;}
|
||||
.roofing-offer-section .info-box{background:rgba(255,255,255,0.14);border:1px solid rgba(255,255,255,0.08);border-radius:14px;padding:16px;margin-bottom:12px;backdrop-filter:blur(8px);}
|
||||
.roofing-offer-section .info-box h5{font-size:16px;font-weight:700;margin:0 0 5px;}
|
||||
.roofing-offer-section .info-box p{margin:0;font-size:13.5px;line-height:1.5;color:rgba(255,255,255,0.9);}
|
||||
.roofing-offer-section .terms-wrapper .custom-accordion-item{background:#fff;border-radius:14px;overflow:hidden;border:none;}
|
||||
.roofing-offer-section summary.accordion-button{list-style:none;cursor:pointer;background:#fff;padding:16px 20px;font-size:15px;font-weight:700;color:#111;display:flex;justify-content:space-between;align-items:center;}
|
||||
.roofing-offer-section summary.accordion-button::-webkit-details-marker{display:none;}
|
||||
.roofing-offer-section .accordion-body{background:#fff;padding:8px 20px 20px;}
|
||||
.roofing-offer-section .accordion-body ul{margin:0;padding-left:20px;}
|
||||
.roofing-offer-section .accordion-body li{margin-bottom:10px;color:#444;font-size:13.5px;}
|
||||
.roofing-offer-section .deal-terms-list{list-style:none;padding-left:0;margin:0;}
|
||||
.roofing-offer-section .deal-terms-list li{margin-bottom:10px;color:#444;font-size:13.5px;line-height:1.5;}
|
||||
.roofing-offer-section .roofing-cta-box{padding:44px 32px;border-radius:22px;background:linear-gradient(90deg,rgba(255,140,0,0.25),rgba(20,150,255,0.22));backdrop-filter:blur(10px);}
|
||||
.roofing-offer-section .roofing-cta-box h2{color:#fff;font-size:32px;font-weight:800;line-height:1.2;margin-bottom:12px;}
|
||||
.roofing-offer-section .roofing-cta-box p{color:#d7d7d7;max-width:620px;margin:auto;font-size:15px;line-height:1.55;}
|
||||
.roofing-offer-section .cta-btn-group{margin-top:26px;display:flex;justify-content:center;gap:14px;flex-wrap:wrap;}
|
||||
.roofing-offer-section .start-btn,.roofing-offer-section .sales-btn{padding:12px 24px;border-radius:12px;text-decoration:none;font-size:14px;font-weight:700;transition:0.3s;}
|
||||
.roofing-offer-section .start-btn{background:linear-gradient(90deg,#ff9321,#4dc5ff);color:#fff;}
|
||||
.roofing-offer-section .sales-btn{background:rgba(255,255,255,0.08);color:#fff;border:1px solid rgba(255,255,255,0.1);}
|
||||
.roofing-offer-section .start-btn:hover,.roofing-offer-section .sales-btn:hover{transform:translateY(-3px);}
|
||||
@media (max-width:991px){
|
||||
.roofing-offer-section .col-lg-6{flex:0 0 100%;max-width:100%;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:32px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:28px;}
|
||||
.roofing-offer-section .price-box h2{font-size:44px;}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.roofing-offer-section{padding:48px 0;}
|
||||
.roofing-offer-section .roofing-heading h2{font-size:27px;}
|
||||
.roofing-offer-section .roofing-info-card h3{font-size:23px;}
|
||||
.roofing-offer-section .roofing-cta-box h2{font-size:24px;}
|
||||
.roofing-offer-section .lifetime-card,
|
||||
.roofing-offer-section .roofing-info-card,
|
||||
.roofing-offer-section .roofing-cta-box{padding:22px;}
|
||||
.roofing-offer-section .price-box h2{font-size:40px;}
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Analytics, FieldIntelligence, Testimonials, Pricing, FinalCTA, Footer });
|
||||
@@ -0,0 +1,642 @@
|
||||
// Multi-step checkout wizard for the "Claim Founders Lifetime Deal" button
|
||||
// (.deal-btn). Opens as a modal and walks the visitor through:
|
||||
//
|
||||
// Step 1 Registration - contact + company + email + phone + sales reps +
|
||||
// number of licenses (1-10) + business address (with
|
||||
// API autofill) + SMS consent. Submitting captures the
|
||||
// record server-side (POST /api/register) BEFORE any
|
||||
// branch, so no lead is ever lost.
|
||||
// Branch on license count:
|
||||
// <= 5 Step 2 Cart + Terms - order summary (licenses x $2,000), the Deal
|
||||
// Terms & Conditions, and an "I agree" checkbox. Then
|
||||
// "Continue to Secure Payment" -> POST /api/checkout ->
|
||||
// redirect to Stripe. After payment Stripe returns the
|
||||
// buyer to /thanks.
|
||||
// > 5 Sales screen - "you may be eligible for a special discount" +
|
||||
// "Talk to Sales". No online payment (bulk orders are
|
||||
// handled by the sales team).
|
||||
//
|
||||
// WHY A CUSTOM FORM AND NOT STRIPE'S PAGE:
|
||||
// Customers could complete an Apple Pay purchase without giving us any contact
|
||||
// details, so there was no way to onboard buyers or distribute licenses. Stripe
|
||||
// hosted Checkout can't fix this (no way to hide the Apple Pay express button;
|
||||
// custom_fields enforcement there is undocumented) and SMS consent legally needs
|
||||
// our own disclaimer text. Collecting first makes the payment method irrelevant.
|
||||
// The server re-validates everything (api/_lead.js) — this form is UX only.
|
||||
//
|
||||
// Event delegation is used so this works on the static pages and the React home
|
||||
// page (where the button mounts after this script loads).
|
||||
(function () {
|
||||
// ---------------------------------------------------------------------------
|
||||
// TODO(client): replace with the exact SMS consent disclaimer Justin provided.
|
||||
// PLACEHOLDER wording, no legal pass. When it changes, bump SMS_CONSENT_VERSION
|
||||
// in api/checkout.js AND api/register.js so the CRM records the agreed version.
|
||||
// ---------------------------------------------------------------------------
|
||||
var SMS_CONSENT_TEXT =
|
||||
"I agree to receive SMS messages from Lynked Up Technologies about my Founders " +
|
||||
"License, onboarding, and product updates at the phone number provided. Message " +
|
||||
"frequency varies. Message and data rates may apply. Reply STOP to opt out or HELP " +
|
||||
"for help.";
|
||||
|
||||
// TODO(client): confirm the sales inbox for >5-license (bulk) enquiries.
|
||||
var SALES_EMAIL = "sales@lynkeduppro.com";
|
||||
|
||||
var SALES_REP_BUCKETS = ["1", "2-5", "6-10", "11-25", "26-50", "51+"];
|
||||
|
||||
// DISPLAY ONLY — the server owns the real charge (api/checkout.js). Keep in sync.
|
||||
var UNIT_PRICE = 2000; // USD per license
|
||||
var MAX_LICENSES = 10; // dropdown ceiling; must match api/_lead.js MAX_LICENSES
|
||||
var SELF_SERVE_MAX = 5; // <= this pays online; above -> sales. Match api/_lead.js
|
||||
|
||||
// Deal Terms & Conditions shown on the cart step. Mirrors the accordion text on
|
||||
// the pricing sections. TODO(client): keep in sync with Justin's final legal T&C.
|
||||
var TERMS = [
|
||||
"All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.",
|
||||
"The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. It includes access to core CRM features and the platform features made available under the applicable license terms. It does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.",
|
||||
"SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges billed separately based on actual usage.",
|
||||
"Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, and premium automation, may require additional fees, setup costs, subscriptions, or usage-based billing.",
|
||||
"Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.",
|
||||
];
|
||||
|
||||
var FIELDS = [
|
||||
{ name: "firstName", label: "First name", type: "text", autocomplete: "given-name", placeholder: "Jane" },
|
||||
{ name: "lastName", label: "Last name", type: "text", autocomplete: "family-name", placeholder: "Smith" },
|
||||
{ name: "companyName", label: "Company name", type: "text", autocomplete: "organization", placeholder: "Smith Roofing LLC" },
|
||||
{ name: "email", label: "Email address", type: "email", autocomplete: "email", placeholder: "jane@smithroofing.com" },
|
||||
{ name: "phone", label: "Phone number", type: "tel", autocomplete: "tel", placeholder: "(555) 010-9999" },
|
||||
{ name: "salesReps", label: "Number of sales reps", type: "select", autocomplete: "off", options: SALES_REP_BUCKETS, empty: "Select…" },
|
||||
{ name: "licenses", label: "Number of licenses", type: "select", autocomplete: "off", options: licenseOptions(), value: "1" },
|
||||
// Street is full-width with autocomplete; selecting a suggestion fills the
|
||||
// city / state / zip fields below (which sit in their own 3-column row).
|
||||
{ name: "streetAddress", label: "Street address", type: "text", autocomplete: "off", full: true, placeholder: "Start typing your address…" },
|
||||
{ name: "city", label: "City", type: "text", autocomplete: "off", col3: true, placeholder: "Plano" },
|
||||
{ name: "state", label: "State", type: "text", autocomplete: "off", col3: true, placeholder: "TX" },
|
||||
{ name: "zip", label: "ZIP code", type: "text", autocomplete: "off", col3: true, placeholder: "75074" },
|
||||
];
|
||||
|
||||
function licenseOptions() {
|
||||
var a = [];
|
||||
for (var i = 1; i <= 10; i++) a.push(String(i));
|
||||
return a;
|
||||
}
|
||||
|
||||
var modal = null; // overlay element (built once)
|
||||
var body = null; // the step container inside the modal
|
||||
var activeBtn = null; // the .deal-btn that opened the modal
|
||||
var lastFocused = null; // element to restore focus to on close
|
||||
// plan: null = normal paid ($2,000/license). "demo" = fixed $1 Stripe test.
|
||||
// "free" = $0 no-card demo (page/19.html): runs the whole flow but skips Stripe
|
||||
// entirely and lands on /thanks, so the team can demo it without a card.
|
||||
var state = { step: "form", values: null, plan: null };
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
function money(n) {
|
||||
return "$" + Number(n).toLocaleString("en-US");
|
||||
}
|
||||
function digitCount(v) {
|
||||
return (String(v).match(/\d/g) || []).length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation. Mirrors api/_lead.js (server is authoritative; this is for UX).
|
||||
// ---------------------------------------------------------------------------
|
||||
function validate(values) {
|
||||
var errors = {};
|
||||
if (values.firstName.length < 1) errors.firstName = "Please enter your first name.";
|
||||
if (values.lastName.length < 1) errors.lastName = "Please enter your last name.";
|
||||
if (values.companyName.length < 2) errors.companyName = "Please enter your company name.";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(values.email)) errors.email = "Please enter a valid email address.";
|
||||
var d = digitCount(values.phone);
|
||||
if (d < 10 || d > 15) errors.phone = "Please enter a valid phone number.";
|
||||
if (SALES_REP_BUCKETS.indexOf(values.salesReps) === -1) errors.salesReps = "Please select how many sales reps you have.";
|
||||
var lic = Number(values.licenses);
|
||||
if (!Number.isInteger(lic) || lic < 1 || lic > MAX_LICENSES) errors.licenses = "Please choose 1 to " + MAX_LICENSES + " licenses.";
|
||||
if (values.streetAddress.length < 3) errors.streetAddress = "Please enter your street address.";
|
||||
if (values.city.length < 2) errors.city = "Please enter your city.";
|
||||
if (values.state.length < 2) errors.state = "Please enter your state.";
|
||||
if (!/^\d{5}(-\d{4})?$/.test(values.zip)) errors.zip = "Please enter a valid ZIP code.";
|
||||
if (!values.smsConsent) errors.smsConsent = "Please agree to receive SMS updates to continue.";
|
||||
return errors;
|
||||
}
|
||||
|
||||
function readFormValues() {
|
||||
function val(n) {
|
||||
var el = body.querySelector('[name="' + n + '"]');
|
||||
return el ? String(el.value || "").trim() : "";
|
||||
}
|
||||
return {
|
||||
firstName: val("firstName"),
|
||||
lastName: val("lastName"),
|
||||
companyName: val("companyName"),
|
||||
email: val("email").toLowerCase(),
|
||||
phone: val("phone"),
|
||||
salesReps: val("salesReps"),
|
||||
licenses: parseInt(val("licenses"), 10) || 1,
|
||||
streetAddress: val("streetAddress"),
|
||||
city: val("city"),
|
||||
state: val("state"),
|
||||
zip: val("zip"),
|
||||
smsConsent: body.querySelector('[name="smsConsent"]').checked,
|
||||
};
|
||||
}
|
||||
|
||||
function showErrors(errors) {
|
||||
var names = FIELDS.map(function (f) { return f.name; }).concat(["smsConsent"]);
|
||||
var first = null;
|
||||
names.forEach(function (name) {
|
||||
var msgEl = body.querySelector('[data-err="' + name + '"]');
|
||||
var input = body.querySelector('[name="' + name + '"]');
|
||||
var msg = errors[name];
|
||||
if (msgEl) msgEl.textContent = msg || "";
|
||||
if (input) {
|
||||
input.setAttribute("aria-invalid", msg ? "true" : "false");
|
||||
if (msg && !first) first = input;
|
||||
}
|
||||
});
|
||||
if (first && first.focus) first.focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Address autocomplete. Provider: Photon (photon.komoot.io) — OpenStreetMap,
|
||||
// FREE, no API key, CORS-enabled. Chosen because no provider key was supplied.
|
||||
// TO SWAP FOR GOOGLE PLACES: replace geocode()'s body so it returns [{label}].
|
||||
// ---------------------------------------------------------------------------
|
||||
function geocode(query) {
|
||||
var url = "https://photon.komoot.io/api/?limit=5&lang=en&q=" + encodeURIComponent(query);
|
||||
return fetch(url)
|
||||
.then(function (r) { return r.ok ? r.json() : { features: [] }; })
|
||||
.then(function (data) {
|
||||
return (data.features || []).map(function (f) {
|
||||
var p = f.properties || {};
|
||||
var street = [p.housenumber, p.street].filter(Boolean).join(" ") ||
|
||||
(p.name && p.name !== (p.city || p.town) ? p.name : "");
|
||||
var city = p.city || p.town || p.village || p.county || "";
|
||||
var state = p.state || "";
|
||||
var zip = p.postcode || "";
|
||||
// Structured components used to fill the separate fields on select.
|
||||
var comp = { street: street, city: city, state: state, zip: zip };
|
||||
var label = [street, city, [state, zip].filter(Boolean).join(" "), p.country]
|
||||
.filter(Boolean).join(", ");
|
||||
comp.label = label;
|
||||
return comp;
|
||||
}).filter(function (s) { return s.label; });
|
||||
})
|
||||
.catch(function () { return []; });
|
||||
}
|
||||
|
||||
// Fill the street field plus city/state/zip from a chosen suggestion, and
|
||||
// clear their error messages.
|
||||
function fillAddress(comp) {
|
||||
var map = { streetAddress: comp.street, city: comp.city, state: comp.state, zip: comp.zip };
|
||||
Object.keys(map).forEach(function (name) {
|
||||
var el = body.querySelector('[name="' + name + '"]');
|
||||
if (el && map[name]) {
|
||||
el.value = map[name];
|
||||
el.setAttribute("aria-invalid", "false");
|
||||
}
|
||||
var msg = body.querySelector('[data-err="' + name + '"]');
|
||||
if (msg && map[name]) msg.textContent = "";
|
||||
});
|
||||
}
|
||||
|
||||
function attachAddressAutocomplete(input) {
|
||||
var box = document.createElement("ul");
|
||||
box.className = "lu-ac";
|
||||
box.setAttribute("hidden", "");
|
||||
input.parentNode.appendChild(box);
|
||||
var timer = null, lastQuery = "";
|
||||
|
||||
function hide() { box.setAttribute("hidden", ""); box.innerHTML = ""; }
|
||||
function render(items) {
|
||||
box.innerHTML = "";
|
||||
if (!items.length) { hide(); return; }
|
||||
items.forEach(function (it) {
|
||||
var li = document.createElement("li");
|
||||
li.className = "lu-ac-item";
|
||||
li.textContent = it.label;
|
||||
li.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
// Prefer the parsed street; fall back to the full label if none.
|
||||
input.value = it.street || it.label;
|
||||
fillAddress(it);
|
||||
hide();
|
||||
});
|
||||
box.appendChild(li);
|
||||
});
|
||||
box.removeAttribute("hidden");
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
if (timer) clearTimeout(timer);
|
||||
if (q.length < 4) { hide(); return; }
|
||||
timer = setTimeout(function () {
|
||||
if (q === lastQuery) return;
|
||||
lastQuery = q;
|
||||
geocode(q).then(function (items) { if (input.value.trim() === q) render(items); });
|
||||
}, 250);
|
||||
});
|
||||
input.addEventListener("blur", function () { setTimeout(hide, 120); });
|
||||
input.addEventListener("keydown", function (e) { if (e.key === "Escape") hide(); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function fieldHtml(f) {
|
||||
var id = "lu-f-" + f.name;
|
||||
var common = 'id="' + id + '" name="' + f.name + '" autocomplete="' + f.autocomplete +
|
||||
'" aria-describedby="lu-e-' + f.name + '" class="lu-input"';
|
||||
var control;
|
||||
if (f.type === "select") {
|
||||
var opts = (f.empty ? '<option value="">' + esc(f.empty) + "</option>" : "") +
|
||||
f.options.map(function (o) {
|
||||
var sel = f.value === o ? " selected" : "";
|
||||
return '<option value="' + esc(o) + '"' + sel + ">" + esc(o) + "</option>";
|
||||
}).join("");
|
||||
control = "<select " + common + ">" + opts + "</select>";
|
||||
} else {
|
||||
control = "<input " + common + ' type="' + f.type + '" placeholder="' + esc(f.placeholder || "") + '" />';
|
||||
}
|
||||
return (
|
||||
'<div class="lu-field' + (f.full ? " lu-field-full" : "") + '">' +
|
||||
'<label class="lu-label" for="' + id + '">' + esc(f.label) + ' <span class="lu-req">*</span></label>' +
|
||||
control +
|
||||
'<div class="lu-err" id="lu-e-' + f.name + '" data-err="' + f.name + '" aria-live="polite"></div>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — registration form
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderForm() {
|
||||
state.step = "form";
|
||||
body.innerHTML =
|
||||
'<h2 class="lu-title">Claim your Founders License</h2>' +
|
||||
'<p class="lu-sub">Tell us a bit about your business so we can set up your account and licenses.</p>' +
|
||||
'<form class="lu-form" novalidate>' +
|
||||
'<div class="lu-grid">' + FIELDS.filter(function (f) { return !f.col3; }).map(fieldHtml).join("") + "</div>" +
|
||||
'<div class="lu-grid3">' + FIELDS.filter(function (f) { return f.col3; }).map(fieldHtml).join("") + "</div>" +
|
||||
'<div class="lu-subtotal" data-subtotal></div>' +
|
||||
'<div class="lu-consent">' +
|
||||
'<label class="lu-check"><input type="checkbox" name="smsConsent" aria-describedby="lu-e-smsConsent" />' +
|
||||
'<span class="lu-consent-text">' + esc(SMS_CONSENT_TEXT) + "</span></label>" +
|
||||
'<div class="lu-err" data-err="smsConsent" id="lu-e-smsConsent" aria-live="polite"></div>' +
|
||||
"</div>" +
|
||||
'<div class="lu-form-err" data-form-err aria-live="polite"></div>' +
|
||||
'<button type="submit" class="lu-submit">Continue</button>' +
|
||||
'<p class="lu-fine">All fields are required. Next you\'ll review your order.</p>' +
|
||||
"</form>";
|
||||
|
||||
// Restore previously entered values if the buyer stepped back.
|
||||
if (state.values) {
|
||||
Object.keys(state.values).forEach(function (k) {
|
||||
var el = body.querySelector('[name="' + k + '"]');
|
||||
if (!el) return;
|
||||
if (el.type === "checkbox") el.checked = !!state.values[k];
|
||||
else el.value = state.values[k];
|
||||
});
|
||||
}
|
||||
|
||||
body.querySelector("form").addEventListener("submit", onFormSubmit);
|
||||
var addr = body.querySelector('[name="streetAddress"]');
|
||||
if (addr) attachAddressAutocomplete(addr);
|
||||
var lic = body.querySelector('[name="licenses"]');
|
||||
if (lic) { lic.addEventListener("change", updateSubtotal); }
|
||||
updateSubtotal();
|
||||
focusFirst('[name="firstName"]');
|
||||
}
|
||||
|
||||
function updateSubtotal() {
|
||||
var el = body.querySelector("[data-subtotal]");
|
||||
var lic = body.querySelector('[name="licenses"]');
|
||||
if (!el || !lic) return;
|
||||
var n = parseInt(lic.value, 10) || 1;
|
||||
if (n > SELF_SERVE_MAX) {
|
||||
el.innerHTML = '<span class="lu-subtotal-note">' + n + " licenses — you may qualify for special founder pricing. We'll take your details next.</span>";
|
||||
} else {
|
||||
el.innerHTML = "<span>" + n + " × " + money(UNIT_PRICE) + "</span><strong>" + money(UNIT_PRICE * n) + "</strong>";
|
||||
}
|
||||
}
|
||||
|
||||
function onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
var values = readFormValues();
|
||||
var errors = validate(values);
|
||||
body.querySelector("[data-form-err]").textContent = "";
|
||||
showErrors(errors);
|
||||
if (Object.keys(errors).length) return;
|
||||
state.values = values;
|
||||
|
||||
setSubmitting(true, "Saving your details…");
|
||||
// Capture the record server-side FIRST (both branches), then route.
|
||||
fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||||
.then(function (res) {
|
||||
setSubmitting(false);
|
||||
if (!res.ok) {
|
||||
if (res.data && res.data.fields) { showErrors(res.data.fields); return; }
|
||||
throw new Error((res.data && res.data.error) || "Could not save your details.");
|
||||
}
|
||||
var path = (res.data && res.data.path) || (values.licenses > SELF_SERVE_MAX ? "sales" : "checkout");
|
||||
if (path === "sales") renderSales();
|
||||
else renderCart();
|
||||
})
|
||||
.catch(function (err) {
|
||||
setSubmitting(false);
|
||||
console.error("Register error:", err);
|
||||
var fe = body.querySelector("[data-form-err]");
|
||||
if (fe) fe.textContent = "Sorry, something went wrong. Please try again in a moment.";
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — cart + terms (<= SELF_SERVE_MAX licenses)
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderCart() {
|
||||
state.step = "cart";
|
||||
var n = state.values.licenses;
|
||||
var free = state.plan === "free";
|
||||
var unit = free ? 0 : UNIT_PRICE;
|
||||
var total = unit * n;
|
||||
body.innerHTML =
|
||||
'<button type="button" class="lu-back" data-back>← Back</button>' +
|
||||
'<h2 class="lu-title">' + (free ? "Review your demo" : "Review your order") + "</h2>" +
|
||||
'<p class="lu-sub">' +
|
||||
(free
|
||||
? "Demo access — no payment and no card required."
|
||||
: "Founders Lifetime License — one-time payment, use forever.") +
|
||||
"</p>" +
|
||||
(free ? '<div class="lu-demo-flag">🎬 Demo mode · $0 · no card</div>' : "") +
|
||||
'<div class="lu-cart">' +
|
||||
'<div class="lu-cart-row"><span>Founders Lifetime License' + (free ? " (demo)" : "") + "</span><span>" + money(unit) + " × " + n + "</span></div>" +
|
||||
'<div class="lu-cart-line"></div>' +
|
||||
'<div class="lu-cart-row lu-cart-total"><span>Total today</span><span>' + money(total) + "</span></div>" +
|
||||
'<div class="lu-cart-sub">' + (free ? "Demo · no charge" : "One-time payment") + " · " + n + (n > 1 ? " licenses" : " license") + "</div>" +
|
||||
"</div>" +
|
||||
'<div class="lu-terms-label">Deal Terms & Conditions</div>' +
|
||||
'<div class="lu-terms">' + TERMS.map(function (p) { return "<p>" + esc(p) + "</p>"; }).join("") + "</div>" +
|
||||
'<div class="lu-consent">' +
|
||||
'<label class="lu-check"><input type="checkbox" name="termsAgree" />' +
|
||||
'<span class="lu-consent-text">I have read and agree to the Deal Terms & Conditions above.</span></label>' +
|
||||
'<div class="lu-err" data-err="termsAgree" aria-live="polite"></div>' +
|
||||
"</div>" +
|
||||
'<div class="lu-form-err" data-form-err aria-live="polite"></div>' +
|
||||
'<button type="button" class="lu-submit" data-pay>' +
|
||||
(free ? "Complete demo" : "Continue to Secure Payment") + "</button>" +
|
||||
'<p class="lu-fine">' +
|
||||
(free
|
||||
? "No card needed — this is a demo of the full flow."
|
||||
: "You'll be redirected to Stripe to complete payment securely.") +
|
||||
"</p>";
|
||||
|
||||
body.querySelector("[data-back]").addEventListener("click", renderForm);
|
||||
body.querySelector("[data-pay]").addEventListener("click", onPay);
|
||||
focusFirst("[data-pay]");
|
||||
}
|
||||
|
||||
function onPay() {
|
||||
var agree = body.querySelector('[name="termsAgree"]');
|
||||
var errEl = body.querySelector('[data-err="termsAgree"]');
|
||||
if (!agree.checked) {
|
||||
if (errEl) errEl.textContent = "Please agree to the terms to continue.";
|
||||
agree.focus();
|
||||
return;
|
||||
}
|
||||
if (errEl) errEl.textContent = "";
|
||||
|
||||
// $0 no-card demo (page/19.html): the record was already captured at Step 1,
|
||||
// so just land on the Thanks page — no Stripe, no card. Full flow, zero cost.
|
||||
if (state.plan === "free") {
|
||||
setSubmitting(true, "Finishing demo…", "[data-pay]");
|
||||
window.location.href = "/thanks?demo=1";
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true, "Loading checkout…", "[data-pay]");
|
||||
|
||||
var plan = activeBtn ? activeBtn.getAttribute("data-plan") : null;
|
||||
var promoEl = document.querySelector("[data-promo-input]");
|
||||
var promo = promoEl && promoEl.value ? promoEl.value.trim() : "";
|
||||
var qs = [];
|
||||
if (plan) qs.push("plan=" + encodeURIComponent(plan));
|
||||
if (promo) qs.push("promo=" + encodeURIComponent(promo));
|
||||
var endpoint = "/api/checkout" + (qs.length ? "?" + qs.join("&") : "");
|
||||
|
||||
var payload = Object.assign({}, state.values, { termsAgreed: true });
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, data: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok && res.data && res.data.url) { window.location.href = res.data.url; return; }
|
||||
// Server guard: bulk orders route to sales even if we reach here.
|
||||
if (res.data && res.data.path === "sales") { renderSales(); return; }
|
||||
setSubmitting(false, null, "[data-pay]");
|
||||
var fe = body.querySelector("[data-form-err]");
|
||||
if (fe) fe.textContent = (res.data && res.data.error) || "We couldn't start checkout. Please try again.";
|
||||
})
|
||||
.catch(function (err) {
|
||||
setSubmitting(false, null, "[data-pay]");
|
||||
console.error("Checkout error:", err);
|
||||
var fe = body.querySelector("[data-form-err]");
|
||||
if (fe) fe.textContent = "Sorry, we couldn't start the checkout. Please try again in a moment.";
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sales screen (> SELF_SERVE_MAX licenses)
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildMailto() {
|
||||
var v = state.values || {};
|
||||
var name = [v.firstName, v.lastName].filter(Boolean).join(" ");
|
||||
var address = [v.streetAddress, v.city, [v.state, v.zip].filter(Boolean).join(" ")]
|
||||
.filter(Boolean).join(", ");
|
||||
var subject = "Founders License — bulk order (" + (v.licenses || "") + " licenses)";
|
||||
var lines = [
|
||||
"Hi LynkedUp Pro team,",
|
||||
"",
|
||||
"I'd like to discuss a bulk Founders License order.",
|
||||
"",
|
||||
"Name: " + name,
|
||||
"Company: " + (v.companyName || ""),
|
||||
"Email: " + (v.email || ""),
|
||||
"Phone: " + (v.phone || ""),
|
||||
"Sales reps: " + (v.salesReps || ""),
|
||||
"Licenses wanted: " + (v.licenses || ""),
|
||||
"Business address: " + address,
|
||||
];
|
||||
return "mailto:" + SALES_EMAIL + "?subject=" + encodeURIComponent(subject) + "&body=" + encodeURIComponent(lines.join("\n"));
|
||||
}
|
||||
|
||||
function renderSales() {
|
||||
state.step = "sales";
|
||||
var n = state.values.licenses;
|
||||
body.innerHTML =
|
||||
'<button type="button" class="lu-back" data-back>← Back</button>' +
|
||||
'<div class="lu-celebrate">🎉</div>' +
|
||||
'<h2 class="lu-title lu-center">Congratulations — you may be eligible for a special discount</h2>' +
|
||||
'<p class="lu-sub lu-center">You\'re looking at <strong>' + n + " licenses</strong>. Orders above " + SELF_SERVE_MAX +
|
||||
" licenses qualify for special founder pricing that isn't available online. Your details are saved — our team will reach out, or you can start the conversation now." +
|
||||
"</p>" +
|
||||
'<a class="lu-submit lu-link-btn" href="' + buildMailto() + '">Talk to Sales</a>' +
|
||||
'<p class="lu-fine">Prefer fewer licenses? Go back and choose ' + SELF_SERVE_MAX + " or fewer to check out instantly.</p>";
|
||||
body.querySelector("[data-back]").addEventListener("click", renderForm);
|
||||
focusFirst(".lu-link-btn");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared modal chrome
|
||||
// ---------------------------------------------------------------------------
|
||||
function focusFirst(sel) {
|
||||
var el = body.querySelector(sel);
|
||||
if (el && el.focus) el.focus();
|
||||
}
|
||||
|
||||
function setSubmitting(on, label, sel) {
|
||||
var b = body.querySelector(sel || ".lu-submit");
|
||||
if (!b) return;
|
||||
b.disabled = !!on;
|
||||
if (on) { b.dataset.orig = b.dataset.orig || b.textContent; b.textContent = label || "Loading…"; }
|
||||
else if (b.dataset.orig) b.textContent = b.dataset.orig;
|
||||
}
|
||||
|
||||
var CSS =
|
||||
".lu-overlay{position:fixed;inset:0;z-index:2147483000;background:rgba(6,6,10,.78);backdrop-filter:blur(4px);display:flex;align-items:flex-start;justify-content:center;padding:24px 16px;overflow-y:auto;}" +
|
||||
".lu-overlay[hidden]{display:none;}" +
|
||||
".lu-modal{position:relative;width:100%;max-width:560px;margin:auto;background:#121216;border:1px solid rgba(255,255,255,.10);border-radius:18px;padding:28px;box-shadow:0 30px 80px rgba(0,0,0,.6);font-family:inherit;}" +
|
||||
".lu-close{position:absolute;top:14px;right:16px;width:32px;height:32px;border:0;border-radius:8px;background:rgba(255,255,255,.06);color:#fff;font-size:20px;line-height:1;cursor:pointer;z-index:2;}" +
|
||||
".lu-close:hover{background:rgba(255,255,255,.12);}" +
|
||||
".lu-back{background:none;border:0;color:#9a9aa2;font-size:13px;font-family:inherit;cursor:pointer;padding:0;margin:0 0 12px;}" +
|
||||
".lu-back:hover{color:#fff;}" +
|
||||
".lu-title{margin:0 0 6px;color:#fff;font-size:22px;font-weight:800;letter-spacing:-.01em;line-height:1.25;}" +
|
||||
".lu-center{text-align:center;}" +
|
||||
".lu-sub{margin:0 0 20px;color:#9a9aa2;font-size:13.5px;line-height:1.55;}" +
|
||||
".lu-sub strong{color:#fff;}" +
|
||||
".lu-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;}" +
|
||||
".lu-field-full{grid-column:1/-1;position:relative;}" +
|
||||
".lu-grid3{display:grid;grid-template-columns:2fr 1fr 1fr;gap:14px;margin-top:14px;}" +
|
||||
"@media(max-width:560px){.lu-grid3{grid-template-columns:1fr 1fr;}}" +
|
||||
".lu-label{display:block;margin-bottom:6px;color:#e6e6ea;font-size:12.5px;font-weight:600;}" +
|
||||
".lu-req{color:#ff9321;}" +
|
||||
".lu-input{width:100%;box-sizing:border-box;padding:11px 13px;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#fff;font-size:14px;font-family:inherit;outline:none;transition:border-color .15s,box-shadow .15s;}" +
|
||||
".lu-input::placeholder{color:#6f6f78;}" +
|
||||
".lu-input:focus{border-color:#4dc5ff;box-shadow:0 0 0 3px rgba(77,197,255,.15);}" +
|
||||
'.lu-input[aria-invalid="true"]{border-color:#ff5f56;}' +
|
||||
"select.lu-input{appearance:none;cursor:pointer;}select.lu-input option{background:#121216;color:#fff;}" +
|
||||
".lu-ac{position:absolute;left:0;right:0;top:100%;margin:4px 0 0;padding:4px;list-style:none;z-index:5;background:#1b1b21;border:1px solid rgba(255,255,255,.14);border-radius:10px;box-shadow:0 18px 40px rgba(0,0,0,.5);max-height:220px;overflow-y:auto;}" +
|
||||
".lu-ac[hidden]{display:none;}" +
|
||||
".lu-ac-item{padding:9px 11px;border-radius:7px;color:#e6e6ea;font-size:13px;cursor:pointer;}" +
|
||||
".lu-ac-item:hover{background:rgba(77,197,255,.14);}" +
|
||||
".lu-subtotal{display:flex;align-items:center;justify-content:space-between;margin:14px 0 2px;padding:10px 14px;border-radius:10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);color:#c9c9d2;font-size:13px;}" +
|
||||
".lu-subtotal strong{color:#fff;font-size:16px;}" +
|
||||
".lu-subtotal-note{color:#ffb066;font-size:12.5px;line-height:1.4;}" +
|
||||
".lu-cart{margin:0 0 18px;padding:18px;border-radius:14px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.09);}" +
|
||||
".lu-cart-row{display:flex;align-items:center;justify-content:space-between;color:#e6e6ea;font-size:14px;}" +
|
||||
".lu-cart-line{height:1px;background:rgba(255,255,255,.10);margin:14px 0;}" +
|
||||
".lu-cart-total{font-weight:800;}" +
|
||||
".lu-cart-total span:last-child{font-size:24px;color:#fff;}" +
|
||||
".lu-cart-sub{color:#8a8a93;font-size:11.5px;margin-top:6px;}" +
|
||||
".lu-terms-label{color:#e6e6ea;font-size:12.5px;font-weight:700;margin:0 0 6px;}" +
|
||||
".lu-terms{max-height:150px;overflow-y:auto;padding:12px 14px;border-radius:10px;background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.08);color:#9a9aa2;font-size:11.5px;line-height:1.6;}" +
|
||||
".lu-terms p{margin:0 0 9px;}.lu-terms p:last-child{margin:0;}" +
|
||||
".lu-consent{margin:16px 0 4px;}" +
|
||||
".lu-check{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}" +
|
||||
".lu-check input{margin-top:2px;width:16px;height:16px;flex:0 0 auto;accent-color:#4dc5ff;cursor:pointer;}" +
|
||||
".lu-consent-text{color:#9a9aa2;font-size:11.5px;line-height:1.5;}" +
|
||||
".lu-form-err{color:#ff6b61;font-size:12.5px;min-height:16px;margin:6px 0;}" +
|
||||
".lu-err{color:#ff6b61;font-size:11.5px;margin-top:4px;min-height:14px;}" +
|
||||
".lu-submit{display:block;width:100%;margin-top:8px;padding:14px 24px;border:0;border-radius:12px;color:#fff;font-size:15px;font-weight:700;font-family:inherit;cursor:pointer;text-align:center;text-decoration:none;box-sizing:border-box;background:linear-gradient(90deg,#ff9321,#4dc5ff);box-shadow:0 10px 25px rgba(0,0,0,.3);}" +
|
||||
".lu-submit:disabled{opacity:.6;cursor:default;}" +
|
||||
".lu-link-btn{margin-top:14px;}" +
|
||||
".lu-demo-flag{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;background:rgba(77,197,255,.14);border:1px solid rgba(77,197,255,.35);color:#8fd6ff;font-size:11.5px;font-weight:700;}" +
|
||||
".lu-celebrate{font-size:44px;text-align:center;margin:4px 0 10px;}" +
|
||||
".lu-fine{margin:12px 0 0;text-align:center;color:#6f6f78;font-size:11px;}" +
|
||||
"@media(max-width:560px){.lu-modal{padding:22px 18px;}.lu-grid{grid-template-columns:1fr;}}";
|
||||
|
||||
function injectCss() {
|
||||
if (document.getElementById("lu-checkout-css")) return;
|
||||
var s = document.createElement("style");
|
||||
s.id = "lu-checkout-css";
|
||||
s.textContent = CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function build() {
|
||||
var el = document.createElement("div");
|
||||
el.className = "lu-overlay";
|
||||
el.setAttribute("hidden", "");
|
||||
el.innerHTML =
|
||||
'<div class="lu-modal" role="dialog" aria-modal="true">' +
|
||||
'<button type="button" class="lu-close" aria-label="Close">×</button>' +
|
||||
'<div class="lu-body"></div>' +
|
||||
"</div>";
|
||||
el.addEventListener("click", function (e) { if (e.target === el) close(); });
|
||||
el.querySelector(".lu-close").addEventListener("click", close);
|
||||
document.body.appendChild(el);
|
||||
body = el.querySelector(".lu-body");
|
||||
return el;
|
||||
}
|
||||
|
||||
function open(btn) {
|
||||
injectCss();
|
||||
if (!modal) modal = build();
|
||||
activeBtn = btn;
|
||||
lastFocused = document.activeElement;
|
||||
// keep prior entries if reopened; read the plan off the clicked button
|
||||
state = { step: "form", values: state.values, plan: btn.getAttribute("data-plan") };
|
||||
modal.removeAttribute("hidden");
|
||||
document.body.style.overflow = "hidden";
|
||||
renderForm();
|
||||
document.addEventListener("keydown", onKeydown, true);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!modal || modal.hasAttribute("hidden")) return;
|
||||
modal.setAttribute("hidden", "");
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", onKeydown, true);
|
||||
if (lastFocused && lastFocused.focus) lastFocused.focus();
|
||||
activeBtn = null;
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === "Escape") { close(); return; }
|
||||
if (e.key !== "Tab") return;
|
||||
var f = modal.querySelectorAll("button, input, select, textarea, a[href]");
|
||||
var list = [];
|
||||
for (var i = 0; i < f.length; i++) if (!f[i].disabled && f[i].offsetParent !== null) list.push(f[i]);
|
||||
if (!list.length) return;
|
||||
var firstEl = list[0], lastEl = list[list.length - 1];
|
||||
if (e.shiftKey && document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); }
|
||||
}
|
||||
|
||||
// Re-enable a stuck button after a bfcache restore (e.g. back from Stripe).
|
||||
window.addEventListener("pageshow", function (e) {
|
||||
if (e.persisted && body) { var b = body.querySelector(".lu-submit"); if (b) { b.disabled = false; if (b.dataset.orig) b.textContent = b.dataset.orig; } }
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
function (e) {
|
||||
var target = e.target;
|
||||
var btn = target && target.closest ? target.closest(".deal-btn") : null;
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
open(btn);
|
||||
},
|
||||
true
|
||||
);
|
||||
})();
|
||||
@@ -0,0 +1,823 @@
|
||||
/* LynkedUp Pro — Cinematic Neon Design System */
|
||||
|
||||
:root {
|
||||
--bg: #03060c;
|
||||
--bg-2: #060c18;
|
||||
--bg-3: #0a1326;
|
||||
--ink: #e8f4ff;
|
||||
--ink-2: #b6c8df;
|
||||
--muted: rgba(232, 244, 255, 0.55);
|
||||
--dim: rgba(232, 244, 255, 0.35);
|
||||
|
||||
--cyan: #00d1ff;
|
||||
--cyan-2: #38e6ff;
|
||||
--cyan-deep: #0070a0;
|
||||
--orange: #ff8a1e;
|
||||
--orange-2: #ffb24d;
|
||||
--green: #2bd49c;
|
||||
--violet: #8a6bff;
|
||||
--magenta: #ff5cf0;
|
||||
|
||||
--glass: rgba(10, 22, 40, 0.55);
|
||||
--glass-2: rgba(10, 22, 40, 0.35);
|
||||
--border: rgba(0, 209, 255, 0.18);
|
||||
--border-strong: rgba(0, 209, 255, 0.42);
|
||||
|
||||
--r-sm: 10px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 22px;
|
||||
--r-xl: 32px;
|
||||
|
||||
--shadow-cyan: 0 0 0 1px rgba(0, 209, 255, 0.25), 0 12px 60px -10px rgba(0, 209, 255, 0.45);
|
||||
--shadow-cyan-soft: 0 0 0 1px rgba(0, 209, 255, 0.12), 0 18px 80px -20px rgba(0, 209, 255, 0.35);
|
||||
|
||||
--font-display: "Space Grotesk", "Satoshi", "Inter", system-ui, sans-serif;
|
||||
--font-body: "Inter", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Page-wide animated backdrop --------------------------------- */
|
||||
.app {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Floor + ceiling grid + ambient radial */
|
||||
.bg-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -2;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(900px 600px at 18% -10%, rgba(0, 130, 200, 0.28), transparent 60%),
|
||||
radial-gradient(1100px 700px at 95% 10%, rgba(120, 60, 255, 0.18), transparent 65%),
|
||||
radial-gradient(800px 500px at 50% 120%, rgba(0, 209, 255, 0.18), transparent 60%),
|
||||
linear-gradient(180deg, #03060c 0%, #04081a 50%, #03060c 100%);
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 209, 255, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 209, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
mask-image: radial-gradient(ellipse 100% 70% at 50% 30%, #000 30%, transparent 80%);
|
||||
animation: gridPan 60s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridPan {
|
||||
to { background-position: 60px 60px; }
|
||||
}
|
||||
|
||||
/* Mouse-follow spotlight */
|
||||
.cursor-glow {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, rgba(0, 209, 255, 0.18), rgba(0, 209, 255, 0) 60%);
|
||||
transform: translate(-50%, -50%);
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 0.4s ease;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Floating particles */
|
||||
.particles {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 2px; height: 2px;
|
||||
background: var(--cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--cyan), 0 0 18px rgba(0, 209, 255, 0.6);
|
||||
opacity: 0.5;
|
||||
animation: floatUp linear infinite;
|
||||
}
|
||||
@keyframes floatUp {
|
||||
0% { transform: translateY(0) translateX(0); opacity: 0; }
|
||||
10% { opacity: 0.7; }
|
||||
90% { opacity: 0.7; }
|
||||
100% { transform: translateY(-110vh) translateX(40px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Typography ---------------------------------------------------- */
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-display);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 11px;
|
||||
color: var(--cyan-2);
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 209, 255, 0.05);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.eyebrow .dot {
|
||||
width: 6px; height: 6px;
|
||||
background: var(--cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px var(--cyan);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.h-display {
|
||||
font-size: clamp(48px, 7vw, 96px);
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.h-display .accent { color: var(--cyan); }
|
||||
.h-display .grad {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #c1e8ff 60%, #5fc8ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.h-display .underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
color: var(--cyan);
|
||||
}
|
||||
.h-display .underline::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
bottom: 0.05em;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
box-shadow: 0 0 18px var(--cyan);
|
||||
}
|
||||
|
||||
.h-section {
|
||||
font-size: clamp(38px, 5vw, 68px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.h-section .accent { color: var(--cyan); }
|
||||
.h-card {
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sub {
|
||||
color: var(--ink-2);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
max-width: 56ch;
|
||||
}
|
||||
|
||||
/* Layout -------------------------------------------------------- */
|
||||
.shell {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 48px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.shell { padding: 0 22px; }
|
||||
}
|
||||
|
||||
section.section {
|
||||
position: relative;
|
||||
padding: 140px 0;
|
||||
}
|
||||
section.section.tight { padding: 90px 0; }
|
||||
|
||||
/* Section divider beam */
|
||||
.beam {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1100px, 80%);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 209, 255, 0.6), transparent);
|
||||
box-shadow: 0 0 30px rgba(0, 209, 255, 0.5);
|
||||
}
|
||||
.beam.top { top: 0; }
|
||||
.beam.bottom { bottom: 0; }
|
||||
|
||||
/* Glass card ----------------------------------------------------- */
|
||||
.card {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgba(12, 26, 48, 0.7), rgba(6, 14, 28, 0.55));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
overflow: hidden;
|
||||
transition: transform 0.4s cubic-bezier(.2,.7,.2,1), border-color 0.3s, box-shadow 0.4s;
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
400px circle at var(--mx, 50%) var(--my, 0%),
|
||||
rgba(0, 209, 255, 0.18),
|
||||
transparent 45%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card:hover::before { opacity: 1; }
|
||||
.card:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-cyan-soft);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card .corner {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
border: 1px solid var(--cyan);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.card .corner.tl { top: 8px; left: 8px; border-right: 0; border-bottom: 0; }
|
||||
.card .corner.tr { top: 8px; right: 8px; border-left: 0; border-bottom: 0; }
|
||||
.card .corner.bl { bottom: 8px; left: 8px; border-right: 0; border-top: 0; }
|
||||
.card .corner.br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
|
||||
|
||||
.card .pad { padding: 28px; }
|
||||
.card .pad-lg { padding: 36px; }
|
||||
.card .pad-sm { padding: 18px; }
|
||||
|
||||
/* Buttons ------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 52px;
|
||||
padding: 0 26px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.005em;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.3s, background 0.3s, border-color 0.3s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(180deg, #00d1ff 0%, #0094c8 100%);
|
||||
color: #001520;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 209, 255, 0.6) inset,
|
||||
0 12px 30px -10px rgba(0, 209, 255, 0.7),
|
||||
0 0 60px -10px rgba(0, 209, 255, 0.55);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 209, 255, 0.9) inset,
|
||||
0 18px 40px -10px rgba(0, 209, 255, 0.9),
|
||||
0 0 80px -8px rgba(0, 209, 255, 0.7);
|
||||
}
|
||||
|
||||
.btn-orange {
|
||||
background: linear-gradient(180deg, #ffb24d 0%, #ff7a00 100%);
|
||||
color: #1a0800;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 138, 30, 0.6) inset,
|
||||
0 12px 30px -10px rgba(255, 138, 30, 0.7),
|
||||
0 0 60px -10px rgba(255, 138, 30, 0.55);
|
||||
}
|
||||
.btn-orange:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 138, 30, 0.9) inset,
|
||||
0 18px 40px -10px rgba(255, 138, 30, 0.9),
|
||||
0 0 80px -8px rgba(255, 138, 30, 0.7);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(10, 22, 40, 0.5);
|
||||
border-color: var(--border);
|
||||
color: var(--ink);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: rgba(0, 209, 255, 0.08);
|
||||
box-shadow: 0 0 0 1px var(--border-strong), 0 0 30px -8px var(--cyan);
|
||||
}
|
||||
|
||||
.btn .arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.btn:hover .arrow { transform: translateX(4px); }
|
||||
|
||||
/* Pill / Chip --------------------------------------------------- */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 209, 255, 0.06);
|
||||
color: var(--cyan-2);
|
||||
}
|
||||
.chip.orange {
|
||||
border-color: rgba(255, 138, 30, 0.35);
|
||||
background: rgba(255, 138, 30, 0.08);
|
||||
color: var(--orange-2);
|
||||
}
|
||||
.chip.green {
|
||||
border-color: rgba(43, 212, 156, 0.3);
|
||||
background: rgba(43, 212, 156, 0.08);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Reveal animation (scroll trigger) ----------------------------- */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(28px);
|
||||
transition: opacity 0.9s cubic-bezier(.2,.7,.2,1), transform 0.9s cubic-bezier(.2,.7,.2,1);
|
||||
}
|
||||
.reveal.in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.reveal.delay-1 { transition-delay: 0.08s; }
|
||||
.reveal.delay-2 { transition-delay: 0.16s; }
|
||||
.reveal.delay-3 { transition-delay: 0.24s; }
|
||||
.reveal.delay-4 { transition-delay: 0.32s; }
|
||||
.reveal.delay-5 { transition-delay: 0.4s; }
|
||||
|
||||
/* Helpers ------------------------------------------------------- */
|
||||
.row { display: flex; align-items: center; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.gap-12 { gap: 12px; }
|
||||
.gap-16 { gap: 16px; }
|
||||
.gap-24 { gap: 24px; }
|
||||
.muted { color: var(--muted); }
|
||||
.dim { color: var(--dim); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.tabular { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Glow rings */
|
||||
.ring-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Animated counter dot */
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Wireframe line drawing */
|
||||
@keyframes drawIn {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.wf-line {
|
||||
stroke: var(--cyan);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
filter: drop-shadow(0 0 6px rgba(0, 209, 255, 0.7));
|
||||
}
|
||||
|
||||
/* Marquee tape */
|
||||
.marquee {
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 18px 0;
|
||||
background: rgba(0, 209, 255, 0.03);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
width: max-content;
|
||||
animation: scroll 38s linear infinite;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.marquee-track span.sep { color: var(--cyan); }
|
||||
@keyframes scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Generic micro-bar (mini chart) */
|
||||
.spark {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 32px;
|
||||
}
|
||||
.spark span {
|
||||
flex: 1;
|
||||
background: linear-gradient(180deg, var(--cyan), transparent);
|
||||
border-radius: 2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Section title block */
|
||||
.section-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
margin-bottom: 64px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Two-column split */
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.15fr;
|
||||
gap: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.split { grid-template-columns: 1fr; gap: 50px; }
|
||||
}
|
||||
|
||||
/* Scroll progress bar */
|
||||
.scroll-progress {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 2px;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--cyan), var(--orange));
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 14px var(--cyan);
|
||||
transition: width 0.05s;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(0, 209, 255, 0.35);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--cyan-deep), #1a3a5a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* EDITORIAL / BLUEPRINT CHROME — v2 enhancements */
|
||||
/* ============================================================ */
|
||||
|
||||
/* Section frame with architectural dimension corners */
|
||||
.frame {
|
||||
position: relative;
|
||||
padding: 60px 56px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(800px 400px at 30% -10%, rgba(0,209,255,0.08), transparent 70%),
|
||||
linear-gradient(180deg, rgba(6,12,24,0.55), rgba(2,6,14,0.35));
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.frame-tight { padding: 36px 36px; }
|
||||
|
||||
/* Engineering corner ticks — drawn with conic + line gradients */
|
||||
.frame::before, .frame::after,
|
||||
.frame-corners > .ct {
|
||||
position: absolute;
|
||||
width: 28px; height: 28px;
|
||||
border-color: var(--cyan);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Blueprint dimension marker (tl/tr/bl/br) — draws an L-shape with ticks */
|
||||
.dim-corner {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dim-corner svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
||||
.dim-corner.tl { top: -10px; left: -10px; }
|
||||
.dim-corner.tr { top: -10px; right: -10px; transform: scaleX(-1); }
|
||||
.dim-corner.bl { bottom: -10px; left: -10px; transform: scaleY(-1); }
|
||||
.dim-corner.br { bottom: -10px; right: -10px; transform: scale(-1, -1); }
|
||||
|
||||
/* Section number — oversized mono */
|
||||
.section-number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.32em;
|
||||
color: var(--cyan-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.section-number .num {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 12px rgba(0, 209, 255, 0.6);
|
||||
}
|
||||
.section-number::before {
|
||||
content: "";
|
||||
width: 36px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--cyan), transparent);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Vertical mono label running down the page edge */
|
||||
.vrail {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(180deg, transparent, var(--border), transparent);
|
||||
}
|
||||
.vrail-label {
|
||||
position: absolute;
|
||||
writing-mode: vertical-rl;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
top: 30%;
|
||||
left: -22px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Editorial display type — even larger, tighter */
|
||||
.h-mega {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(64px, 9.5vw, 148px);
|
||||
line-height: 0.88;
|
||||
letter-spacing: -0.045em;
|
||||
font-weight: 600;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.h-mega .grad {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #c1e8ff 55%, #5fc8ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.h-mega .accent { color: var(--cyan); }
|
||||
.h-mega .orange { color: var(--orange); }
|
||||
.h-mega .outline {
|
||||
-webkit-text-stroke: 1.2px var(--cyan);
|
||||
color: transparent;
|
||||
text-shadow: 0 0 30px rgba(0,209,255,0.35);
|
||||
}
|
||||
.h-mega .ital {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Mono dimension marker tag (e.g. "18'-7"") */
|
||||
.dim-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--cyan-2);
|
||||
padding: 4px 10px;
|
||||
border: 1px dashed rgba(0, 209, 255, 0.45);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 209, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Engineering tick line — used as section dividers */
|
||||
.tick-line {
|
||||
height: 18px;
|
||||
background-image:
|
||||
repeating-linear-gradient(90deg,
|
||||
var(--cyan) 0 1px, transparent 1px 24px);
|
||||
mask-image: linear-gradient(180deg, var(--cyan) 0 60%, transparent 60%);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Blueprint dimension callout used inside frames */
|
||||
.dim-callout {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--cyan-2);
|
||||
background: rgba(2, 6, 14, 0.85);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 209, 255, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* "Plate" badge — used for product spec labels */
|
||||
.plate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: rgba(4, 12, 24, 0.65);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.plate .pnum {
|
||||
color: var(--cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Floating system status pill (sticky) */
|
||||
.sys-pill {
|
||||
position: fixed;
|
||||
bottom: 22px; right: 22px;
|
||||
z-index: 40;
|
||||
padding: 10px 16px;
|
||||
background: rgba(2, 6, 14, 0.85);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(10px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--ink-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
box-shadow: 0 12px 30px -10px rgba(0,209,255,0.35);
|
||||
}
|
||||
.sys-pill .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px var(--green);
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Section head v2 — split into rail + content */
|
||||
.shead {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 60px;
|
||||
margin-bottom: 80px;
|
||||
align-items: start;
|
||||
}
|
||||
.shead .rail {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.shead .rail .num-big {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 80px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(180deg, #00d1ff, rgba(0, 209, 255, 0.15));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.shead .rail .tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.shead .rail .ticks {
|
||||
width: 80px;
|
||||
height: 8px;
|
||||
background-image: repeating-linear-gradient(90deg, var(--cyan) 0 1px, transparent 1px 9px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.shead { grid-template-columns: 1fr; gap: 18px; margin-bottom: 50px; }
|
||||
.shead .rail { position: static; }
|
||||
.shead .rail .num-big { font-size: 54px; }
|
||||
}
|
||||
|
||||
/* "Ledger" / spec list */
|
||||
.ledger {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.ledger > .row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 22px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.25s, border-color 0.25s;
|
||||
}
|
||||
.ledger > .row:hover {
|
||||
background: rgba(0, 209, 255, 0.03);
|
||||
border-bottom-color: var(--border-strong);
|
||||
}
|
||||
.ledger > .row .lnum {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--cyan-2);
|
||||
}
|
||||
|
||||
/* Hero specific tweaks (v2) */
|
||||
.hero-frame {
|
||||
position: relative;
|
||||
padding: 100px 56px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(700px 400px at 20% 0%, rgba(0,130,200,0.18), transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 100%, rgba(0,209,255,0.12), transparent 65%),
|
||||
linear-gradient(180deg, rgba(4, 10, 22, 0.5), rgba(2, 6, 14, 0.2));
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.hero-frame { padding: 60px 24px; }
|
||||
}
|
||||
|
||||
/* Sharper card variant (for editorial sections) */
|
||||
.card-sharp {
|
||||
background: linear-gradient(180deg, rgba(8, 18, 36, 0.8), rgba(2, 8, 18, 0.7));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.card-sharp.no-radius {
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Welcome to LynkedUp Pro — you're a Founding Member</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root { --orange:#ff9321; --blue:#4dc5ff; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh;
|
||||
font-family: "Plus Jakarta Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at top left, #8c4a13 0%, transparent 38%),
|
||||
radial-gradient(circle at bottom right, #1597e5 0%, transparent 40%),
|
||||
#14100c;
|
||||
display: flex; align-items: center; justify-content: center; padding: 32px 18px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 620px;
|
||||
background: rgba(18,18,22,.72); border: 1px solid rgba(255,255,255,.10);
|
||||
border-radius: 22px; padding: 44px 40px; text-align: center;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.5); backdrop-filter: blur(6px);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 7px 15px; border-radius: 999px; margin-bottom: 22px;
|
||||
background: linear-gradient(90deg, var(--orange), var(--blue));
|
||||
color: #0d0d0f; font-weight: 800; font-size: 12.5px; letter-spacing: .02em;
|
||||
}
|
||||
.check {
|
||||
width: 66px; height: 66px; margin: 0 auto 22px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--orange), var(--blue));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 34px; color: #0d0d0f; font-weight: 800;
|
||||
}
|
||||
h1 { margin: 0 0 10px; font-size: 27px; font-weight: 800; letter-spacing: -.02em; line-height: 1.2; }
|
||||
p.lead { margin: 0 auto 26px; max-width: 480px; color: #c9c9d2; font-size: 15px; line-height: 1.6; }
|
||||
.steps { text-align: left; margin: 0 auto 30px; max-width: 470px; display: grid; gap: 12px; }
|
||||
.step { display: flex; gap: 13px; align-items: flex-start; padding: 14px 16px;
|
||||
background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.07); border-radius: 13px; }
|
||||
.step .n { flex: 0 0 26px; height: 26px; border-radius: 50%;
|
||||
background: rgba(255,147,33,.16); color: var(--orange);
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 13px; }
|
||||
.step .t { font-size: 14px; line-height: 1.5; color: #e6e6ea; }
|
||||
.step .t b { color: #fff; }
|
||||
.cta { display: inline-block; margin-top: 6px; padding: 14px 30px; border-radius: 12px;
|
||||
text-decoration: none; color: #fff; font-weight: 700; font-size: 15px;
|
||||
background: linear-gradient(90deg, var(--orange), var(--blue)); box-shadow: 0 10px 25px rgba(0,0,0,.3); }
|
||||
.fine { margin-top: 22px; font-size: 12px; color: #8a8a93; line-height: 1.6; }
|
||||
.fine a { color: var(--blue); text-decoration: none; }
|
||||
.demo-note { margin: -6px auto 24px; max-width: 470px; padding: 11px 15px; border-radius: 12px;
|
||||
background: rgba(77,197,255,.12); border: 1px solid rgba(77,197,255,.32); color: #bfe6ff; font-size: 13px; line-height: 1.55; }
|
||||
.ref { margin-top: 10px; font-size: 11px; color: #6f6f78; word-break: break-all; }
|
||||
@media (max-width: 560px) { .card { padding: 34px 22px; } h1 { font-size: 23px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="check">✓</div>
|
||||
<span class="badge" id="badge">🎉 You're a Founding Member</span>
|
||||
<h1 id="headline">Payment received — welcome to LynkedUp Pro</h1>
|
||||
<p class="lead" id="lead">
|
||||
Your Founders Lifetime License is locked in at the founder price. A confirmation
|
||||
email is on its way, and our onboarding team will reach out shortly to get your
|
||||
account set up.
|
||||
</p>
|
||||
<p class="demo-note" id="demoNote" hidden>
|
||||
🎬 <b>Demo mode.</b> This was a walkthrough of the full flow — no payment was taken
|
||||
and no card was charged.
|
||||
</p>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step"><span class="n">1</span><span class="t"><b>Check your inbox.</b> Your receipt and a welcome email with next steps are on the way. (Peek in spam if you don't see them in a few minutes.)</span></div>
|
||||
<div class="step"><span class="n">2</span><span class="t"><b>We'll set you up.</b> Our team will contact you at the details you provided to activate your license and walk you through onboarding.</span></div>
|
||||
<div class="step"><span class="n">3</span><span class="t"><b>Watch for launch updates.</b> As a Founding Member you'll hear first about new features and your rollout timeline.</span></div>
|
||||
</div>
|
||||
|
||||
<a class="cta" id="setupCta" href="/portal/register">Set up your account</a>
|
||||
<p class="fine" id="setupStatus" style="margin-top:10px;">Preparing your account…</p>
|
||||
|
||||
<p class="fine">
|
||||
Questions? Email <a href="mailto:support@lynkeduppro.com">support@lynkeduppro.com</a> and reference your order below.
|
||||
</p>
|
||||
<p class="ref" id="ref"></p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var demo = q.get("demo") === "1";
|
||||
// Demo mode ($0 no-card flow from page/19.html): reframe the page so nobody
|
||||
// thinks a real charge happened.
|
||||
if (demo) {
|
||||
document.getElementById("badge").textContent = "🎬 Demo complete";
|
||||
document.getElementById("headline").innerHTML = "That's the full flow — start to finish";
|
||||
document.getElementById("lead").textContent =
|
||||
"You just walked through the entire Founders License journey. On the live page this is where the real payment would complete.";
|
||||
document.getElementById("demoNote").hidden = false;
|
||||
}
|
||||
// Show the Stripe session id (from success_url) so support can look up the order.
|
||||
var id = q.get("session_id");
|
||||
if (id) document.getElementById("ref").textContent = "Order reference: " + id;
|
||||
|
||||
var status = document.getElementById("setupStatus");
|
||||
var cta = document.getElementById("setupCta");
|
||||
if (demo || !id) {
|
||||
// No real purchase to attribute — just offer the register page.
|
||||
if (status) status.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll be-crm (via our proxy) for the founder token minted by the Stripe
|
||||
// webhook, then redirect to the prefilled register page. The welcome email
|
||||
// carries the same link, so this is a convenience — nothing is lost if the
|
||||
// webhook is briefly delayed.
|
||||
var tries = 0, MAX = 15; // ~30s at 2s intervals
|
||||
function poll() {
|
||||
tries++;
|
||||
fetch("/api/founder/session?session_id=" + encodeURIComponent(id), { cache: "no-store" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d && d.status === "ready" && d.token) {
|
||||
var url = "/portal/register?ft=" + encodeURIComponent(d.token);
|
||||
if (cta) cta.setAttribute("href", url);
|
||||
if (status) status.textContent = "Redirecting you to finish setup…";
|
||||
window.location.replace(url);
|
||||
return;
|
||||
}
|
||||
if (tries < MAX) { setTimeout(poll, 2000); return; }
|
||||
if (status) status.innerHTML = "We emailed you a secure link to finish setting up your account — check your inbox (and spam).";
|
||||
})
|
||||
.catch(function () {
|
||||
if (tries < MAX) { setTimeout(poll, 2000); return; }
|
||||
if (status) status.innerHTML = "We emailed you a secure link to finish setting up your account — check your inbox (and spam).";
|
||||
});
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,431 @@
|
||||
/* global React */
|
||||
// Shared UI primitives for LynkedUp Pro
|
||||
|
||||
const { useEffect, useRef, useState } = React;
|
||||
|
||||
// ---------- Hex logo (matches flyer mark) ----------
|
||||
function Logo({ size = 90 }) {
|
||||
return (
|
||||
<div className="row gap-12" style={{ alignItems: "center" }}>
|
||||
<img
|
||||
src="uploads/lynkedup-logo.png"
|
||||
alt="LynkedUp Pro"
|
||||
width={150}
|
||||
height={30}
|
||||
style={{ objectFit: "contain", filter: "drop-shadow(0 0 8px rgba(0,209,255,0.6))" }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Counter that animates on view ----------
|
||||
function Counter({ to, suffix = "", prefix = "", duration = 1800, decimals = 0 }) {
|
||||
const ref = useRef(null);
|
||||
const [val, setVal] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
const start = performance.now();
|
||||
const tick = (now) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const ease = 1 - Math.pow(1 - t, 3);
|
||||
setVal(to * ease);
|
||||
if (t < 1) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
obs.disconnect();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [to]);
|
||||
const formatted = decimals
|
||||
? val.toFixed(decimals)
|
||||
: Math.round(val).toLocaleString();
|
||||
return <span ref={ref} className="tabular">{prefix}{formatted}{suffix}</span>;
|
||||
}
|
||||
|
||||
// ---------- Reveal-on-scroll wrapper ----------
|
||||
function Reveal({ children, delay = 0, className = "", as: As = "div", style = {} }) {
|
||||
const ref = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => {
|
||||
if (e.isIntersecting) {
|
||||
el.classList.add("in");
|
||||
obs.unobserve(el);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: "0px 0px -60px 0px" });
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
const delayCls = delay ? ` delay-${delay}` : "";
|
||||
return (
|
||||
<As ref={ref} className={`reveal${delayCls} ${className}`} style={style}>
|
||||
{children}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Card with mouse-tracking glow ----------
|
||||
function GlowCard({ children, className = "", style = {}, onClick }) {
|
||||
const ref = useRef(null);
|
||||
const handle = (e) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
el.style.setProperty("--mx", `${e.clientX - r.left}px`);
|
||||
el.style.setProperty("--my", `${e.clientY - r.top}px`);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`card ${className}`}
|
||||
style={style}
|
||||
onMouseMove={handle}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="corner tl"></span>
|
||||
<span className="corner tr"></span>
|
||||
<span className="corner bl"></span>
|
||||
<span className="corner br"></span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Wireframe Blueprint House (SVG, animated drawing) ----------
|
||||
function BlueprintHouse({ size = 600, animate = true, withMeasurements = true, withScan = true }) {
|
||||
// viewBox 600x460
|
||||
const dash = animate ? { strokeDasharray: 1400, strokeDashoffset: 1400, animation: "drawIn 4s ease-out forwards" } : {};
|
||||
return (
|
||||
<svg viewBox="0 0 600 460" width={size} height={size * 460 / 600} style={{ display: "block", overflow: "visible" }}>
|
||||
<defs>
|
||||
<linearGradient id="hglow" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor="#7be8ff"/>
|
||||
<stop offset="1" stopColor="#0090c0"/>
|
||||
</linearGradient>
|
||||
<filter id="bglow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<pattern id="floorgrid" width="32" height="32" patternUnits="userSpaceOnUse" patternTransform="skewX(-30) skewY(0)">
|
||||
<path d="M32 0 L0 0 L0 32" fill="none" stroke="rgba(0,209,255,0.22)" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Floor grid */}
|
||||
<g transform="translate(0,380)">
|
||||
<rect x="-60" y="0" width="720" height="80" fill="url(#floorgrid)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
{/* House outline - two-gable */}
|
||||
<g stroke="url(#hglow)" strokeWidth="1.5" fill="none" filter="url(#bglow)" style={dash}>
|
||||
{/* Main body */}
|
||||
<path d="M60 380 L60 200 L210 90 L360 200 L360 380 Z" />
|
||||
{/* Right side smaller gable */}
|
||||
<path d="M360 380 L360 240 L450 180 L540 240 L540 380 Z" />
|
||||
{/* Roof ridge */}
|
||||
<line x1="210" y1="90" x2="210" y2="200" opacity="0.4"/>
|
||||
<line x1="450" y1="180" x2="450" y2="240" opacity="0.4"/>
|
||||
{/* Chimney */}
|
||||
<rect x="100" y="135" width="22" height="48"/>
|
||||
{/* Door */}
|
||||
<rect x="180" y="290" width="60" height="90"/>
|
||||
<circle cx="232" cy="335" r="2.5" fill="#7be8ff"/>
|
||||
{/* Windows */}
|
||||
<rect x="90" y="240" width="60" height="50"/>
|
||||
<line x1="120" y1="240" x2="120" y2="290" opacity="0.5"/>
|
||||
<line x1="90" y1="265" x2="150" y2="265" opacity="0.5"/>
|
||||
<rect x="270" y="240" width="60" height="50"/>
|
||||
<line x1="300" y1="240" x2="300" y2="290" opacity="0.5"/>
|
||||
<line x1="270" y1="265" x2="330" y2="265" opacity="0.5"/>
|
||||
<rect x="410" y="280" width="80" height="60"/>
|
||||
<line x1="450" y1="280" x2="450" y2="340" opacity="0.5"/>
|
||||
<line x1="410" y1="310" x2="490" y2="310" opacity="0.5"/>
|
||||
{/* Roof structure lines */}
|
||||
<line x1="60" y1="200" x2="360" y2="200" opacity="0.35"/>
|
||||
<line x1="360" y1="240" x2="540" y2="240" opacity="0.35"/>
|
||||
</g>
|
||||
|
||||
{/* Measurements */}
|
||||
{withMeasurements && (
|
||||
<g fontFamily="var(--font-mono)" fontSize="13" fill="#7be8ff" filter="url(#bglow)">
|
||||
<g>
|
||||
<line x1="60" y1="60" x2="210" y2="60" stroke="#7be8ff" strokeWidth="0.8" strokeDasharray="3 3" opacity="0.7"/>
|
||||
<text x="115" y="52">18′-7″</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="210" y1="60" x2="360" y2="60" stroke="#7be8ff" strokeWidth="0.8" strokeDasharray="3 3" opacity="0.7"/>
|
||||
<text x="265" y="52">12′-3″</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="360" y1="160" x2="540" y2="160" stroke="#7be8ff" strokeWidth="0.8" strokeDasharray="3 3" opacity="0.7"/>
|
||||
<text x="430" y="153">15′-4″</text>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="560" y1="240" x2="560" y2="380" stroke="#7be8ff" strokeWidth="0.8" strokeDasharray="3 3" opacity="0.7"/>
|
||||
<text x="566" y="315">8′-0″</text>
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Corner markers */}
|
||||
<g fill="#00d1ff">
|
||||
{[[60,200],[210,90],[360,200],[450,180],[540,240]].map(([x,y],i) => (
|
||||
<g key={i}>
|
||||
<circle cx={x} cy={y} r="3.5" />
|
||||
<circle cx={x} cy={y} r="9" fill="none" stroke="#00d1ff" opacity="0.5">
|
||||
<animate attributeName="r" values="6;14;6" dur="2.4s" begin={`${i*0.2}s`} repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.7;0;0.7" dur="2.4s" begin={`${i*0.2}s`} repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* AI scan line */}
|
||||
{withScan && (
|
||||
<rect x="0" y="80" width="600" height="3" fill="rgba(0,209,255,0.7)" style={{ filter: "blur(2px)" }}>
|
||||
<animate attributeName="y" values="80;380;80" dur="6s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0;1;0" dur="6s" repeatCount="indefinite" />
|
||||
</rect>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Tiny chart components ----------
|
||||
function Sparkline({ data, color = "#00d1ff", height = 36, width = 120 }) {
|
||||
const max = Math.max(...data), min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
const pts = data.map((v, i) => `${i * stepX},${height - ((v - min) / range) * height}`).join(" ");
|
||||
const area = `0,${height} ${pts} ${width},${height}`;
|
||||
return (
|
||||
<svg width={width} height={height} style={{ display: "block" }}>
|
||||
<defs>
|
||||
<linearGradient id={`sg-${color}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stopColor={color} stopOpacity="0.4"/>
|
||||
<stop offset="1" stopColor={color} stopOpacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={area} fill={`url(#sg-${color})`} />
|
||||
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.6"
|
||||
style={{ filter: `drop-shadow(0 0 6px ${color})` }}/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DonutChart({ size = 120, stroke = 12, segments }) {
|
||||
// segments: [{value, color}]
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
let offset = 0;
|
||||
return (
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)" }}>
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={stroke}/>
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle key={i} cx={size/2} cy={size/2} r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${len} ${c - len}`}
|
||||
strokeDashoffset={-offset}
|
||||
style={{ filter: `drop-shadow(0 0 4px ${s.color})` }}
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Icon set (line, glowing) ----------
|
||||
const Icon = ({ name, size = 22, stroke = "#7be8ff" }) => {
|
||||
const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke, strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", style: { filter: `drop-shadow(0 0 6px ${stroke}55)` } };
|
||||
switch (name) {
|
||||
case "ai":
|
||||
return <svg {...props}><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/><circle cx="12" cy="12" r="4"/><path d="M10 12h4M12 10v4"/></svg>;
|
||||
case "calendar":
|
||||
return <svg {...props}><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/><rect x="7" y="13" width="4" height="3" rx="0.5"/></svg>;
|
||||
case "crew":
|
||||
return <svg {...props}><circle cx="9" cy="8" r="3"/><circle cx="17" cy="10" r="2.2"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6M14 20c0-2 1.2-3.6 3-4.4"/></svg>;
|
||||
case "fleet":
|
||||
return <svg {...props}><circle cx="12" cy="11" r="9"/><path d="M3 11h18M12 2c3 3 3 15 0 18M12 2c-3 3-3 15 0 18"/><circle cx="12" cy="11" r="2" fill={stroke}/></svg>;
|
||||
case "budget":
|
||||
return <svg {...props}><path d="M3 17l5-5 4 3 8-9"/><path d="M14 6h6v6"/><path d="M3 21h18"/></svg>;
|
||||
case "report":
|
||||
return <svg {...props}><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>;
|
||||
case "chat":
|
||||
return <svg {...props}><path d="M21 12a8 8 0 1 1-3.4-6.5L21 4l-1.4 3.5A8 8 0 0 1 21 12z"/><path d="M8 11h.01M12 11h.01M16 11h.01"/></svg>;
|
||||
case "field":
|
||||
return <svg {...props}><path d="M3 12l9-8 9 8M5 10v10h14V10"/><path d="M10 20v-6h4v6"/></svg>;
|
||||
case "shield":
|
||||
return <svg {...props}><path d="M12 3l8 3v6c0 5-4 8-8 9-4-1-8-4-8-9V6l8-3z"/><path d="M9 12l2 2 4-4"/></svg>;
|
||||
case "clock":
|
||||
return <svg {...props}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>;
|
||||
case "target":
|
||||
return <svg {...props}><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill={stroke}/></svg>;
|
||||
case "growth":
|
||||
return <svg {...props}><path d="M3 20V8M9 20V13M15 20v-9M21 20V5"/></svg>;
|
||||
case "bell":
|
||||
return <svg {...props}><path d="M18 16v-5a6 6 0 0 0-12 0v5l-2 3h16l-2-3z"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>;
|
||||
case "pin":
|
||||
return <svg {...props}><path d="M12 21s7-6 7-12a7 7 0 0 0-14 0c0 6 7 12 7 12z"/><circle cx="12" cy="9" r="2.5"/></svg>;
|
||||
case "sync":
|
||||
return <svg {...props}><path d="M21 12a9 9 0 0 1-15.5 6.3M3 12a9 9 0 0 1 15.5-6.3"/><path d="M21 4v5h-5M3 20v-5h5"/></svg>;
|
||||
case "brain":
|
||||
return <svg {...props}><path d="M9 4a3 3 0 0 0-3 3v0a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3v0a3 3 0 0 0 3 3"/><path d="M15 4a3 3 0 0 1 3 3v0a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3v0a3 3 0 0 1-3 3"/><path d="M12 4v16M9 9h6M9 14h6"/></svg>;
|
||||
case "doc":
|
||||
return <svg {...props}><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/></svg>;
|
||||
case "camera":
|
||||
return <svg {...props}><path d="M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z"/><circle cx="12" cy="13" r="4"/></svg>;
|
||||
case "hammer":
|
||||
return <svg {...props}><path d="M15 5l4 4-3 3-4-4 3-3z"/><path d="M12 8L4 16l3 3 8-8"/></svg>;
|
||||
case "lead":
|
||||
return <svg {...props}><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/><path d="M16 4l2 2-4 4"/></svg>;
|
||||
case "money":
|
||||
return <svg {...props}><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M5 9v.01M19 15v.01"/></svg>;
|
||||
case "spark":
|
||||
return <svg {...props}><path d="M12 3l1.7 5.3L19 10l-5.3 1.7L12 17l-1.7-5.3L5 10l5.3-1.7L12 3z"/></svg>;
|
||||
case "arrow":
|
||||
return <svg {...props}><path d="M5 12h14M13 5l7 7-7 7"/></svg>;
|
||||
case "play":
|
||||
return <svg {...props}><polygon points="6,4 20,12 6,20" fill={stroke} opacity="0.2"/><polygon points="6,4 20,12 6,20"/></svg>;
|
||||
default:
|
||||
return <svg {...props}><circle cx="12" cy="12" r="9"/></svg>;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Dimension corner brackets (blueprint chrome) ----------
|
||||
function DimCorner({ pos = "tl" }) {
|
||||
return (
|
||||
<div className={`dim-corner ${pos}`}>
|
||||
<svg viewBox="0 0 36 36">
|
||||
<path d="M0 14 L0 0 L14 0" fill="none" stroke="#00d1ff" strokeWidth="1.2"
|
||||
style={{ filter: "drop-shadow(0 0 4px #00d1ff)" }}/>
|
||||
<line x1="0" y1="6" x2="6" y2="6" stroke="#00d1ff" strokeWidth="0.8" opacity="0.6"/>
|
||||
<line x1="6" y1="0" x2="6" y2="6" stroke="#00d1ff" strokeWidth="0.8" opacity="0.6"/>
|
||||
<circle cx="0" cy="0" r="2" fill="#00d1ff"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Dimension line (engineering measurement) ----------
|
||||
function DimLine({ orientation = "h", label, length = "100%", offset = 0, side = "top" }) {
|
||||
const isH = orientation === "h";
|
||||
return (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
[side]: offset,
|
||||
...(isH ? { left: "10%", right: "10%" } : { top: "10%", bottom: "10%", width: 20 }),
|
||||
pointerEvents: "none",
|
||||
}}>
|
||||
<svg viewBox={isH ? "0 0 200 12" : "0 0 12 200"}
|
||||
preserveAspectRatio="none"
|
||||
style={{ width: isH ? "100%" : 12, height: isH ? 12 : "100%" }}>
|
||||
{isH ? (
|
||||
<>
|
||||
<line x1="0" y1="6" x2="200" y2="6" stroke="#7be8ff" strokeWidth="0.6" strokeDasharray="3 3" opacity="0.6"/>
|
||||
<line x1="0" y1="2" x2="0" y2="10" stroke="#7be8ff" strokeWidth="0.8"/>
|
||||
<line x1="200" y1="2" x2="200" y2="10" stroke="#7be8ff" strokeWidth="0.8"/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<line x1="6" y1="0" x2="6" y2="200" stroke="#7be8ff" strokeWidth="0.6" strokeDasharray="3 3" opacity="0.6"/>
|
||||
<line x1="2" y1="0" x2="10" y2="0" stroke="#7be8ff" strokeWidth="0.8"/>
|
||||
<line x1="2" y1="200" x2="10" y2="200" stroke="#7be8ff" strokeWidth="0.8"/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{label && (
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
padding: "2px 6px",
|
||||
background: "rgba(2,6,14,0.9)",
|
||||
border: "1px solid rgba(0,209,255,0.3)",
|
||||
borderRadius: 3,
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.16em",
|
||||
color: "var(--cyan-2)",
|
||||
}}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- SectionFrame: standard architectural-framed section ----------
|
||||
function SectionFrame({ children, number, tag, vlabel, id, className = "", style = {} }) {
|
||||
return (
|
||||
<section id={id} className={`section ${className}`} style={style}>
|
||||
<div className="shell" style={{ position: "relative" }}>
|
||||
{vlabel && (
|
||||
<>
|
||||
<div className="vrail"/>
|
||||
<div className="vrail-label">{vlabel}</div>
|
||||
</>
|
||||
)}
|
||||
{(number || tag) && (
|
||||
<Reveal>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 40 }}>
|
||||
{number && (
|
||||
<span className="section-number">
|
||||
<span className="num">§{number}</span>
|
||||
<span>{tag}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="dim-tag">// {vlabel || "BUILD MK.07"}</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Section head with rail (number + tag + ticks) ----------
|
||||
function SectionHead({ number, tag, title, sub, align = "left" }) {
|
||||
return (
|
||||
<div className="shead">
|
||||
<Reveal>
|
||||
<div className="rail">
|
||||
<span className="num-big">{number}</span>
|
||||
<span className="tag">{tag}</span>
|
||||
<div className="ticks"/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div>
|
||||
<Reveal delay={1}>
|
||||
{typeof title === "string" ? <h2 className="h-section">{title}</h2> : title}
|
||||
</Reveal>
|
||||
{sub && (
|
||||
<Reveal delay={2}>
|
||||
<p className="sub" style={{ marginTop: 20 }}>{sub}</p>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export to window for cross-script access
|
||||
Object.assign(window, { Logo, Counter, Reveal, GlowCard, BlueprintHouse, Sparkline, DonutChart, Icon, DimCorner, DimLine, SectionFrame, SectionHead });
|
||||
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -1,73 +0,0 @@
|
||||
#!/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.");
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateLead, leadToStripeMetadata, SELF_SERVE_MAX } from "@/lib/founder/lead";
|
||||
|
||||
// Validates the buyer info captured by the pre-checkout form, then creates a
|
||||
// Stripe Checkout Session and returns its hosted-checkout URL. The Stripe secret
|
||||
// key stays server-side and is NEVER exposed to the browser. Ported from the
|
||||
// landing's api/checkout.js.
|
||||
//
|
||||
// Env:
|
||||
// STRIPE_SECRET_KEY (required) sk_test_… / sk_live_… — swap to switch modes
|
||||
// CHECKOUT_PRICE cents, default 200000 (USD 2000.00)
|
||||
// CHECKOUT_CURRENCY ISO code, default usd
|
||||
// CHECKOUT_PRODUCT_NAME line-item name, default "Founders Lifetime Access"
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Bump when the SMS consent wording in public/square-checkout.js changes (TCPA evidence).
|
||||
const SMS_CONSENT_VERSION = "v1";
|
||||
// Server-side promo table — the client can only pass a code name, never a price.
|
||||
const PROMOS: Record<string, number> = { SAVE30: 30, SAVE50: 50, SAVE70: 70, SAVE99: 99, JUSTIN99: 99 };
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
return NextResponse.json({ error: "Missing STRIPE_SECRET_KEY environment variable on the server." }, { status: 500 });
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
// Never trust the browser: the form validates for UX, this validates for real.
|
||||
const result = validateLead(body);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: "Please check the highlighted fields.", fields: result.errors }, { status: 400 });
|
||||
}
|
||||
const lead = result.lead;
|
||||
|
||||
// Orders over the self-serve limit are NOT payable online — they go to sales.
|
||||
if (lead.licenses > SELF_SERVE_MAX) {
|
||||
return NextResponse.json(
|
||||
{ error: `Orders of more than ${SELF_SERVE_MAX} licenses are handled by our sales team.`, path: "sales", licenses: lead.licenses },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const origin = originFromRequest(request, url);
|
||||
|
||||
let amount = parseInt(process.env.CHECKOUT_PRICE || "200000", 10); // USD 2000.00 in cents
|
||||
let currency = (process.env.CHECKOUT_CURRENCY || "usd").toLowerCase();
|
||||
let productName = process.env.CHECKOUT_PRODUCT_NAME || "Founders Lifetime Access";
|
||||
|
||||
// Optional $1 demo plan (?plan=demo) — the amount is fixed server-side.
|
||||
const plan = url.searchParams.get("plan");
|
||||
if (plan === "demo") {
|
||||
amount = 100;
|
||||
currency = "usd";
|
||||
productName = "LynkedUp Pro Demo Access ($1)";
|
||||
}
|
||||
|
||||
// Promo codes (?promo=CODE) — percent-off applied here, never trusting a client price.
|
||||
const promoCode = (url.searchParams.get("promo") || "").trim().toUpperCase();
|
||||
let appliedPct = 0;
|
||||
if (plan !== "demo" && promoCode && Object.prototype.hasOwnProperty.call(PROMOS, promoCode)) {
|
||||
appliedPct = PROMOS[promoCode];
|
||||
amount = Math.max(50, Math.round((amount * (100 - appliedPct)) / 100)); // Stripe min charge $0.50
|
||||
productName = `${productName} (${appliedPct}% off · code ${promoCode})`;
|
||||
}
|
||||
|
||||
const consentIp = String(request.headers.get("x-forwarded-for") || "").split(",")[0].trim();
|
||||
const capturedAt = new Date().toISOString();
|
||||
// Let Stripe multiply via quantity so the Dashboard shows "N x $X". Demo is a single unit.
|
||||
const quantity = plan === "demo" ? 1 : lead.licenses;
|
||||
|
||||
const metadata = leadToStripeMetadata(lead, {
|
||||
sms_consent_version: SMS_CONSENT_VERSION,
|
||||
sms_consent_at: lead.smsConsent ? capturedAt : "",
|
||||
sms_consent_ip: consentIp,
|
||||
plan: plan || "founders",
|
||||
promo_code: appliedPct ? promoCode : "",
|
||||
unit_amount_cents: String(amount),
|
||||
total_amount_cents: String(amount * quantity),
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
mode: "payment",
|
||||
success_url: `${origin}/thanks?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/?canceled=1`,
|
||||
"line_items[0][quantity]": String(quantity),
|
||||
"line_items[0][price_data][currency]": currency,
|
||||
"line_items[0][price_data][unit_amount]": String(amount),
|
||||
"line_items[0][price_data][product_data][name]": productName,
|
||||
customer_email: lead.email,
|
||||
customer_creation: "always",
|
||||
billing_address_collection: "required",
|
||||
});
|
||||
// Mirror onto the Session (drives our webhook) AND the PaymentIntent (visible in Dashboard).
|
||||
for (const [k, v] of Object.entries(metadata)) {
|
||||
params.set(`metadata[${k}]`, String(v ?? ""));
|
||||
params.set(`payment_intent_data[metadata][${k}]`, String(v ?? ""));
|
||||
}
|
||||
|
||||
const r = await fetch("https://api.stripe.com/v1/checkout/sessions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${secretKey}`, "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: params.toString(),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) return NextResponse.json({ error: "Stripe checkout creation failed", detail: data }, { status: 502 });
|
||||
if (!data.url) return NextResponse.json({ error: "Stripe did not return a checkout URL", detail: data }, { status: 502 });
|
||||
|
||||
return NextResponse.json({ url: data.url });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String((e as Error)?.message || e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Build the public origin from the incoming request so success/cancel redirects
|
||||
// work on any deployment (preview or production).
|
||||
function originFromRequest(request: Request, url: URL): string {
|
||||
const proto = (request.headers.get("x-forwarded-proto") || url.protocol.replace(":", "") || "https").split(",")[0];
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || url.host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Server-side proxy to be-crm's public founder-access lookup, so the register page
|
||||
// can prefill the paid founder's details (and confirm the token is still valid)
|
||||
// before they have a session. Server-to-server avoids CORS. The token is the only
|
||||
// credential — it was emailed to the founder / carried from /thanks.
|
||||
|
||||
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/founder-access/lookup?token=${encodeURIComponent(token)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
|
||||
return NextResponse.json(await res.json());
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Server-side proxy to be-crm's founder-access by-session lookup. The /thanks page
|
||||
// (post-Stripe) polls this with the checkout session_id until the webhook has
|
||||
// fulfilled the purchase and a founder token exists, then redirects to the
|
||||
// prefilled register page. `pending` means "poll again shortly".
|
||||
|
||||
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 sessionId = new URL(request.url).searchParams.get("session_id") ?? "";
|
||||
if (!sessionId || sessionId.length > 256) {
|
||||
return NextResponse.json({ ok: false, status: "pending" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${CRM_BASE_URL}/public/founder-access/by-session?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ ok: false, status: "pending" }, { status: 502 });
|
||||
return NextResponse.json(await res.json());
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, status: "pending" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
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[] } }>;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateLead, SELF_SERVE_MAX } from "@/lib/founder/lead";
|
||||
|
||||
// Step-1 record capture for the founder-access form, BEFORE the payment branch.
|
||||
// Validates the same required fields as checkout and tells the client which path
|
||||
// to take by license count (<=SELF_SERVE_MAX -> Stripe checkout, else -> sales).
|
||||
// No payment happens here; the authoritative "paid" fulfillment is the Stripe
|
||||
// webhook -> be-crm. Ported from the landing's api/register.js.
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const result = validateLead(body);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: "Please check the highlighted fields.", fields: result.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
const lead = result.lead;
|
||||
const path = lead.licenses > SELF_SERVE_MAX ? "sales" : "checkout";
|
||||
return NextResponse.json({ ok: true, path, licenses: lead.licenses });
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Stripe webhook — the ONLY trustworthy signal that money actually moved. On a
|
||||
// paid Founders-License checkout it hands the purchase to be-crm, which mints the
|
||||
// founder token, sends the welcome email with the register link, and marks the
|
||||
// email owner-eligible. Ported from the landing's api/stripe-webhook.mjs (which
|
||||
// pushed to LeadPulse); here the fulfilment target is be-crm.
|
||||
//
|
||||
// Env:
|
||||
// STRIPE_WEBHOOK_SECRET whsec_… (Stripe Dashboard -> Webhooks -> signing secret)
|
||||
// CRM_BASE_URL be-crm base (default https://crm.lynkedup.cloud)
|
||||
// FOUNDER_FULFILL_SECRET shared secret sent to be-crm's public fulfill endpoint
|
||||
// Stripe endpoint URL: https://<domain>/api/stripe-webhook
|
||||
// Events: checkout.session.completed (+ checkout.session.async_payment_succeeded)
|
||||
//
|
||||
// Route Handlers receive the exact raw bytes via request.arrayBuffer() (no body
|
||||
// parser), which the signature check requires. Node runtime for node:crypto.
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const TOLERANCE_SECONDS = 300; // Stripe's documented replay window
|
||||
const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, "");
|
||||
|
||||
interface StripeSession {
|
||||
id: string;
|
||||
payment_status?: string;
|
||||
customer?: string | null;
|
||||
payment_intent?: string | null;
|
||||
amount_total?: number;
|
||||
currency?: string;
|
||||
created?: number;
|
||||
metadata?: Record<string, string>;
|
||||
customer_details?: { email?: string; address?: unknown } | null;
|
||||
}
|
||||
|
||||
function verifyStripeSignature(raw: Buffer, header: string | null, secret: string): { type: string; data: { object: StripeSession } } {
|
||||
if (!header) throw new Error("Missing Stripe-Signature header");
|
||||
let timestamp: string | null = null;
|
||||
const v1: string[] = [];
|
||||
for (const part of header.split(",")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i === -1) continue;
|
||||
const key = part.slice(0, i).trim();
|
||||
const value = part.slice(i + 1).trim();
|
||||
if (key === "t") timestamp = value;
|
||||
else if (key === "v1") v1.push(value); // only v1 — ignoring v0 prevents a downgrade attack
|
||||
}
|
||||
if (!timestamp || !/^\d+$/.test(timestamp) || !v1.length) throw new Error("Malformed Stripe-Signature header");
|
||||
|
||||
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), raw]);
|
||||
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest();
|
||||
const matched = v1.some((sig) => {
|
||||
if (!/^[a-f0-9]{64}$/i.test(sig)) return false;
|
||||
const got = Buffer.from(sig, "hex");
|
||||
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
|
||||
});
|
||||
if (!matched) throw new Error("No matching v1 signature");
|
||||
|
||||
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
|
||||
if (Math.abs(age) > TOLERANCE_SECONDS) throw new Error(`Timestamp outside tolerance (age ${age}s)`);
|
||||
|
||||
return JSON.parse(raw.toString("utf8"));
|
||||
}
|
||||
|
||||
async function fulfil(session: StripeSession): Promise<void> {
|
||||
// Card payments arrive already paid; delayed methods can complete a session while
|
||||
// still unpaid. Only a paid session is a real purchase.
|
||||
if (session.payment_status !== "paid") {
|
||||
console.log("[webhook] session complete but not paid — ignoring", { id: session.id, status: session.payment_status });
|
||||
return;
|
||||
}
|
||||
const md = session.metadata || {};
|
||||
const email = (md.email || session.customer_details?.email || "").toLowerCase();
|
||||
if (!email) {
|
||||
console.error("[webhook] paid session with no email — cannot attribute", { id: session.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = process.env.FOUNDER_FULFILL_SECRET;
|
||||
if (!secret) {
|
||||
// Throw so Stripe retries once the secret is configured (nothing is lost — the
|
||||
// session metadata replays the whole purchase on retry).
|
||||
throw new Error("FOUNDER_FULFILL_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
firstName: md.first_name || "",
|
||||
lastName: md.last_name || "",
|
||||
contactName: md.contact_name || "",
|
||||
companyName: md.company_name || "",
|
||||
phone: md.phone || "",
|
||||
streetAddress: md.street_address || "",
|
||||
city: md.city || "",
|
||||
state: md.state || "",
|
||||
zip: md.zip || "",
|
||||
businessAddress: md.business_address || "",
|
||||
salesReps: md.sales_reps || "",
|
||||
licenses: Number(md.num_licenses) || 1,
|
||||
plan: md.plan || "founders",
|
||||
promoCode: md.promo_code || null,
|
||||
smsConsent: md.sms_consent === "true",
|
||||
smsConsentVersion: md.sms_consent_version || null,
|
||||
smsConsentAt: md.sms_consent_at || null,
|
||||
stripeSessionId: session.id,
|
||||
stripeCustomerId: session.customer || null,
|
||||
stripePaymentIntent: session.payment_intent || null,
|
||||
amountTotalCents: session.amount_total ?? null,
|
||||
currency: session.currency || null,
|
||||
billingAddress: session.customer_details?.address ?? null,
|
||||
purchasedAt: session.created ? new Date(session.created * 1000).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
|
||||
const res = await fetch(`${CRM_BASE_URL}/public/founder-access/fulfill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Founder-Secret": secret },
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
throw new Error(`be-crm fulfil failed: ${res.status} ${detail.slice(0, 200)}`);
|
||||
}
|
||||
console.log("[webhook] purchase fulfilled", { email, session: session.id });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
console.error("[webhook] STRIPE_WEBHOOK_SECRET is not set");
|
||||
return new Response("Webhook secret not configured", { status: 500 }); // 500 -> Stripe retries
|
||||
}
|
||||
|
||||
const raw = Buffer.from(await request.arrayBuffer());
|
||||
let event: { type: string; data: { object: StripeSession } };
|
||||
try {
|
||||
event = verifyStripeSignature(raw, request.headers.get("stripe-signature"), secret);
|
||||
} catch (err) {
|
||||
console.error("[webhook] signature verification failed:", (err as Error).message);
|
||||
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 }); // 400 -> don't retry
|
||||
}
|
||||
|
||||
try {
|
||||
if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
|
||||
await fulfil(event.data.object);
|
||||
} else {
|
||||
console.log("[webhook] ignoring event type", event.type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[webhook] handler failed", { type: event.type, error: String((err as Error)?.message) });
|
||||
return new Response("Handler error", { status: 500 }); // 500 -> Stripe retries (metadata replays the lead)
|
||||
}
|
||||
|
||||
return Response.json({ received: true });
|
||||
}
|
||||
@@ -134,21 +134,6 @@
|
||||
.dash-content { padding: 22px 28px 40px; width: 100%; }
|
||||
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
|
||||
|
||||
/* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens
|
||||
onto the CRM design system, so the drop-in SDK matches the rest of the app. */
|
||||
.dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
|
||||
.dash-root .miu-host .miu-messenger,
|
||||
.dash-root .miu-host .miu-inbox {
|
||||
--miu-bg: var(--bg);
|
||||
--miu-panel: var(--panel);
|
||||
--miu-panel-2: var(--panel-2);
|
||||
--miu-border: var(--border);
|
||||
--miu-text: var(--text);
|
||||
--miu-muted: var(--muted);
|
||||
--miu-accent: var(--orange);
|
||||
--miu-accent-text: #1a1206;
|
||||
}
|
||||
|
||||
/* ---- grid helpers ---- */
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.row { display: flex; align-items: center; }
|
||||
@@ -452,7 +437,7 @@
|
||||
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
|
||||
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
|
||||
.dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
|
||||
.dash-root .ds-modal-body { padding: 18px 20px; overflow-y: auto; }
|
||||
.dash-root .ds-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ---- Toasts ---- */
|
||||
@@ -1156,365 +1141,3 @@
|
||||
.dash-root .ai-view { height: calc(100vh - 150px); }
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* Leads — pipeline board + rich detail popup */
|
||||
/* ========================================================== */
|
||||
|
||||
/* ---- stat strip ---- */
|
||||
.dash-root .leads-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .leads-stat { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); position: relative; overflow: hidden; }
|
||||
.dash-root .leads-stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--orange); }
|
||||
.dash-root .leads-stat.tone-blue::before { background: var(--blue); }
|
||||
.dash-root .leads-stat.tone-purple::before { background: var(--purple); }
|
||||
.dash-root .leads-stat.tone-green::before { background: var(--green); }
|
||||
.dash-root .leads-stat.tone-orange::before { background: var(--orange); }
|
||||
.dash-root .leads-stat-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .leads-stat.tone-blue .leads-stat-ic { background: color-mix(in srgb, var(--blue) 16%, transparent); color: #6f9bff; }
|
||||
.dash-root .leads-stat.tone-purple .leads-stat-ic { background: color-mix(in srgb, var(--purple) 16%, transparent); color: #b07bf2; }
|
||||
.dash-root .leads-stat.tone-green .leads-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .leads-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .leads-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
/* ---- toolbar ---- */
|
||||
.dash-root .leads-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.dash-root .leads-search { display: flex; align-items: center; gap: 9px; flex: 1 1 260px; min-width: 220px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .leads-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .leads-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .leads-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .leads-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .leads-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .leads-tabs { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); }
|
||||
.dash-root .leads-tab { border: 0; background: none; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .leads-tab:hover { color: var(--text-2); }
|
||||
.dash-root .leads-tab.active { background: var(--orange); color: #1a1205; box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--orange) 60%, transparent); }
|
||||
|
||||
/* ---- board ---- */
|
||||
.dash-root .leads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||
.dash-root .lead-card { text-align: left; width: 100%; cursor: pointer; display: flex; flex-direction: column; gap: 11px; padding: 16px 17px; border-radius: 18px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi), 0 1px 2px rgba(0,0,0,0.18); font-family: inherit; color: var(--text); transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; }
|
||||
.dash-root .lead-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--orange) 40%, var(--border)); box-shadow: var(--shadow), 0 14px 34px -20px color-mix(in srgb, var(--orange) 50%, transparent); }
|
||||
.dash-root .lead-card-top { display: flex; align-items: center; gap: 12px; }
|
||||
.dash-root .lead-ava { position: relative; border-radius: 50%; padding: 3px; display: inline-flex; }
|
||||
.dash-root .lead-ava.prio-high { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-medium { box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-low { box-shadow: 0 0 0 2px var(--border-2); }
|
||||
.dash-root .lead-card-id { flex: 1; min-width: 0; }
|
||||
.dash-root .lead-card-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.dash-root .lead-card-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||
.dash-root .lead-card-sub svg { color: var(--orange); }
|
||||
.dash-root .lead-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lead-card-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .lead-card-foot { display: flex; align-items: center; gap: 8px; margin-top: 3px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.dash-root .lead-source { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted); }
|
||||
.dash-root .lead-card-spacer { flex: 1; }
|
||||
.dash-root .lead-rep { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
|
||||
.dash-root .lead-updated { font-size: 10.5px; color: var(--faint, var(--muted)); }
|
||||
|
||||
.dash-root .leads-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; padding: 48px 20px; color: var(--muted); }
|
||||
.dash-root .leads-empty svg { color: var(--muted); margin-bottom: 6px; }
|
||||
.dash-root .leads-empty h3 { font-size: 16px; color: var(--text); }
|
||||
|
||||
/* ---- detail popup ---- */
|
||||
.dash-root .lead-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .ld-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .ld-identity-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .ld-identity-pills { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.dash-root .ld-storm { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--orange) 30%, var(--border)); background: color-mix(in srgb, var(--orange) 9%, transparent); }
|
||||
.dash-root .ld-storm-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .ld-storm-zone { font-size: 13.5px; font-weight: 700; }
|
||||
.dash-root .ld-storm-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.dash-root .ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .ld-section { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .ld-section.wide { grid-column: 1 / -1; }
|
||||
.dash-root .ld-section-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .ld-section-body { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dash-root .ld-dl { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .ld-dl:last-child { border-bottom: 0; }
|
||||
.dash-root .ld-dl-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-dl-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .ld-assign { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
|
||||
.dash-root .ld-sublabel { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--faint, var(--muted)); font-weight: 700; margin: 8px 0 6px; }
|
||||
.dash-root .ld-sublabel:first-child { margin-top: 0; }
|
||||
.dash-root .ld-contact-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12.5px; color: var(--text); }
|
||||
.dash-root .ld-contact-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-contact-val { font-weight: 600; }
|
||||
.dash-root .ld-contact-tag { font-size: 10.5px; color: var(--muted); padding: 2px 7px; border-radius: 99px; background: var(--panel-3); }
|
||||
.dash-root .ld-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.5; margin-top: 2px; }
|
||||
|
||||
/* ---- New Lead form ---- */
|
||||
.dash-root .nl-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .nl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||
.dash-root .nl-grid .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-grid > .ds-field, .dash-root .nl-grid > .nl-full { margin-bottom: 14px; }
|
||||
.dash-root .nl-full { grid-column: 1 / -1; }
|
||||
.dash-root .nl-full .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-prio { display: flex; gap: 8px; }
|
||||
.dash-root .nl-prio-btn { flex: 1; height: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-prio-btn:hover { color: var(--text-2); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.low { background: color-mix(in srgb, var(--muted) 22%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.medium { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.active.high { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.standard { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.urg.active.high { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.emergency { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
/* multi-value rows (phones / emails) */
|
||||
.dash-root .nl-multirow { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.dash-root .nl-multirow .ds-input { flex: 1; }
|
||||
.dash-root .nl-typesel { flex: 0 0 108px; width: 108px; }
|
||||
.dash-root .nl-rowx { flex: 0 0 auto; width: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .nl-rowx:hover { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
||||
.dash-root .nl-add { display: inline-flex; align-items: center; gap: 6px; margin-top: 2px; padding: 8px 13px; border-radius: 10px; border: 1px dashed var(--border-2); background: none; color: var(--orange); font-family: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-add:hover { background: color-mix(in srgb, var(--orange) 10%, transparent); border-color: color-mix(in srgb, var(--orange) 45%, transparent); }
|
||||
.dash-root .nl-empty { font-size: 12.5px; color: var(--faint, var(--muted)); padding: 8px 0 10px; }
|
||||
|
||||
/* site photos dropzone */
|
||||
.dash-root .nl-photos { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 22px; border-radius: 14px; border: 1.5px dashed var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-photos:hover { border-color: color-mix(in srgb, var(--orange) 50%, transparent); color: var(--orange); background: color-mix(in srgb, var(--orange) 7%, transparent); }
|
||||
.dash-root .nl-photos-t { font-size: 13px; font-weight: 700; color: var(--text-2); }
|
||||
.dash-root .nl-photos:hover .nl-photos-t { color: var(--orange); }
|
||||
.dash-root .nl-photos-s { font-size: 11px; }
|
||||
.dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
||||
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
|
||||
|
||||
/* Canvasser search (Door Knock source) */
|
||||
.dash-root .nl-canvasser { position: relative; }
|
||||
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
|
||||
.dash-root .nl-canvasser-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: none; border-radius: 9px; background: transparent; color: var(--text); font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-opt:hover { background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-name { font-weight: 700; }
|
||||
.dash-root .nl-canvasser-email { color: var(--muted); font-size: 12px; }
|
||||
.dash-root .nl-canvasser-opt .nl-canvasser-email { margin-left: auto; }
|
||||
.dash-root .nl-canvasser-empty { padding: 10px; color: var(--muted); font-size: 12.5px; text-align: center; }
|
||||
.dash-root .nl-canvasser-chip { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-chip .nl-canvasser-email { margin-left: 2px; }
|
||||
.dash-root .nl-canvasser-clear { margin-left: auto; display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-clear:hover { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); }
|
||||
|
||||
/* ========================================================== */
|
||||
/* Lead Verification — stat tiles + filters + table */
|
||||
/* ========================================================== */
|
||||
.dash-root .lv-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .lv-stat { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); cursor: pointer; text-align: left; font-family: inherit; transition: 0.15s; position: relative; }
|
||||
.dash-root .lv-stat:hover { border-color: var(--border-2); transform: translateY(-2px); }
|
||||
.dash-root .lv-stat.active { border-color: color-mix(in srgb, var(--accent, var(--orange)) 60%, transparent); box-shadow: var(--card-hi), 0 0 0 1px color-mix(in srgb, var(--accent, var(--orange)) 40%, transparent); }
|
||||
.dash-root .lv-stat-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; margin-bottom: 6px; }
|
||||
.dash-root .lv-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .lv-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; }
|
||||
.dash-root .lv-stat.tone-green { --accent: var(--green); } .dash-root .lv-stat.tone-green .lv-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .lv-stat.tone-orange { --accent: var(--orange); } .dash-root .lv-stat.tone-orange .lv-stat-ic { background: color-mix(in srgb, var(--orange) 16%, transparent); color: var(--orange); }
|
||||
.dash-root .lv-stat.tone-blue { --accent: var(--blue); } .dash-root .lv-stat.tone-blue .lv-stat-ic { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
|
||||
.dash-root .lv-stat.tone-purple { --accent: var(--purple); } .dash-root .lv-stat.tone-purple .lv-stat-ic { background: color-mix(in srgb, var(--purple) 18%, transparent); color: #b07bf2; }
|
||||
.dash-root .lv-stat.tone-red { --accent: var(--red); } .dash-root .lv-stat.tone-red .lv-stat-ic { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); }
|
||||
|
||||
.dash-root .lv-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.dash-root .lv-search { display: flex; align-items: center; gap: 9px; flex: 1 1 240px; min-width: 200px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .lv-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .lv-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .lv-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .lv-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .lv-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .lv-filter { height: 42px; flex: 0 0 auto; width: auto; min-width: 150px; }
|
||||
|
||||
.dash-root .lv-tablewrap { border: 1px solid var(--border); border-radius: 18px; background: var(--card-grad); box-shadow: var(--card-hi); overflow-x: auto; }
|
||||
.dash-root .lv-table { width: 100%; border-collapse: collapse; min-width: 940px; }
|
||||
.dash-root .lv-table thead th { text-align: left; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 14px 16px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.dash-root .lv-table tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 12.5px; vertical-align: middle; }
|
||||
.dash-root .lv-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.dash-root .lv-table tbody tr { transition: background 0.12s; }
|
||||
.dash-root .lv-table tbody tr:hover { background: color-mix(in srgb, var(--orange) 5%, transparent); }
|
||||
.dash-root .lv-id { font-family: ui-monospace, monospace; font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
||||
.dash-root .lv-id-date { font-size: 10.5px; color: var(--faint, var(--muted)); margin-top: 2px; }
|
||||
.dash-root .lv-cust { display: flex; align-items: center; gap: 10px; min-width: 210px; }
|
||||
.dash-root .lv-cust-name { font-weight: 700; font-size: 13px; color: var(--text); }
|
||||
.dash-root .lv-cust-addr { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.dash-root .lv-phone { color: var(--text-2); white-space: nowrap; }
|
||||
.dash-root .lv-source { display: inline-block; font-size: 11.5px; font-weight: 600; color: var(--text-2); padding: 4px 10px; border-radius: 99px; background: var(--panel-3); white-space: nowrap; }
|
||||
.dash-root .lv-assignee { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.dash-root .lv-assignee span { font-weight: 600; color: var(--text-2); font-size: 12px; }
|
||||
.dash-root .lv-unassigned { font-size: 11.5px; color: var(--faint, var(--muted)); }
|
||||
.dash-root .lv-verif { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; white-space: nowrap; color: var(--muted); }
|
||||
.dash-root .lv-verif.v-verified { color: var(--green); }
|
||||
.dash-root .lv-verif.v-in_progress { color: var(--orange); }
|
||||
.dash-root .lv-verif.v-assigned { color: #6f9bff; }
|
||||
.dash-root .lv-verif.v-pending { color: #b07bf2; }
|
||||
.dash-root .lv-verif.v-unverified { color: var(--red); }
|
||||
.dash-root .lv-created { color: var(--muted); white-space: nowrap; }
|
||||
.dash-root .lv-rowacts { display: flex; gap: 6px; }
|
||||
.dash-root .lv-act { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .lv-act:hover { color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .lv-act.primary:hover { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); background: color-mix(in srgb, var(--green) 10%, transparent); }
|
||||
.dash-root .lv-empty { text-align: center; color: var(--muted); padding: 40px 16px; font-size: 13px; }
|
||||
.dash-root .lv-count { font-size: 11.5px; color: var(--muted); margin-top: 12px; text-align: right; }
|
||||
|
||||
/* ---- row actions dropdown (portalled to body) ---- */
|
||||
.lv-menu-scrim { position: fixed; inset: 0; z-index: 90; }
|
||||
.lv-menu { position: fixed; z-index: 91; width: 188px; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2, rgba(255,255,255,0.12)); background: var(--panel, #0e0e13); box-shadow: 0 18px 44px -18px rgba(0,0,0,0.7); animation: ds-rise 0.13s ease; }
|
||||
.lv-menu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2, #eaeaea); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.lv-menu-item:hover { background: var(--panel-3, #1b1b22); color: var(--text, #fff); }
|
||||
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
|
||||
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
|
||||
|
||||
/* ---- verification detail popup ---- */
|
||||
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .lv-d-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .lv-d-pills { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.dash-root .lv-d-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .lv-d-section, .dash-root .lv-d-notes, .dash-root .lv-d-activity { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .lv-d-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .lv-d-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .lv-d-row:last-child { border-bottom: 0; }
|
||||
.dash-root .lv-d-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lv-d-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .lv-d-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.55; margin-top: 2px; }
|
||||
|
||||
.dash-root .lv-timeline { list-style: none; margin: 0; padding: 0; }
|
||||
.dash-root .lv-tl-item { position: relative; display: flex; gap: 12px; padding: 0 0 16px 4px; }
|
||||
.dash-root .lv-tl-item::before { content: ""; position: absolute; left: 8px; top: 14px; bottom: -2px; width: 1.5px; background: var(--border-2); }
|
||||
.dash-root .lv-tl-item:last-child { padding-bottom: 0; }
|
||||
.dash-root .lv-tl-item:last-child::before { display: none; }
|
||||
.dash-root .lv-tl-dot { position: relative; z-index: 1; flex: 0 0 auto; width: 10px; height: 10px; margin-top: 4px; border-radius: 50%; background: var(--orange); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 20%, transparent); }
|
||||
.dash-root .lv-tl-text { font-size: 12.5px; color: var(--text); font-weight: 600; }
|
||||
.dash-root .lv-tl-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .lv-d-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .leads-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.dash-root .ld-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .ld-assign { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(3, 1fr); }
|
||||
.dash-root .lv-filter { flex: 1 1 45%; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dash-root .leads-stats { grid-template-columns: 1fr; }
|
||||
.dash-root .leads-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Smart Gallery — the embedded @photo-gallery/sdk surface.
|
||||
The SDK is themed entirely through the token map in lib/gallery-api.ts
|
||||
(--apg-* -> this file's own vars), so the rules below only handle the
|
||||
host chrome: sizing, the demo banner, and the load/error placeholders.
|
||||
========================================================================= */
|
||||
|
||||
/* The gallery is the one view that wants the whole viewport: it has its own sidebar, toolbar and
|
||||
scrollers, so any height we leave on the table is wasted chrome. The old big PageHead cost ~90px;
|
||||
a slim header (~40px) + compact banner + tight gaps hand almost all of that back to the shell.
|
||||
96px = the dashboard's top padding + the slim header row; `.gal-shell` (flex:1; min-height:0)
|
||||
consumes whatever is left after the header and the optional demo banner. */
|
||||
.dash-root .gal { display: flex; flex-direction: column; gap: 10px; height: calc(100vh - 96px); min-height: 700px; }
|
||||
|
||||
/* Slim inline header — replaces the tall PageHead. One row, ~40px, so the shell keeps the height. */
|
||||
.dash-root .gal-head { display: flex; align-items: center; gap: 10px; min-height: 36px; flex: 0 0 auto; }
|
||||
.dash-root .gal-head-ic { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); flex: 0 0 auto; }
|
||||
.dash-root .gal-head-title { font-size: 16px; font-weight: 700; line-height: 1.1; margin: 0; }
|
||||
.dash-root .gal-head-sub { color: var(--muted); font-size: 12.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
@media (max-width: 720px) { .dash-root .gal-head-sub { display: none; } }
|
||||
|
||||
/* `.view` sets `z-index: 1`, which makes it a stacking context and traps the SDK's
|
||||
full-screen overlays (lightbox z1000, editors z1050, camera z1080, modals z1100)
|
||||
underneath the topbar's `z-index: 20`. Opting this one view out of the stacking
|
||||
context lets those overlays cover the whole dashboard, as they must. The view still
|
||||
paints above the ambient `.dash-content::before` glow because it follows it in the DOM. */
|
||||
.dash-root .view.gal { z-index: auto; }
|
||||
|
||||
/* Compact single-line demo banner (~34px). Truncates rather than wrapping so it never steals a
|
||||
second row of height from the shell. */
|
||||
.dash-root .gal-banner { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 6px 12px; border-radius: 11px; border: 1px solid var(--border); background: color-mix(in srgb, var(--orange) 9%, var(--panel-2)); color: var(--text-2); font-size: 12px; font-weight: 500; flex: 0 0 auto; white-space: nowrap; overflow: hidden; }
|
||||
.dash-root .gal-banner span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-root .gal-banner svg { color: var(--orange); flex: 0 0 auto; }
|
||||
|
||||
/* The gallery's own viewport. `overflow: hidden` keeps the SDK's internal scrollers
|
||||
in charge; its full-screen overlays (lightbox/editor/camera) are position:fixed
|
||||
and deliberately escape this box to cover the whole dashboard. */
|
||||
.dash-root .gal-shell { flex: 1; min-height: 0; position: relative; border-radius: 18px; border: 1px solid var(--border); background: var(--panel-2); overflow: hidden; box-shadow: var(--card-hi), 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||
.dash-root[data-theme="dark"] .gal-shell { border: 0.5px solid #452b1a; border-radius: 20px; }
|
||||
|
||||
/* The SDK's embedded root fills this box. (--apg-overlay-top is set from the
|
||||
component's `style` prop — the SDK writes an inline default that a stylesheet
|
||||
rule could not override.) */
|
||||
.dash-root .gal-shell .apg { height: 100%; }
|
||||
|
||||
/* ---- Fullscreen (the SDK puts `.apg--fullscreen` on its root: position:fixed; inset:0) ----
|
||||
A position:fixed box is only clipped by an ancestor that is its CONTAINING BLOCK, which
|
||||
`overflow`/`border-radius`/`box-shadow` alone never create — only transform / filter /
|
||||
perspective / backdrop-filter / will-change / contain do. Nothing on the path
|
||||
(.dash-content > .view.gal > .gal-shell) uses any of those: `.view`'s `ds-fade` animates
|
||||
opacity only, and `.view.gal` already drops the `z-index: 1` stacking context. So the
|
||||
fullscreen root does escape today — these rules make that survive an edit above. */
|
||||
|
||||
/* `.dash-root .gal-shell .apg` (0,3,0) would otherwise out-specify the SDK's own sizing; with
|
||||
inset:0 driving the box, height must get out of the way. */
|
||||
.dash-root .gal-shell .apg.apg--fullscreen {
|
||||
height: auto;
|
||||
/* Above the topbar (z-index: 20) and the sidebar, below the SDK's own overlays (1000+). */
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* Belt and braces: if a future rule ever DOES make `.gal-shell` a containing block, an
|
||||
`overflow: hidden` on it would crop the fullscreen root to the embedded box. Drop the clip
|
||||
(and the rounded corner it exists to enforce) for exactly as long as fullscreen is on. */
|
||||
.dash-root .gal-shell:has(.apg--fullscreen) { overflow: visible; }
|
||||
|
||||
.dash-root .gal-placeholder { height: 100%; min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 28px; }
|
||||
.dash-root .gal-placeholder-ic { width: 66px; height: 66px; border-radius: 20px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); }
|
||||
.dash-root .gal-placeholder p { color: var(--muted); font-size: 13px; max-width: 420px; }
|
||||
.dash-root .gal-placeholder h3 { font-size: 16px; font-weight: 700; }
|
||||
.dash-root .gal-placeholder-error .gal-placeholder-ic { background: color-mix(in srgb, var(--red) 88%, #000); box-shadow: 0 10px 28px -12px color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
/* Narrower chrome: a little less top offset, and a smaller floor so short viewports still work. */
|
||||
.dash-root .gal { height: calc(100vh - 84px); min-height: 560px; }
|
||||
}
|
||||
|
||||
/* ---- Org Settings → Integrations ---- */
|
||||
.dash-root .settings-section { margin-top: 8px; }
|
||||
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
|
||||
.dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
|
||||
.dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .settings-card.is-soon { opacity: 0.6; }
|
||||
.dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; }
|
||||
.dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); }
|
||||
.dash-root .settings-card-sub { font-weight: 500; color: var(--muted); }
|
||||
.dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
||||
.dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.dash-root .settings-kv { display: grid; gap: 10px; margin: 0; }
|
||||
.dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
.dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||
.dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); }
|
||||
.dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
|
||||
.dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
|
||||
.dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
||||
|
||||
/* ---- Global conversation search (topbar) ---- */
|
||||
.dash-root .gs-wrap { position: relative; }
|
||||
.dash-root .gs-field { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 42vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
|
||||
.dash-root .gs-field:focus-within { border-color: var(--orange); }
|
||||
.dash-root .gs-input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; }
|
||||
.dash-root .gs-input::placeholder { color: var(--muted); }
|
||||
.dash-root .gs-pop { position: absolute; top: calc(100% + 6px); right: 0; width: 420px; max-width: 90vw; max-height: 420px; overflow-y: auto; padding: 6px; border-radius: 14px; border: 1px solid var(--border); background: var(--panel); box-shadow: 0 24px 60px -20px rgba(0,0,0,0.55); z-index: 60; }
|
||||
.dash-root .gs-empty { padding: 14px; font-size: 13px; color: var(--muted); text-align: center; }
|
||||
.dash-root .gs-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: none; border-radius: 10px; cursor: pointer; text-align: left; color: var(--text); }
|
||||
.dash-root .gs-row:hover { background: var(--panel-2); }
|
||||
.dash-root .gs-ic { flex: 0 0 auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.dash-root .gs-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.dash-root .gs-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
|
||||
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
||||
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
|
||||
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
||||
|
||||
@@ -15,51 +15,17 @@ 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";
|
||||
|
||||
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; });
|
||||
}
|
||||
@@ -70,29 +36,20 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dash-root" data-theme={theme}>
|
||||
<RealtimeProvider>
|
||||
<Sidebar active={active} onSelect={setActive} />
|
||||
<div className="dash-main">
|
||||
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
|
||||
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} />
|
||||
<div className="dash-content">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return esc.replace(/<em>/g, "<em>").replace(/<\/em>/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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
// ============================================================
|
||||
// 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"];
|
||||
@@ -1,615 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
@@ -3,15 +3,20 @@
|
||||
// ============================================================
|
||||
// Profile — header card + 6 tabs:
|
||||
// Personal Info · KYC · Security · Notifications · Privacy · Devices
|
||||
//
|
||||
// Personal / KYC / Notifications / Privacy are served by the be-crm profile
|
||||
// module (via useProfileData → live data door, or the local mock when the Shell
|
||||
// isn't configured). Security & Devices are Shell-owned and stay on the mock.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
user, kycDocTypes, kyc as kycSeed, contactPrefs, contactDestinations,
|
||||
contactTimezones, contactTimes, loginActivity, consentGroups,
|
||||
passwordStrength, maskEmail, maskPhone,
|
||||
type KycSlot, type NotifCategory, type ConsentGroup,
|
||||
user, loginActivity, passwordStrength,
|
||||
} from "./account-data";
|
||||
import {
|
||||
useProfileData,
|
||||
type ProfileData, type UiAccount, type UiKycSlot, type ContactKind, type KycKey, type NotifChannel, type UploadFile,
|
||||
} from "@/lib/profile-api";
|
||||
import {
|
||||
Avatar, Btn, Field, Icon, Modal, OtpField, PageHead, Pill, SegTabs,
|
||||
StatusDot, Toggle, useToast,
|
||||
@@ -26,7 +31,27 @@ const TABS = [
|
||||
{ value: "devices", label: "Devices", icon: "devices" },
|
||||
];
|
||||
|
||||
/** Read a picked File into the base64 shape a data-door command carries. */
|
||||
function readFileAsUpload(file: File): Promise<UploadFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = String(reader.result).split(",")[1] ?? "";
|
||||
resolve({ fileName: file.name, contentType: file.type || "application/octet-stream", base64 });
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error("could not read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Turn a thrown data-door error into a short, readable toast message. */
|
||||
function errText(e: unknown): string {
|
||||
const m = e instanceof Error ? e.message : String(e);
|
||||
return m.replace(/^\d+\s*[-:]\s*/, "").slice(0, 160) || "Something went wrong.";
|
||||
}
|
||||
|
||||
export function Profile() {
|
||||
const p = useProfileData();
|
||||
const [tab, setTab] = useState("personal");
|
||||
|
||||
return (
|
||||
@@ -36,19 +61,26 @@ export function Profile() {
|
||||
title="Profile"
|
||||
subtitle="Manage your identity, verification, security and preferences."
|
||||
icon="user"
|
||||
actions={<Pill tone="green"><StatusDot status="online" /> Account active</Pill>}
|
||||
actions={<Pill tone={p.account.status === "active" ? "green" : "red"}><StatusDot status={p.account.status === "active" ? "online" : "offline"} /> Account {p.account.status}</Pill>}
|
||||
/>
|
||||
|
||||
<ProfileHeaderCard />
|
||||
{p.error && (
|
||||
<div className="callout tone-red" style={{ marginBottom: 16 }}>
|
||||
<Icon name="alert" size={18} />
|
||||
<div><strong>Couldn't load your profile.</strong><span>{p.error}</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileHeaderCard account={p.account} />
|
||||
|
||||
<SegTabs tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
<div className="view-body">
|
||||
{tab === "personal" && <PersonalInfoTab />}
|
||||
{tab === "kyc" && <KycTab />}
|
||||
{tab === "personal" && <PersonalInfoTab p={p} />}
|
||||
{tab === "kyc" && <KycTab p={p} />}
|
||||
{tab === "security" && <SecurityTab />}
|
||||
{tab === "notifications" && <NotificationsTab />}
|
||||
{tab === "privacy" && <PrivacyTab />}
|
||||
{tab === "notifications" && <NotificationsTab p={p} />}
|
||||
{tab === "privacy" && <PrivacyTab p={p} />}
|
||||
{tab === "devices" && <DevicesTab />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,35 +91,36 @@ export function Profile() {
|
||||
/* Header card */
|
||||
/* ============================================================ */
|
||||
|
||||
function ProfileHeaderCard() {
|
||||
function ProfileHeaderCard({ account }: { account: UiAccount }) {
|
||||
const { push } = useToast();
|
||||
const locale = [account.sector, account.pocket].filter(Boolean).join(" · ") || "Location on file";
|
||||
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 />
|
||||
<Avatar initials={account.initials} gradient={account.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}
|
||||
{account.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-role">{account.role} · Member since {account.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>
|
||||
<span className="hero-chip"><Icon name="pin" size={13} /> {locale}</span>
|
||||
{account.category && <span className="hero-chip"><Icon name="owners" size={13} /> {account.category}</span>}
|
||||
{account.plot && <span className="hero-chip accent"><Icon name="check" size={13} /> House {account.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)" />
|
||||
<HeroStat value={account.category ?? "—"} label="Category" />
|
||||
<HeroStat value={account.size ?? "—"} label="House size" />
|
||||
<HeroStat value={`${account.kycCompletionPct}%`} label="KYC complete" accent="var(--orange)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -105,31 +138,30 @@ function HeroStat({ value, label, accent }: { value: string; label: string; acce
|
||||
/* Tab: Personal Info — verify-to-reveal + change email/mobile */
|
||||
/* ============================================================ */
|
||||
|
||||
type ContactKind = "email" | "mobile" | "whatsapp";
|
||||
|
||||
function PersonalInfoTab() {
|
||||
function PersonalInfoTab({ p }: { p: ProfileData }) {
|
||||
const { push } = useToast();
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [plot, setPlot] = useState("");
|
||||
const { account } = p;
|
||||
const verified = account.revealed;
|
||||
const [house, setHouse] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
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}).`);
|
||||
async function verify() {
|
||||
if (!house.trim()) return;
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const ok = await p.reveal(house.trim());
|
||||
if (ok) 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.");
|
||||
} catch (e) { setErr(errText(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
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) },
|
||||
const contactRows: { key: ContactKind; label: string; value: string }[] = [
|
||||
{ key: "email", label: "Email", value: account.email },
|
||||
{ key: "mobile", label: "Mobile", value: account.mobile },
|
||||
{ key: "whatsapp", label: "WhatsApp", value: account.whatsapp },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -150,10 +182,10 @@ function PersonalInfoTab() {
|
||||
<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(""); }}
|
||||
<input className="ds-input" placeholder="House number" value={house} maxLength={16}
|
||||
onChange={(e) => { setHouse(e.target.value); setErr(""); }}
|
||||
onKeyDown={(e) => e.key === "Enter" && verify()} />
|
||||
<Btn icon="check" onClick={verify}>Reveal</Btn>
|
||||
<Btn icon="check" onClick={verify} disabled={busy || !house.trim()}>{busy ? "Verifying…" : "Reveal"}</Btn>
|
||||
</div>
|
||||
{err && <div className="ds-field-err"><Icon name="alert" size={12} /> {err}</div>}
|
||||
</div>
|
||||
@@ -164,7 +196,7 @@ function PersonalInfoTab() {
|
||||
<div className="kv" key={f.key}>
|
||||
<dt>{f.label}</dt>
|
||||
<dd className={verified ? "kv-edit" : "masked"}>
|
||||
{verified ? f.full : f.masked}
|
||||
{f.value}
|
||||
{verified && (
|
||||
<button className="kv-edit-btn" aria-label={`Edit ${f.label}`} onClick={() => setChange(f.key)}><Icon name="edit" size={13} /></button>
|
||||
)}
|
||||
@@ -188,64 +220,82 @@ function PersonalInfoTab() {
|
||||
: <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>Full name</dt><dd>{account.name}</dd></div>
|
||||
<div className="kv"><dt>Role</dt><dd>{account.role}</dd></div>
|
||||
{account.allotmentNo && <div className="kv"><dt>Allotment no.</dt><dd>{account.allotmentNo}</dd></div>}
|
||||
<div className="kv"><dt>Member since</dt><dd>{account.memberSince}</dd></div>
|
||||
<div className="kv"><dt>Account status</dt><dd><Pill tone={account.status === "active" ? "green" : "red"}><StatusDot status={account.status === "active" ? "online" : "offline"} /> {account.status === "active" ? "Active" : "Suspended"}</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>
|
||||
<dd className={verified ? "kv-edit" : "masked"}>{account.pan}{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>
|
||||
<dd className={verified ? "kv-edit" : "masked"}>{account.aadhaar}{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 & Aadhaar are managed under the KYC tab and can't be edited here.</p>
|
||||
</div>
|
||||
|
||||
<ChangeContactModal
|
||||
kind={change}
|
||||
current={change ? contacts[change] : ""}
|
||||
onClose={() => setChange(null)}
|
||||
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
|
||||
/>
|
||||
<ChangeContactModal p={p} kind={change} onClose={() => setChange(null)} />
|
||||
</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;
|
||||
}) {
|
||||
// OTP-to-registered-mobile flow for changing a contact field. The one-time code
|
||||
// is always sent to the registered mobile (never the new destination), then the
|
||||
// new value is persisted only after the code verifies.
|
||||
function ChangeContactModal({ p, kind, onClose }: { p: ProfileData; kind: null | ContactKind; onClose: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [step, setStep] = useState<"enter" | "otp">("enter");
|
||||
const [val, setVal] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [challengeId, setChallengeId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 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]);
|
||||
if (kind) { setStep("enter"); setVal(""); setOtp(""); setChallengeId(""); setBusy(false); }
|
||||
}, [kind]);
|
||||
|
||||
function close() { setStep("enter"); setVal(""); setOtp(""); onClose(); }
|
||||
|
||||
const isEmail = kind === "email";
|
||||
const label = kind ? CONTACT_LABEL[kind] : "";
|
||||
|
||||
async function sendCode() {
|
||||
if (!kind) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const id = await p.requestContactChangeOtp(kind, val.trim());
|
||||
setChallengeId(id); setStep("otp");
|
||||
push({ tone: "info", title: "Code sent", desc: "A one-time code was sent to your registered mobile." });
|
||||
} catch (e) { push({ tone: "error", title: "Couldn't send code", desc: errText(e) }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function confirm() {
|
||||
if (!kind) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await p.changeContact(kind, val.trim(), challengeId, otp);
|
||||
push({ tone: "success", title: `${label[0].toUpperCase()}${label.slice(1)} updated`, desc: "Your change has been confirmed." });
|
||||
close();
|
||||
} catch (e) { push({ tone: "error", title: "Couldn't confirm", desc: errText(e) }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
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}`}
|
||||
subtitle={step === "enter" ? "We'll send a code to your registered mobile to confirm." : "Enter the 6-digit code sent to your registered mobile."}
|
||||
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></>
|
||||
? <><Btn variant="ghost" onClick={close}>Cancel</Btn><Btn icon="send" disabled={busy || !val.trim()} onClick={sendCode}>{busy ? "Sending…" : "Send code"}</Btn></>
|
||||
: <><Btn variant="ghost" onClick={() => setStep("enter")}>Back</Btn><Btn icon="check" disabled={busy || otp.length < 6} onClick={confirm}>{busy ? "Confirming…" : "Confirm"}</Btn></>
|
||||
}
|
||||
>
|
||||
{step === "enter" ? (
|
||||
@@ -255,7 +305,7 @@ function ChangeContactModal({ kind, current, onClose, onSave }: {
|
||||
) : (
|
||||
<div className="otp-wrap">
|
||||
<OtpField value={otp} onChange={setOtp} />
|
||||
<button className="link-inline" onClick={() => push({ tone: "info", title: "Code resent" })}>Resend code</button>
|
||||
<button className="link-inline" onClick={sendCode} disabled={busy}>Resend code</button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -266,54 +316,52 @@ function ChangeContactModal({ kind, current, onClose, onSave }: {
|
||||
/* Tab: KYC — inline upload, ID≠address, photo mandatory, % */
|
||||
/* ============================================================ */
|
||||
|
||||
function KycTab() {
|
||||
function KycTab({ p }: { p: ProfileData }) {
|
||||
const { push } = useToast();
|
||||
const [slots, setSlots] = useState<KycSlot[]>(kycSeed.slots);
|
||||
const { slots, docTypes, completionPct } = p.kyc;
|
||||
// Doc type chosen for a slot but not yet uploaded (be-crm only persists it on upload).
|
||||
const [pending, setPending] = useState<Partial<Record<KycKey, string>>>({});
|
||||
|
||||
const idDoc = slots.find((s) => s.key === "id")?.docId ?? null;
|
||||
const addrDoc = slots.find((s) => s.key === "address")?.docId ?? null;
|
||||
const docIdFor = (key: KycKey) => pending[key] ?? slots.find((s) => s.key === key)?.docId ?? null;
|
||||
const idDoc = docIdFor("id");
|
||||
const addrDoc = docIdFor("address");
|
||||
|
||||
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"]) {
|
||||
function options(key: KycKey) {
|
||||
const want = key === "id" ? ["id", "any"] : key === "address" ? ["address", "any"] : [];
|
||||
return kycDocTypes.filter((d) => want.includes(d.use));
|
||||
return docTypes.filter((d) => want.includes(d.use));
|
||||
}
|
||||
|
||||
function pickDoc(key: KycSlot["key"], docId: string) {
|
||||
// enforce two-different-documents rule between ID and address
|
||||
function pickDoc(key: KycKey, docId: string) {
|
||||
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));
|
||||
setPending((s) => ({ ...s, [key]: docId }));
|
||||
}
|
||||
async function upload(key: KycKey, file: File) {
|
||||
const docId = key === "photo" ? null : docIdFor(key);
|
||||
if (key !== "photo" && !docId) { push({ tone: "error", title: "Select a document type first" }); return; }
|
||||
try {
|
||||
await p.uploadKyc(key, docId, await readFileAsUpload(file));
|
||||
setPending((s) => { const n = { ...s }; delete n[key]; return n; });
|
||||
push({ tone: "success", title: "Uploaded", desc: "Your document is queued for review." });
|
||||
} catch (e) { push({ tone: "error", title: "Upload failed", desc: errText(e) }); }
|
||||
}
|
||||
async function remove(key: KycKey) {
|
||||
try { await p.removeKyc(key); push({ tone: "info", title: "Removed" }); }
|
||||
catch (e) { push({ tone: "error", title: "Couldn't remove", desc: errText(e) }); }
|
||||
}
|
||||
|
||||
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";
|
||||
const photoDone = (slots.find((s) => s.key === "photo")?.status ?? "missing") !== "missing";
|
||||
const docLabel = (id: string | null) => docTypes.find((d) => d.id === id)?.label ?? "Not chosen";
|
||||
|
||||
return (
|
||||
<div className="grid-side">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h3>Verification documents</h3>
|
||||
<Pill tone="orange">{completion}% complete</Pill>
|
||||
<Pill tone="orange">{completionPct}% complete</Pill>
|
||||
</div>
|
||||
|
||||
<div className="kyc-progress">
|
||||
<div className="kyc-progress-bar"><span style={{ width: `${completion}%` }} /></div>
|
||||
<div className="kyc-progress-bar"><span style={{ width: `${completionPct}%` }} /></div>
|
||||
<div className="kyc-progress-legend">
|
||||
{slots.map((s) => (
|
||||
<span key={s.key} className={`kyc-leg ${s.status}`}>
|
||||
@@ -327,7 +375,8 @@ function KycTab() {
|
||||
|
||||
<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)} />
|
||||
<KycSlotRow key={slot.key} slot={slot} docId={docIdFor(slot.key)} docTypes={docTypes} options={options(slot.key)}
|
||||
onPick={(d) => pickDoc(slot.key, d)} onUpload={(f) => upload(slot.key, f)} onRemove={() => remove(slot.key)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,19 +392,22 @@ function KycTab() {
|
||||
</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 className="pair-row"><span>Identity</span><Pill tone="blue">{docLabel(idDoc)}</Pill></div>
|
||||
<div className="pair-row"><span>Address</span><Pill tone="purple">{docLabel(addrDoc)}</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;
|
||||
function KycSlotRow({ slot, docId, docTypes, options, onPick, onUpload, onRemove }: {
|
||||
slot: UiKycSlot; docId: string | null; docTypes: { id: string; label: string; hint: string; formats: string; maxMb: number }[];
|
||||
options: { id: string; label: string }[]; onPick: (d: string) => void; onUpload: (f: File) => void; onRemove: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const tone = slot.status === "verified" ? "green" : slot.status === "uploaded" ? "blue" : slot.status === "rejected" ? "red" : "muted";
|
||||
const docMeta = kycDocTypes.find((d) => d.id === slot.docId);
|
||||
const docMeta = docTypes.find((d) => d.id === docId);
|
||||
const accept = slot.key === "photo" ? "image/*" : "application/pdf,image/jpeg,image/png";
|
||||
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>
|
||||
@@ -366,23 +418,27 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
||||
</div>
|
||||
|
||||
{slot.key !== "photo" && (
|
||||
<select className="ds-select" value={slot.docId ?? ""} onChange={(e) => onPick(e.target.value)}>
|
||||
<select className="ds-select" value={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>}
|
||||
{docMeta && <div className="kyc-slot-hint">{docMeta.hint ? `${docMeta.hint} · ` : ""}{docMeta.formats} · ≤ {docMeta.maxMb} MB</div>}
|
||||
{slot.status === "rejected" && slot.note && <div className="ds-field-err"><Icon name="alert" size={12} /> {slot.note}</div>}
|
||||
|
||||
<input ref={fileRef} type="file" accept={accept} hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ""; }} />
|
||||
|
||||
{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>}
|
||||
{slot.note && slot.status !== "rejected" && <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}>
|
||||
<button className="kyc-drop" onClick={() => fileRef.current?.click()}>
|
||||
<Icon name="upload" size={16} /> Click to upload {slot.key === "photo" ? "a photo" : "document"}
|
||||
</button>
|
||||
)}
|
||||
@@ -392,7 +448,7 @@ function KycSlotRow({ slot, options, onPick, onUpload, onRemove }: {
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Tab: Security */
|
||||
/* Tab: Security (Shell-owned — local mock) */
|
||||
/* ============================================================ */
|
||||
|
||||
function SecurityTab() {
|
||||
@@ -478,24 +534,29 @@ function SettingRow({ icon, title, desc, children }: { icon: string; title: stri
|
||||
/* Tab: Notifications */
|
||||
/* ============================================================ */
|
||||
|
||||
function NotificationsTab() {
|
||||
function NotificationsTab({ p }: { p: ProfileData }) {
|
||||
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 }[] = [
|
||||
const { prefs, timezone, timezoneOptions, windows, destinations } = p.notifications;
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [verifyId, setVerifyId] = useState<string | null>(null);
|
||||
const channels: { key: NotifChannel; 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;
|
||||
}));
|
||||
async function toggle(cat: (typeof prefs)[number], ch: NotifChannel) {
|
||||
if (cat.locked) { push({ tone: "info", title: "Required notifications", desc: "Security alerts can't be turned off." }); return; }
|
||||
try { await p.setPreference(cat.id, ch, !cat[ch]); }
|
||||
catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||
}
|
||||
async function toggleWindow(w: (typeof windows)[number]) {
|
||||
try { await p.setContactWindow(w.label, { startTime: w.startTime, endTime: w.endTime, enabled: !w.enabled }); }
|
||||
catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||
}
|
||||
async function changeTz(tz: string) {
|
||||
try { await p.setTimezone(tz); } catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -515,7 +576,7 @@ function NotificationsTab() {
|
||||
</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}`} />
|
||||
<Toggle checked={Boolean(cat[c.key])} disabled={cat.locked} onChange={() => toggle(cat, c.key)} label={`${cat.label} ${c.label}`} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -527,13 +588,13 @@ function NotificationsTab() {
|
||||
<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 className="ds-select" value={timezone} onChange={(e) => changeTz(e.target.value)}>
|
||||
{timezoneOptions.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))}>
|
||||
{windows.map((t) => (
|
||||
<button key={t.label} className={`time-win ${t.enabled ? "on" : ""}`} onClick={() => toggleWindow(t)}>
|
||||
<Icon name={t.enabled ? "check-circle" : "clock"} size={15} />
|
||||
<span className="tw-label">{t.label}</span>
|
||||
<span className="tw-range">{t.range}</span>
|
||||
@@ -546,76 +607,155 @@ function NotificationsTab() {
|
||||
<div className="card">
|
||||
<div className="card-head"><h3>Destinations</h3></div>
|
||||
<div className="dest-list">
|
||||
{contactDestinations.map((d) => (
|
||||
{destinations.length === 0 && <p className="muted-note"><Icon name="info" size={13} /> No extra destinations yet.</p>}
|
||||
{destinations.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>}
|
||||
{d.verified ? <Pill tone="green"><Icon name="check" size={12} /> Verified</Pill> : <Btn variant="outline" size="sm" onClick={() => setVerifyId(d.id)}>Verify</Btn>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Btn variant="ghost" icon="plus" onClick={() => push({ tone: "info", title: "Add destination" })}>Add destination</Btn>
|
||||
<Btn variant="ghost" icon="plus" onClick={() => setAddOpen(true)}>Add destination</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddDestinationModal p={p} open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
<VerifyDestinationModal p={p} id={verifyId} onClose={() => setVerifyId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDestinationModal({ p, open, onClose }: { p: ProfileData; open: boolean; onClose: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [channel, setChannel] = useState<"email" | "sms" | "whatsapp">("email");
|
||||
const [label, setLabel] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (open) { setChannel("email"); setLabel(""); setValue(""); setBusy(false); }
|
||||
}, [open]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await p.addDestination(channel, label.trim() || channel, value.trim());
|
||||
push({ tone: "success", title: "Destination added", desc: "Verify it to start receiving messages there." });
|
||||
onClose();
|
||||
} catch (e) { push({ tone: "error", title: "Couldn't add", desc: errText(e) }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const isEmail = channel === "email";
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} icon="plus" title="Add a destination"
|
||||
subtitle="Add another address or number, then verify it with a one-time code."
|
||||
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="check" disabled={busy || !value.trim()} onClick={save}>{busy ? "Adding…" : "Add destination"}</Btn></>}
|
||||
>
|
||||
<Field label="Channel">
|
||||
<select className="ds-select" value={channel} onChange={(e) => setChannel(e.target.value as "email" | "sms" | "whatsapp")}>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Label" hint="A name to recognise this destination.">
|
||||
<input className="ds-input" placeholder={isEmail ? "Work email" : "Alternate mobile"} value={label} onChange={(e) => setLabel(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={isEmail ? "Email address" : "Phone number"}>
|
||||
<input className="ds-input" type={isEmail ? "email" : "tel"} placeholder={isEmail ? "you@example.com" : "+91 90000 00000"} value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyDestinationModal({ p, id, onClose }: { p: ProfileData; id: string | null; onClose: () => void }) {
|
||||
const { push } = useToast();
|
||||
const [otp, setOtp] = useState("");
|
||||
const [challengeId, setChallengeId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setOtp(""); setChallengeId(""); setSent(false); setBusy(true);
|
||||
p.requestDestinationOtp(id)
|
||||
.then((cid) => { if (!cancelled) { setChallengeId(cid); setSent(true); } })
|
||||
.catch((e) => { if (!cancelled) push({ tone: "error", title: "Couldn't send code", desc: errText(e) }); })
|
||||
.finally(() => { if (!cancelled) setBusy(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [id, p, push]);
|
||||
|
||||
async function confirm() {
|
||||
if (!id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await p.verifyDestination(id, challengeId, otp);
|
||||
push({ tone: "success", title: "Destination verified" });
|
||||
onClose();
|
||||
} catch (e) { push({ tone: "error", title: "Couldn't verify", desc: errText(e) }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<Modal open={id != null} onClose={onClose} icon="shield-check" title="Verify destination"
|
||||
subtitle="Enter the 6-digit code sent to your registered mobile."
|
||||
footer={<><Btn variant="ghost" onClick={onClose}>Cancel</Btn><Btn icon="check" disabled={busy || otp.length < 6} onClick={confirm}>{busy ? "Verifying…" : "Verify"}</Btn></>}
|
||||
>
|
||||
<div className="otp-wrap">
|
||||
<OtpField value={otp} onChange={setOtp} />
|
||||
<span className="muted-note">{sent ? "A code was sent to your registered mobile." : "Sending a code…"}</span>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Tab: Privacy & Consent */
|
||||
/* ============================================================ */
|
||||
|
||||
function PrivacyTab() {
|
||||
function PrivacyTab({ p }: { p: ProfileData }) {
|
||||
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 };
|
||||
}),
|
||||
}));
|
||||
async function toggle(gid: string, iid: string, locked: boolean, granted: boolean) {
|
||||
if (locked) { push({ tone: "info", title: "Required consent", desc: "Statutory consents can't be withdrawn while active." }); return; }
|
||||
try { await p.setConsent(gid, iid, !granted); }
|
||||
catch (e) { push({ tone: "error", title: "Couldn't save", desc: errText(e) }); }
|
||||
}
|
||||
|
||||
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>
|
||||
{p.consent.map((g) => {
|
||||
const required = g.items.some((it) => it.locked);
|
||||
return (
|
||||
<div className="card" key={g.id}>
|
||||
<div className="card-head">
|
||||
<h3>{g.title}</h3>
|
||||
{required
|
||||
? <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>
|
||||
: <Pill tone="muted">Optional</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>
|
||||
{it.desc && <div className="consent-desc">{it.desc}</div>}
|
||||
</div>
|
||||
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id, it.locked, it.granted)} label={it.label} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================ */
|
||||
/* Tab: Devices */
|
||||
/* Tab: Devices (Shell-owned — local mock) */
|
||||
/* ============================================================ */
|
||||
|
||||
function DevicesTab() {
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
"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 1–65535.";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -29,14 +29,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ key: "pipeline", label: "Pipeline", icon: "pipeline" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Communication",
|
||||
items: [
|
||||
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
|
||||
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, messages, alerts and mail — all in one" },
|
||||
{ key: "gallery", label: "Smart Gallery", icon: "gallery", subtitle: "Every photo and video for your jobs — searchable, editable and shareable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
items: [
|
||||
@@ -80,15 +72,7 @@ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||
// 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 ALWAYS_VISIBLE = new Set(["dashboard", "profile"]);
|
||||
const NAV_PERMISSION: Record<string, string | undefined> = {
|
||||
team: "team.manage",
|
||||
people: "team.manage",
|
||||
|
||||