44 Commits

Author SHA1 Message Date
tanweer919 d82d71d0b2 feat(founder): payment→register flow + founder-only registration gate
- Next API routes: /api/register + /api/checkout (Stripe session) + /api/stripe-webhook
  (signature-verified; fulfils through be-crm), ported from the landing's serverless
  functions. Shared founder lead validation in src/lib/founder/lead.ts.
- /api/founder/lookup + /api/founder/session proxy be-crm's public endpoints.
- /thanks polls session_id → founder token → redirects to /portal/register?ft=<token>;
  falls back to the emailed link if the webhook is briefly delayed.
- register-flow: reads ?ft=, validates + prefills (email locked, name, phone) from the
  paid founder record, passes founderToken to crm.account.register (claims token +
  provisions owner). Open registration is now blocked — only a paid founder (ft) or a
  team invitee may register; everyone else sees a 'Founder access required' gate.
2026-07-18 00:23:55 +05:30
tanweer919 a459fefb7f feat(landing): port the full marketing site into the CRM (static)
Copies the goutam-pages landing (React marketing home + /page/1..19 + assets)
into /public and adds beforeFiles rewrites so '/' serves the marketing home and
'/1'..'/19' + '/thanks' serve the static pages — reproducing the landing's
vercel.json clean URLs. /portal/* (login/register) is untouched.
2026-07-18 00:06:53 +05:30
tanweer919 f634312b83 feat(profile): wire Profile to the be-crm profile data door
New src/lib/profile-api.ts (useProfileData) mirrors team-api: live (crm.account/
kyc/notifications/consent) or local mock, one interface. profile.tsx consumes it —
Personal (verify-to-reveal + change contact OTP), KYC (real file upload/remove),
Notifications (matrix, timezone, windows, add/verify destination), Privacy (consent).
Security + Devices stay on the mock (Shell-owned).
2026-07-15 18:57:39 +05:30
tanweer919 9eb234935d refactor(email): remove frontend invite email; be-crm sends it now
Per AppShell, email delivery lives in the domain API. Delete the frontend
/api/email/invite route + invite-email template, and the team-management email
wiring. be-crm now emails invitees on create/resend. Copy link stays as a
fallback (invite token still returned in the pending-invite list).
2026-07-14 15:38:53 +05:30
tanweer919 1a2043eaae fix(login): readable email-OTP send error instead of raw '{}'
gotrue can throw an error whose message is an unhelpful JSON blob (e.g. 500
'Error sending magic link email' when Supabase SMTP isn't configured). Show a
readable, actionable message instead of rendering the raw payload.
2026-07-13 20:44:05 +05:30
tanweer919 eeded95940 fix(team): show member real name + email (no more principal UUIDs)
Use displayName + email now returned by be-crm member search (from CRM
registration). Falls back to the ACE for the current user, then job title —
never the raw principal id as the email.
2026-07-13 20:33:01 +05:30
tanweer919 be4bd5f41d fix(invite): look up the invitation and route correctly (register vs sign in)
The invite link is token-only, so the landing page couldn't tell the invited
email or whether an account existed — it dumped everyone on an empty register
screen. Now it calls a public lookup (/api/invite/lookup → be-crm) and:
- first-time invitee → register with the email prefilled + locked;
- email already registered → sign in with the email prefilled;
- signed-in + registered → accept immediately; signed-in + no profile → onboarding.
Login now redeems the pending invite after sign-in (password/OTP/OAuth), so an
existing user's invite is accepted on login (not only when already logged in).
2026-07-13 20:25:25 +05:30
tanweer919 cc71516278 feat(email): send team invitations from a Vercel serverless route (Twilio)
- POST /api/email/invite: sends the invite email via the Twilio Emails API using
  server-only TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN (Vercel env), from
  EMAIL_FROM_ADDRESS (default support@lynkedup.dev). Fixed invite template; gated
  on a session cookie so it isn't an open relay.
- Team Management emails the invite on create and resend (token now returned by
  both), with the Copy-link as a fallback. Mock mode skips email.
2026-07-13 19:48:19 +05:30
tanweer919 103f3ae3a1 feat(rbac): no role at registration; invitation-based membership; nav gating
- Registration no longer asks for a role/persona (removed the role select +
  allottee block); crm.account.register sends no persona.
- New users have no permissions → the sidebar now shows only Dashboard + Profile
  for them, gated on crm.account.me (membership + permissions). Members see the
  areas their permissions allow.
- Add /portal/invite?token=… : accepts the invite for a signed-in registered
  user, or routes an unregistered invitee through register/onboarding, which
  redeems the stashed token on completion (granting the invited role).
- Team Management: 'Copy link' on pending invites builds the invite link from
  the invitation token (no email delivery yet).
2026-07-13 16:25:53 +05:30
tanweer919 68bf137d11 fix(legal): explicit spacing after inline elements on SMS opt-in page 2026-07-13 14:59:59 +05:30
tanweer919 5f67c8fa85 fix(legal): correct spacing around company name on opt-in and terms pages 2026-07-13 14:55:33 +05:30
tanweer919 5511ef4110 feat(legal): SMS opt-in form + Privacy Policy + Terms for Twilio A2P 10DLC
Public, unauthenticated pages required for A2P 10DLC campaign verification:
- /sms-opt-in: web opt-in form (phone field, un-pre-checked consent checkbox,
  message description, frequency, msg&data-rates + HELP/STOP disclaimers, links
  to Terms & Privacy, 'Yes, sign me up!' submit).
- /privacy: Privacy Policy with the mandatory SMS clauses (no third-party/affiliate
  sharing of mobile numbers or SMS consent, message frequency, rates disclosure).
- /terms: Terms of Service with an SMS program-terms section.
Business details centralized in legal-config.ts (replace SMS_SENDER with the
live Twilio number once approved).
2026-07-13 14:50:49 +05:30
tanweer919 127e0f8912 fix(login): back-button nav, single OTP channel, register back buttons
- Login: identify → password/otp now push() history (was replace()), so Back
  returns to the email screen instead of leaving to /register.
- Login: in Shell mode the OTP screen no longer shows an email/SMS toggle —
  the channel is fixed by how you signed in, so email sign-in never shows a
  stray SMS option.
- Login: hide phone (SMS) sign-in while OTP is mocked — a faked login code
  can't mint a session (AuthGate would bounce), so it can't work without SMS.
  Email OTP + password + Google stay real and working.
- Register: add Back buttons (email screen → sign in; profile screen → email).
- Share MOCK_OTP/DEMO_OTP via @/lib/otp.
2026-07-13 14:32:18 +05:30
tanweer919 27a5aa939e feat(otp): mock phone OTP fallback while Twilio SMS is down
The Twilio sender isn't delivering, so gate the Verify step behind
NEXT_PUBLIC_MOCK_OTP (default true): 'Send code' skips Supabase/Twilio and a
fixed demo code (123456) verifies, with clear demo-mode copy. The real
addPhone/verifyPhone path is untouched — flip the flag to "false" to restore
live SMS verification.
2026-07-13 13:59:04 +05:30
tanweer919 da7f7a7891 feat(register/onboard): real phone OTP verification via Twilio (SDK 0.2.6)
Replaces the mock verify step (which accepted any 6 digits) with a real SMS
OTP: addPhone() → Supabase/Twilio texts a code → verifyPhone() confirms it.

- Registration creates the auth account when leaving the Account step, so the
  phone can be attached + verified against a live Supabase session; finish()
  no longer double-registers.
- Onboarding gains a real phone-verify step (Profile → Verify → Address).
- Email is trusted without an OTP in both flows (Supabase auto-confirms on
  signup; Google verifies for onboarding), matching project config.
- Bump @abe-kap/appshell-sdk to ^0.2.6.
2026-07-13 04:29:02 +05:30
tanweer919 3c6190d16a Merge pull request 'fix(onboarding): reliably prefill verified email (SDK 0.2.5)' (#15) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:36:29 +00:00
tanweer919 6ba00bfbf9 fix(onboarding): reliably prefill the verified email (SDK 0.2.5)
getUserEmail() relied on getSession(), which returns null here because the
Supabase session is intentionally never persisted to storage — so the
onboarding email field stayed empty and Continue was blocked. SDK 0.2.5
captures the email in memory from the token-exchange response instead.
Bump @abe-kap/appshell-sdk to ^0.2.5.
2026-07-13 04:06:13 +05:30
tanweer919 5e2fa574bd Merge pull request 'fix(onboarding): gate direct access behind OAuth handoff' (#14) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:32:35 +00:00
tanweer919 d79da8cd6a fix(onboarding): gate direct access behind the OAuth handoff
/portal/onboarding is only a step in the Google sign-in flow. Redirect to
login unless the login page's onboard_email handoff hint is present (and the
session is authenticated), instead of showing an empty form on direct/typed
URL access. Hold rendering until the check passes to avoid a form flash, and
clear the hint once onboarding completes so it can't be reused.
2026-07-13 04:01:01 +05:30
tanweer919 10141806dc Merge pull request 'fix(onboarding): prefill verified email; SDK 0.2.4' (#13) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-12 22:26:05 +00:00
tanweer919 08ef85869f fix(onboarding): prefill verified email in a disabled field; bump SDK 0.2.4
- Onboarding reads the OAuth email (session-storage hint + live Supabase
  session via getUserEmail) so Continue is no longer disabled.
- Replace the empty 'Signed in as' banner with a disabled, prefilled
  email field (email is fixed — Google-verified).
- Stash the verified email at OAuth callback time for onboarding prefill.
- Bump @abe-kap/appshell-sdk to ^0.2.4 (getUserEmail via getSession).
2026-07-13 03:55:05 +05:30
tanweer919 619ec4c9b1 Merge pull request 'feat(auth): Google onboarding flow (option 3)' (#12) from feat/login-methods into goutamnextflow 2026-07-12 22:06:03 +00:00
tanweer919 948abf75fd feat(auth): first-time Google onboarding (option 3)
After a Google sign-in with no CRM profile, route to /portal/onboarding — the
registration flow in mode='onboard': email skipped (Google-verified, prefilled
via getUserEmail), sets a password (updateUser) so email+password login also
works, skips the OTP-verify step, and persists the profile via crm.account.register.
Existing-profile users go straight to the dashboard. Bumps SDK ^0.2.3.
2026-07-13 03:35:55 +05:30
tanweer919 66cd8953ba Merge pull request 'feat(auth): gate Google sign-in on CRM profile' (#11) from feat/login-methods into goutamnextflow 2026-07-12 21:52:11 +00:00
tanweer919 34cb44cfa8 feat(auth): gate Google sign-in on an existing CRM profile
Google is sign-IN only: after OAuth, check crm.account.registrationStatus; if the
account has no CRM profile, sign back out and show an error on the login screen
instead of admitting a profile-less user. (Onboarding to collect the profile comes later.)
2026-07-13 03:22:04 +05:30
tanweer919 11931cbf6f Merge pull request 'polish: real role in user chip' (#10) from feat/login-methods into goutamnextflow 2026-07-12 21:33:24 +00:00
tanweer919 f38fa80bde polish(dashboard): show real ACE role in user chip instead of static 'Property Owner' 2026-07-13 03:03:17 +05:30
tanweer919 43f9a3eb83 Merge pull request 'feat(dashboard): real user + sign-out; remove demo text' (#9) from feat/login-methods into goutamnextflow 2026-07-12 21:27:58 +00:00
tanweer919 86af77c92b feat(dashboard): real user in sidebar + sign-out; strip demo/mock hints
- Sidebar footer now shows the ACE identity (was static 'James Carter') with a
  Sign-out menu; topbar user gets a Sign-out dropdown too. Both call useAuth().logout.
- Remove all visible 'Demo:' hints from login + register and the 'Demo mode' subtitle.
2026-07-13 02:57:51 +05:30
tanweer919 f5ff7bf6ea Merge pull request 'fix(auth): session persists on reload (SDK 0.2.2)' (#8) from feat/login-methods into goutamnextflow 2026-07-12 21:11:46 +00:00
tanweer919 27e7bdf8f5 fix(auth): bump appshell-sdk 0.2.2 (ready-after-restore) — session persists on reload 2026-07-13 02:41:39 +05:30
tanweer919 3e79b3bf31 Merge pull request 'fix(auth): session persists on reload (gate redirect on boot-ready)' (#7) from feat/login-methods into goutamnextflow 2026-07-12 21:05:20 +00:00
tanweer919 64be4b9b3f fix(auth): don't redirect from dashboard until SDK boot completes
AuthGate redirected to /portal/login whenever status !== authenticated, which
during boot (before restore() resolves) dropped a valid session on reload. Gate
the redirect on useAppShell().ready so we only bounce once auth has resolved.
2026-07-13 02:35:13 +05:30
tanweer919 e08fa357f7 Merge pull request 'chore: remove debug logging' (#6) from feat/login-methods into goutamnextflow 2026-07-12 20:47:30 +00:00
tanweer919 5186497c50 chore(auth): remove debug logging from OAuth + registration error paths 2026-07-13 02:17:22 +05:30
tanweer919 e37ef375eb Merge pull request 'fix(auth): PKCE verifier (SDK 0.2.1) — Google sign-in' (#5) from feat/login-methods into goutamnextflow 2026-07-12 20:32:32 +00:00
tanweer919 0e8a207640 fix(auth): bump appshell-sdk 0.2.1 (PKCE verifier fix); tidy OAuth error handling 2026-07-13 02:02:24 +05:30
tanweer919 66118ff63f Merge pull request 'fix(auth): surface OAuth error' (#4) from feat/login-methods into goutamnextflow 2026-07-12 20:15:17 +00:00
tanweer919 7570fe7be3 fix(auth): surface the real OAuth completion error (console + flash) 2026-07-13 01:45:10 +05:30
tanweer919 6d0c8890d3 Merge pull request 'fix(auth): complete Google OAuth after SDK boot' (#3) from feat/login-methods into goutamnextflow 2026-07-12 20:01:53 +00:00
tanweer919 6e1a895e7e fix(auth): complete Google OAuth after SDK boot (was stuck on ?code=…#identify)
The OAuth-return effect ran on mount before AppShell finished booting, so
completeOAuthLogin no-oped (sdk.auth undefined → null) and fell back to identify,
never retrying. Gate it on useAppShell().ready so it runs once auth is up, and
strip ?code= from the URL on success.
2026-07-13 01:31:37 +05:30
tanweer919 e9ec33d412 Merge pull request 'feat(auth): Google-only social; email/password sign-up; phone login' (#2) from feat/login-methods into goutamnextflow 2026-07-11 15:19:06 +00:00
tanweer919 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- SocialButtons: only Google (Microsoft/Apple hidden).
- Sign-up: remove all social (email/password only).
- Login: keep Google + email/password (single step) + email/SMS OTP; add a direct
  'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to
  'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
2026-07-11 20:48:47 +05:30
tanweer919 2a653b807b Merge pull request 'feat: AppShell integration — auth, email/SMS OTP login, live multi-role team management' (#1) from tanweer919/lynkeduppro-crm:feat/appshell-integration into goutamnextflow 2026-07-11 14:24:59 +00:00
98 changed files with 34454 additions and 434 deletions
+1
View File
@@ -41,3 +41,4 @@ yarn-error.log*
next-env.d.ts
.vercel
.env*.local
+23 -3
View File
@@ -1,14 +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 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 existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
async rewrites() {
// 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: [],
};
},
};
+780 -20
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.0",
"@abe-kap/appshell-sdk": "^0.2.6",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+93
View File
@@ -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/>);
+649
View File
@@ -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 });
+1091
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -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>
+543
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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>
+560
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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> &nbsp;·&nbsp; <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>
+635
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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)">&lt;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)">&lt;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> &nbsp;·&nbsp; <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>
+2194
View File
File diff suppressed because it is too large Load Diff
+2110
View File
File diff suppressed because it is too large Load Diff
+2026
View File
File diff suppressed because it is too large Load Diff
+1332
View File
File diff suppressed because it is too large Load Diff
+426
View File
@@ -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 &amp; 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 &nbsp;·&nbsp; <a href="lynkeduppro_final.html">See Full Platform →</a> &nbsp;·&nbsp; <a href="https://book.lynkeduppro.com/">Privacy</a> &nbsp;·&nbsp; <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>
+516
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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 &nbsp;·&nbsp; <a href="lynkeduppro_final.html">See Full Platform →</a> &nbsp;·&nbsp; <a href="https://book.lynkeduppro.com/">Privacy</a> &nbsp;·&nbsp; <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>
+2116
View File
File diff suppressed because it is too large Load Diff
+1620
View File
File diff suppressed because it is too large Load Diff
+1346
View File
File diff suppressed because it is too large Load Diff
+1125
View File
File diff suppressed because it is too large Load Diff
+1451
View File
File diff suppressed because it is too large Load Diff
+1102
View File
File diff suppressed because it is too large Load Diff
+481
View File
@@ -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 &amp; 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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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>
+470
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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>
+534
View File
@@ -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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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 &nbsp;·&nbsp; 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+641
View File
@@ -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);
}
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+239
View File
@@ -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'}, '*');
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+797
View File
@@ -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 &amp; 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 &amp; 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 &amp; Field Tracking</li>
<li>Insurance Workflow</li>
<li>Proposal &amp; 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 &amp; 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 });
+642
View File
@@ -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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &amp; 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 &amp; 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">&times;</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
);
})();
+823
View File
@@ -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;
}
+147
View File
@@ -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&nbsp;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>
+431
View File
@@ -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 });
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import Link from "next/link";
import "@/components/portal/portal.css";
import "./legal.css";
import { COMPANY } from "./legal-config";
export const metadata: Metadata = {
title: `${COMPANY} — Legal & SMS`,
description: `${COMPANY} SMS program opt-in, Privacy Policy and Terms of Service.`,
};
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<div className="legal-root">
<header className="legal-bar">
<Link href="/portal/login" aria-label={`${COMPANY} home`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/image/logo.png" alt={`${COMPANY} logo`} />
</Link>
<nav>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy</Link>
<Link href="/terms">Terms</Link>
</nav>
</header>
{children}
<footer className="legal-foot">
<div>© {new Date().getFullYear()} {COMPANY}. All rights reserved.</div>
<div style={{ marginTop: 8 }}>
<Link href="/sms-opt-in">SMS Alerts</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
</div>
</footer>
</div>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Shared business details for the public compliance pages (SMS opt-in, Privacy,
* Terms). Edit these in one place to keep every page and disclosure consistent.
* Replace SMS_SENDER with your live Twilio number once the campaign is approved.
*/
export const COMPANY = "LynkedUp Pro";
export const SUPPORT_EMAIL = "support@lynkeduppro.com";
export const PRIVACY_EMAIL = "privacy@lynkeduppro.com";
export const SMS_SENDER = "(555) 010-0100"; // TODO: replace with your Twilio A2P number
export const SITE_URL = "https://lynkeduppro-crmnew.vercel.app";
export const LAST_UPDATED = "July 13, 2026";
+125
View File
@@ -0,0 +1,125 @@
/* Public compliance pages (SMS opt-in, Privacy, Terms). Reuses the portal design
tokens (--bg, --text, --primary) from portal.css for a consistent look, with
prose styling tuned for long-form legal copy. */
.legal-root {
min-height: 100vh;
background: var(--bg, #0b0c11);
color: var(--text, #f3f4f8);
font-family: var(--font-ui, "Inter", system-ui, sans-serif);
display: flex;
flex-direction: column;
}
.legal-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid var(--line, rgba(255, 255, 255, 0.09));
position: sticky;
top: 0;
background: color-mix(in srgb, var(--bg, #0b0c11) 88%, transparent);
backdrop-filter: blur(10px);
z-index: 5;
}
.legal-bar img { height: 30px; width: auto; }
.legal-bar nav { display: flex; gap: 20px; flex-wrap: wrap; }
.legal-bar nav a {
color: var(--muted, #a3a8b5);
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.legal-bar nav a:hover { color: var(--text, #f3f4f8); }
.legal-bar nav a.on { color: var(--primary-2, #fb923c); }
.legal-wrap { flex: 1; width: 100%; max-width: 820px; margin: 0 auto; padding: 44px 24px 80px; }
.legal-wrap h1 { font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 6px; }
.legal-wrap .updated { color: var(--faint, #6c7280); font-size: 13px; margin: 0 0 30px; }
.legal-wrap h2 {
font-size: 19px; font-weight: 700; margin: 34px 0 10px;
padding-top: 20px; border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
}
.legal-wrap h2:first-of-type { border-top: none; padding-top: 0; }
.legal-wrap p, .legal-wrap li { color: var(--muted, #a3a8b5); font-size: 15px; line-height: 1.7; }
.legal-wrap p { margin: 0 0 12px; }
.legal-wrap ul { margin: 0 0 12px; padding-left: 20px; }
.legal-wrap li { margin: 0 0 6px; }
.legal-wrap strong { color: var(--text, #f3f4f8); font-weight: 600; }
.legal-wrap a { color: var(--primary-2, #fb923c); }
/* Callout box for the mandatory SMS disclosures — makes them easy for a reviewer to find. */
.legal-callout {
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 16px 18px;
margin: 8px 0 18px;
}
.legal-callout p:last-child { margin-bottom: 0; }
.legal-foot {
border-top: 1px solid var(--line, rgba(255, 255, 255, 0.09));
padding: 22px 24px;
text-align: center;
color: var(--faint, #6c7280);
font-size: 13px;
}
.legal-foot a { color: var(--muted, #a3a8b5); text-decoration: none; margin: 0 10px; }
.legal-foot a:hover { color: var(--text, #f3f4f8); }
/* SMS opt-in form */
.optin-card {
border: 1px solid var(--line, rgba(255, 255, 255, 0.09));
background: var(--surface, rgba(255, 255, 255, 0.04));
border-radius: var(--radius-sm, 13px);
padding: 22px;
margin-top: 22px;
}
.optin-field { margin-bottom: 16px; }
.optin-field label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 7px; color: var(--text, #f3f4f8); }
.optin-input {
width: 100%;
background: var(--ink, #121319);
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.15));
border-radius: 10px;
padding: 13px 14px;
color: var(--text, #f3f4f8);
font-size: 15px;
font-family: inherit;
}
.optin-input:focus { outline: 2px solid var(--primary, #f97316); outline-offset: 1px; border-color: transparent; }
.optin-consent { display: flex; gap: 11px; align-items: flex-start; margin: 6px 0 4px; }
.optin-consent input { margin-top: 3px; width: 18px; height: 18px; flex: none; accent-color: var(--primary, #f97316); }
.optin-consent label { font-size: 13.5px; line-height: 1.6; color: var(--muted, #a3a8b5); }
.optin-submit {
width: 100%;
margin-top: 18px;
background: var(--primary, #f97316);
color: #fff;
border: none;
border-radius: 10px;
padding: 14px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.optin-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.optin-fine { font-size: 12.5px; color: var(--faint, #6c7280); line-height: 1.6; margin-top: 14px; }
.optin-done {
border: 1px solid color-mix(in srgb, var(--green, #34d399) 45%, transparent);
background: color-mix(in srgb, var(--green, #34d399) 12%, transparent);
color: #a7f3d0;
border-radius: var(--radius-sm, 13px);
padding: 18px 20px;
margin-top: 22px;
font-size: 14.5px;
line-height: 1.6;
}
+98
View File
@@ -0,0 +1,98 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, PRIVACY_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Privacy Policy — ${COMPANY}`,
description: `How ${COMPANY} collects, uses, and protects your information, including SMS/text messaging.`,
};
export default function PrivacyPage() {
return (
<main className="legal-wrap">
<h1>Privacy Policy</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
This Privacy Policy explains how {COMPANY} (&quot;{COMPANY},&quot; &quot;we,&quot;
&quot;us&quot;) collects, uses, and protects your information when you use our
website, portal, and services, including our text message (SMS) program.
</p>
<h2>Information we collect</h2>
<ul>
<li><strong>Account information</strong> name, email address, and mobile phone number you provide when registering or signing in.</li>
<li><strong>Property and service information</strong> details you share to request roof inspections, estimates, and reports.</li>
<li><strong>Usage and device information</strong> log data, device and browser type, and similar technical information collected automatically.</li>
</ul>
<h2>How we use your information</h2>
<ul>
<li>To create and secure your account, including sending one-time verification codes (OTP).</li>
<li>To provide, maintain, and improve our services.</li>
<li>To communicate with you about your account, appointments, estimates, and support requests.</li>
<li>To comply with legal obligations and protect against fraud and abuse.</li>
</ul>
<h2>SMS / text messaging</h2>
<div className="legal-callout">
<p>
<strong>
We do not sell, rent, or share your mobile phone number, or your SMS opt-in
consent, with any third parties or affiliates for their marketing or
promotional purposes.
</strong>
</p>
<p>
Mobile information collected for the purpose of sending text messages is used
only to deliver the messages you opted into and is not shared with third parties
for marketing. We may share your number only with service providers (such as our
messaging platform) strictly to deliver the messages, and as required by law.
</p>
<p>
<strong>Message frequency varies</strong> verification codes are sent when you
request them (for example, each time you sign in or register), and account or
service notifications are occasional. <strong>Message and data rates may
apply</strong>, depending on your mobile carrier and plan.
</p>
<p>
You can opt out at any time by replying <strong>STOP</strong> to any message, and
get help by replying <strong>HELP</strong>. See our{" "}
<Link href="/terms">Terms of Service</Link> and the{" "}
<Link href="/sms-opt-in">SMS opt-in page</Link> for full program details.
</p>
</div>
<h2>How we share information</h2>
<p>
We do not sell your personal information. We share information only with service
providers who help us operate the service (for example, cloud hosting,
authentication, and messaging providers), and when required by law or to protect
our rights. As stated above, mobile phone numbers and SMS consent are never shared
with third parties or affiliates for marketing purposes.
</p>
<h2>Data retention and security</h2>
<p>
We retain personal information for as long as your account is active or as needed to
provide the service and meet legal obligations. We use administrative, technical,
and physical safeguards designed to protect your information; no method of
transmission or storage is completely secure.
</p>
<h2>Your choices and rights</h2>
<p>
You may access or update your account information at any time, opt out of SMS by
replying STOP, and request deletion of your account by contacting us. Depending on
your location, you may have additional rights under applicable privacy laws.
</p>
<h2>Contact us</h2>
<p>
Questions about this Privacy Policy? Email us at{" "}
<a href={`mailto:${PRIVACY_EMAIL}`}>{PRIVACY_EMAIL}</a> or{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { COMPANY, SMS_SENDER, SUPPORT_EMAIL } from "../legal-config";
/**
* Public SMS opt-in web form for A2P 10DLC campaign verification. Contains every
* element Twilio/CTIA require: phone field, an un-pre-checked consent checkbox, a
* description of the messages, frequency, rates disclaimer, HELP/STOP instructions,
* and links to the Terms and Privacy Policy.
*/
export default function SmsOptInPage() {
const [phone, setPhone] = useState("");
const [consent, setConsent] = useState(false);
const [done, setDone] = useState(false);
const digits = phone.replace(/\D/g, "");
const valid = digits.length >= 10 && consent;
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!valid) return;
// Records the opt-in. Consent + timestamp should be persisted server-side for your
// records; this confirmation is the user-facing acknowledgement.
setDone(true);
}
return (
<main className="legal-wrap">
<h1>{COMPANY} SMS Alerts</h1>
<p className="updated">Sign up to receive text messages from {COMPANY}.</p>
<p>
At {COMPANY}, we send text messages to help you use and secure your account. By
opting in below you&apos;ll receive <strong>account and service messages</strong>,
including:
</p>
<ul>
<li>One-time verification codes (OTP) when you sign in or register</li>
<li>Sign-in and security alerts</li>
<li>Roof inspection scheduling and status updates</li>
<li>Estimate and report notifications</li>
</ul>
{done ? (
<div className="optin-done" role="status">
<strong>You&apos;re signed up.</strong>{" "}We&apos;ll text account and service
messages to {phone}. Reply <strong>STOP</strong>{" "}at any time to unsubscribe,
or <strong>HELP</strong>{" "}for help.
</div>
) : (
<form className="optin-card" onSubmit={onSubmit}>
<div className="optin-field">
<label htmlFor="sms-phone">Mobile phone number</label>
<input
id="sms-phone"
className="optin-input"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="+1 (555) 010-0100"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
/>
</div>
{/* Consent checkbox — MUST NOT be pre-checked (starts false). */}
<div className="optin-consent">
<input
id="sms-consent"
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
<label htmlFor="sms-consent">
I agree to receive account and service text messages (including one-time
verification codes) from {COMPANY} at the number provided. Consent is not a
condition of purchase. Message frequency varies. Message and data rates may
apply. Reply HELP for help and STOP to unsubscribe. See our{" "}
<Link href="/terms">Terms of Service</Link> and{" "}
<Link href="/privacy">Privacy Policy</Link>.
</label>
</div>
<button className="optin-submit" type="submit" disabled={!valid}>
Yes, sign me up!
</button>
<p className="optin-fine">
Message frequency varies. Message and data rates may apply. Reply{" "}
<strong>HELP</strong>{" "}for help or <strong>STOP</strong>{" "}to cancel at any
time. Carriers are not liable for delayed or undelivered messages.
</p>
</form>
)}
<h2>Program details</h2>
<ul>
<li><strong>Program name:</strong> {COMPANY} Account &amp; Service Alerts</li>
<li><strong>Message types:</strong> verification codes (OTP), security alerts, appointment and inspection updates, estimate notifications</li>
<li><strong>Message frequency:</strong> varies codes are sent when you request them (e.g. each sign-in or registration); notifications are occasional</li>
<li><strong>Cost:</strong> Message and data rates may apply, per your mobile plan</li>
</ul>
<h2>How to opt out or get help</h2>
<p>
Reply <strong>STOP</strong>{" "}to any message to unsubscribe you&apos;ll get one
confirmation and no further texts. Reply <strong>HELP</strong>{" "}for help, or
contact us at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>
{SMS_SENDER ? <> or {SMS_SENDER}</> : null}. Messages are sent from {COMPANY}.
</p>
<h2>Your privacy</h2>
<p>
<strong>
We do not sell, rent, or share your mobile phone number or your SMS opt-in
consent with any third parties or affiliates for their marketing purposes.
</strong>{" "}
See our <Link href="/privacy">Privacy Policy</Link>{" "}for full details.
</p>
</main>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import Link from "next/link";
import { COMPANY, SUPPORT_EMAIL, LAST_UPDATED } from "../legal-config";
export const metadata: Metadata = {
title: `Terms of Service — ${COMPANY}`,
description: `The terms governing your use of ${COMPANY}, including the SMS/text messaging program.`,
};
export default function TermsPage() {
return (
<main className="legal-wrap">
<h1>Terms of Service</h1>
<p className="updated">Last updated: {LAST_UPDATED}</p>
<p>
These Terms of Service (&quot;Terms&quot;) govern your access to and use of the {COMPANY}{" "}
website, portal, and services (the &quot;Service&quot;). By using the
Service you agree to these Terms.
</p>
<h2>Use of the Service</h2>
<p>
You must provide accurate information, keep your credentials secure, and use the
Service only for lawful purposes and in accordance with these Terms. You are
responsible for activity that occurs under your account.
</p>
<h2>Accounts and verification</h2>
<p>
To protect your account we may verify your identity, including by sending one-time
codes to your email or mobile number. You agree to receive these verification
messages as part of using the Service.
</p>
<h2>SMS / text messaging program</h2>
<div className="legal-callout">
<p>
By opting in on our <Link href="/sms-opt-in">SMS opt-in page</Link> or by
providing your mobile number and agreeing to receive texts, you consent to
receive <strong>account and service text messages</strong> from {COMPANY},
including one-time verification codes (OTP), security alerts, appointment and
inspection updates, and estimate notifications.
</p>
<p>
<strong>Message frequency varies.</strong> <strong>Message and data rates may
apply.</strong> Reply <strong>HELP</strong> for help or <strong>STOP</strong> to
unsubscribe at any time; after you send STOP we will send one confirmation and no
further messages. Carriers are not liable for delayed or undelivered messages.
</p>
<p>
We do not sell, rent, or share your mobile number or SMS consent with third
parties or affiliates for marketing. See our{" "}
<Link href="/privacy">Privacy Policy</Link> for details.
</p>
</div>
<h2>Acceptable use</h2>
<p>
You may not misuse the Service, attempt to access accounts or data you are not
authorized to access, interfere with the Service&apos;s operation, or use it to
send unlawful, harmful, or infringing content.
</p>
<h2>Disclaimers and limitation of liability</h2>
<p>
The Service is provided &quot;as is&quot; without warranties of any kind. To the
fullest extent permitted by law, {COMPANY} is not liable for any indirect,
incidental, or consequential damages arising from your use of the Service.
</p>
<h2>Changes to these Terms</h2>
<p>
We may update these Terms from time to time. Continued use of the Service after
changes take effect constitutes acceptance of the updated Terms.
</p>
<h2>Contact us</h2>
<p>
Questions about these Terms? Email us at{" "}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
</p>
</main>
);
}
+129
View File
@@ -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}`;
}
+27
View File
@@ -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 });
}
}
+27
View File
@@ -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 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
// Server-side proxy to be-crm's public invitation lookup, so the invite landing page
// can learn the invited email + roles and whether an account exists — before the
// invitee has a session. Server-to-server avoids CORS. No secrets involved (the token
// is the only credential, and it was emailed to the invitee).
export const runtime = "nodejs";
const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, "");
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token") ?? "";
if (token.length < 16 || token.length > 256) {
return NextResponse.json({ ok: false, status: "invalid" }, { status: 400 });
}
try {
const res = await fetch(`${CRM_BASE_URL}/public/invitations/lookup?token=${encodeURIComponent(token)}`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 });
}
}
+28
View File
@@ -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 });
}
+155
View File
@@ -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 });
}
+5
View File
@@ -106,6 +106,11 @@
.sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; }
.sb-user .nm { font-size: 12.5px; font-weight: 600; }
.sb-user .rl { font-size: 11px; color: var(--faint); }
.sb-user .nm, .sb-user .rl { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-user-menu { position: absolute; bottom: 56px; left: 12px; right: 12px; z-index: 30; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow), 0 8px 30px -12px rgba(0,0,0,0.55); animation: ds-rise 0.14s ease; }
.sb-user-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
.sb-user-menu button:hover { background: var(--panel-2); }
.sb-user-menu button.danger { color: var(--red, #ef4444); }
/* ---- main ---- */
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { CookieBanner, Spinner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
interface InviteInfo { ok: boolean; status?: string; email?: string; roleNames?: string[]; hasAccount?: boolean }
/**
* Team invitation landing page (/portal/invite?token=). It looks the token up
* (public, pre-auth) to learn the invited email and whether an account exists, then:
* - signed in + registered accept now (creates the membership with the invited role);
* - signed in, no CRM profile onboarding, which redeems the token on finish;
* - not signed in + email already has an account sign in (email prefilled);
* - not signed in + first-time invitee register (email prefilled + locked).
* The register/login/onboarding flows redeem the stashed token on completion.
*/
export default function InvitePage() {
const router = useRouter();
const { status, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [error, setError] = useState("");
const started = useRef(false);
useEffect(() => {
let token = "";
try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ }
if (!token) { setError("This invitation link is invalid or incomplete."); return; }
if (!isShellConfigured()) { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } router.replace("/portal/register"); return; }
if (!ready || started.current) return;
started.current = true;
(async () => {
try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
// Look the invitation up (pre-auth) to get the email + account state.
let info: InviteInfo = { ok: false };
try {
const res = await fetch(`/api/invite/lookup?token=${encodeURIComponent(token)}`, { cache: "no-store" });
info = await res.json();
} catch { /* treat as unavailable below */ }
if (!info.ok) {
const msg = info.status === "expired" ? "This invitation has expired. Ask for a new one."
: info.status === "accepted" ? "This invitation has already been used."
: info.status === "revoked" ? "This invitation was revoked."
: "This invitation link is invalid.";
setError(msg);
try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ }
return;
}
if (info.email) { try { sessionStorage.setItem("invite_email", info.email); } catch { /* ignore */ } }
// Signed in already: accept if registered, else finish onboarding first.
if (status === "authenticated") {
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
if (!st?.registered) {
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
return;
}
} catch { /* fall through to accept */ }
try {
await sdk.command("crm.team.invitation.accept", { token });
try { sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_email"); } catch { /* ignore */ }
router.replace("/dashboard");
} catch {
setError("We couldn't accept this invitation — it may have expired or already been used.");
}
return;
}
// Not signed in: existing account → sign in; first-time invitee → register.
router.replace(info.hasAccount ? "/portal/login" : "/portal/register");
})();
}, [ready, status, router, sdk, getUserEmail]);
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
{error ? (
<>
<h1>Invitation problem</h1>
<p className="sub">{error}</p>
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => router.replace("/portal/login")}>
Go to sign in
</button>
</>
) : (
<div className="interstitial">
<Spinner lg />
<h1 style={{ fontSize: 20, marginTop: 8 }}>Checking your invitation</h1>
<p className="sub">One moment while we set things up.</p>
</div>
)}
</div>
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { RegisterFlow } from "@/components/portal/register-flow";
import { CookieBanner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
/**
* Post-OAuth onboarding a first-time Google user completes their CRM profile
* (everything except email, which Google already verified).
*
* This screen is ONLY a step in the OAuth onboarding handoff: the login page
* stashes the verified email in `sessionStorage` right before routing here. Any
* other way in a typed URL, a fresh tab, a lost session has no handoff hint
* (and possibly no session), so we bounce back to sign in rather than showing an
* empty form.
*/
export default function OnboardingPage() {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
if (!isShellConfigured()) { setAllowed(true); return; } // local dev without the Shell
if (!ready) return; // wait for session restore
let hasHandoff = false;
try { hasHandoff = !!sessionStorage.getItem("onboard_email"); } catch { /* ignore */ }
if (status === "unauthenticated" || !hasHandoff) {
router.replace("/portal/login");
return;
}
setAllowed(true);
}, [ready, status, router]);
if (!allowed) return <main className="portal-main"><span className="portal-grid" /></main>;
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<RegisterFlow mode="onboard" />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+10 -5
View File
@@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
/**
@@ -14,16 +14,21 @@ import { isShellConfigured } from "@/lib/appshell";
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const shell = isShellConfigured();
useEffect(() => {
if (shell && status === "unauthenticated") router.replace("/portal/login");
}, [shell, status, router]);
// Only bounce to login once the SDK has finished booting AND restore() has
// resolved to unauthenticated. Redirecting while boot is still in flight would
// drop a perfectly valid session on reload (the status is transiently not-yet
// "authenticated" during boot).
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
}, [shell, ready, status, router]);
if (shell && status !== "authenticated") {
if (shell && (!ready || status !== "authenticated")) {
return (
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
{status === "loading" ? "Loading your workspace…" : "Redirecting to sign in…"}
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
</div>
);
}
+292 -152
View File
@@ -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&apos;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 &amp; Aadhaar are managed under the KYC tab and can&apos;t be edited here.</p>
</div>
<ChangeContactModal
kind={change}
current={change ? contacts[change] : ""}
onClose={() => setChange(null)}
onSave={(kind, value) => setContacts((c) => ({ ...c, [kind]: value }))}
/>
<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 }));
}
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));
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 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) => (
{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>
{g.id === "marketing" && <Pill tone="muted">Optional</Pill>}
{g.id === "essential" && <Pill tone="red"><Icon name="lock" size={12} /> Required</Pill>}
{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>
<div className="consent-desc">{it.desc}</div>
{it.desc && <div className="consent-desc">{it.desc}</div>}
</div>
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id)} label={it.label} />
<Toggle checked={it.granted} disabled={it.locked} onChange={() => toggle(g.id, it.id, it.locked, it.granted)} label={it.label} />
</div>
))}
</div>
{g.id === "marketing" && groups.find((x) => x.id === "marketing")!.items.find((i) => i.id === "promos")!.granted && (
<div className="consent-sub">
<div className="muted-note">Promotional sub-preferences</div>
{["Product offers", "Surveys & feedback", "Partner news"].map((s, i) => (
<label className="consent-sub-row" key={s}><span>{s}</span><Toggle checked={i === 0} onChange={() => {}} label={s} /></label>
))}
</div>
)}
</div>
))}
);
})}
</div>
);
}
/* ============================================================ */
/* Tab: Devices */
/* Tab: Devices (Shell-owned — local mock) */
/* ============================================================ */
function DevicesTab() {
+75 -8
View File
@@ -1,8 +1,16 @@
"use client";
import { ChevronsUpDown } from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui";
import { user } from "./account-data";
import { useMyAccess } from "@/lib/access";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
export type NavGroup = { title: string; items: NavItem[] };
@@ -60,7 +68,58 @@ export const NAV_GROUPS: NavGroup[] = [
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
// Nav visibility by CRM permission. Dashboard + Profile are always visible (even to a
// brand-new user with no membership); every other item requires membership, and the
// items mapped here additionally require the given permission. Unmapped items are
// shown to any member. This is UX only — be-crm still enforces every action.
const ALWAYS_VISIBLE = new Set(["dashboard", "profile"]);
const NAV_PERMISSION: Record<string, string | undefined> = {
team: "team.manage",
people: "team.manage",
leads: "leads.manage",
verify: "leads.manage",
pipeline: "pipeline.manage",
estimates: "estimates.create",
procanvas: "estimates.create",
dispatch: "dispatch.manage",
schedule: "dispatch.manage",
storm: "dispatch.manage",
territory: "dispatch.manage",
leaderboard: "reports.view",
settings: "settings.manage",
};
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
const router = useRouter();
const { user: me, logout, context } = useAuth();
const access = useMyAccess();
const [menuOpen, setMenuOpen] = useState(false);
// A new user with no membership sees only Dashboard + Profile. Members see the areas
// their permissions allow. While access is still loading, keep it minimal to avoid
// flashing items the user can't actually use.
const canSee = (key: string): boolean => {
if (ALWAYS_VISIBLE.has(key)) return true;
if (access.loading || !access.isMember) return false;
const perm = NAV_PERMISSION[key];
return perm ? access.can(perm) : true;
};
const visibleGroups = NAV_GROUPS
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.key)) }))
.filter((g) => g.items.length > 0);
// Real signed-in identity from the App Context Envelope; fall back to the static
// demo user only when the Shell isn't wired.
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
router.replace("/portal/login");
}
return (
<aside className="dash-sidebar">
<div className="dash-brand">
@@ -69,7 +128,7 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
</div>
<nav className="dash-nav">
{NAV_GROUPS.map((g, gi) => (
{visibleGroups.map((g, gi) => (
<div className="nav-section" key={gi}>
<div className="nav-group">{g.title}</div>
{g.items.map((it) => (
@@ -82,12 +141,20 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
))}
</nav>
<div className="sb-foot">
<button className="sb-user">
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span>
<div style={{ flex: 1, textAlign: "left" }}>
<div className="nm">{user.name}</div>
<div className="rl">{user.role}</div>
<div className="sb-foot" style={{ position: "relative" }}>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="sb-user-menu" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
</div>
<ChevronsUpDown size={15} />
</button>
+9 -2
View File
@@ -301,8 +301,15 @@ export function TeamManagement() {
</div>
<RoleChips roleIds={inv.roleIds} roleById={roleById} />
<div className="tm-invite-actions">
{inv.token && (
<Btn variant="soft" size="sm" icon="copy" onClick={async () => {
const link = `${window.location.origin}/portal/invite?token=${inv.token}`;
try { await navigator.clipboard.writeText(link); toast.push({ tone: "success", title: "Invite link copied", desc: `Send it to ${inv.email} to join.` }); }
catch { toast.push({ tone: "info", title: "Invite link", desc: link }); }
}}>Copy link</Btn>
)}
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); }
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${inv.email}.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
}}>Resend</Btn>
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
@@ -326,7 +333,7 @@ export function TeamManagement() {
try {
await team.invite(email, roleIds);
setTab("invites");
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
}}
/>
+30 -7
View File
@@ -1,20 +1,33 @@
"use client";
import { Sun, Moon, ChevronDown } from "lucide-react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui";
import { user } from "./account-data";
import { useAuth } from "@abe-kap/appshell-sdk/react";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
// When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user.
const { user: me } = useAuth();
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore */ }
router.replace("/portal/login");
}
return (
<header className="dash-topbar">
<div className="dash-title">
@@ -30,14 +43,24 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
<Icon name="bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button>
<button className="top-user">
<div className="top-user-wrap" style={{ position: "relative" }}>
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ textAlign: "left" }}>
<div style={{ textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl">{user.role}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
</div>
<ChevronDown size={16} />
</button>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
</div>
</div>
</header>
);
+2 -7
View File
@@ -145,18 +145,13 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
if (url) { window.location.href = url; return true; }
return false;
}
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) {
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
// Only Google is offered (Microsoft/Apple intentionally hidden).
return (
<div className="col gap-3">
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
<GoogleMark /> {verb} with Google
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
<MicrosoftMark /> {verb} with Microsoft / Outlook
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
<AppleMark /> {verb} with Apple
</button>
</div>
);
}
+104 -30
View File
@@ -9,8 +9,9 @@ import {
PasswordStrength,
} from "./bits";
import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP } from "@/lib/otp";
// Map the portal's social button ids to Supabase OAuth provider ids.
const OAUTH_PROVIDER: Record<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
@@ -24,13 +25,45 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() {
const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
const { login, loginWithOAuth, completeOAuthLogin, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null);
const [provider, setProvider] = useState<string>("");
const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>("");
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email");
const [invited, setInvited] = useState(false);
// Arriving from a team invite for an already-registered email → prefill it.
useEffect(() => {
try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvited(true); } } catch { /* ignore */ }
}, []);
// After a successful sign-in, redeem a pending team invite (if any), then go to the dashboard.
async function acceptPendingInviteThenDashboard() {
try {
const t = sessionStorage.getItem("invite_token");
if (t) {
await sdk.command("crm.team.invitation.accept", { token: t });
sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
}
} catch { /* invite expired/used — proceed to the dashboard anyway */ }
router.replace("/dashboard");
}
// Minimal account stand-in for passwordless entry points (Shell mode).
function blankAccount(): Account {
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
}
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
push("otp"); // push (not replace) so Back returns to the identify screen
}
/* ---- history hash sync ---- */
function push(s: Step) {
@@ -58,11 +91,31 @@ export function LoginFlow() {
if (!isShellConfigured()) return;
const code = new URLSearchParams(window.location.search).get("code");
if (!code) return;
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
if (!ready) { setStep("connecting"); return; }
setStep("connecting");
completeOAuthLogin(code)
.then((ace) => { if (ace) router.replace("/dashboard"); else replace("identify"); })
.catch(() => { setFlash("Sign-in with that provider didn't complete. Try again."); replace("identify"); });
}, [completeOAuthLogin, router]);
.then(async (ace) => {
if (!ace) { replace("identify"); return; }
// First-time Google user (no CRM profile yet) → complete onboarding; an
// existing profile → straight to the dashboard.
let registered = false;
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
if (registered) { await acceptPendingInviteThenDashboard(); return; }
// Capture the verified email now (session is fresh) so onboarding prefills it.
try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding");
})
.catch(() => {
setFlash("Google sign-in didn't complete. Please try again.");
replace("identify");
});
}, [ready, completeOAuthLogin, router, sdk, getUserEmail]);
/* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
@@ -100,7 +153,8 @@ export function LoginFlow() {
hasPasskey: false, hasTotp: false, hasPush: false,
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
replace("password");
setOtpChannel("email");
push("password"); // push (not replace) so Back returns to the email screen, not off-page
return;
}
push("connecting");
@@ -116,14 +170,18 @@ export function LoginFlow() {
/* =================================================================== */
return (
<div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />}
{step === "identify" && (
<>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} invited={invited} />
</>
)}
{step === "connecting" && (
<div className="interstitial">
<Spinner lg />
<div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div>
</div>
)}
@@ -162,11 +220,11 @@ export function LoginFlow() {
)}
{step === "password" && account && (
<Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
<Password account={account} email={email} login={login} onAuthenticated={acceptPendingInviteThenDashboard} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)}
{step === "otp" && account && (
<OtpVerify account={account} email={email} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} />
)}
{step === "another" && account && (
@@ -230,17 +288,17 @@ export function LoginFlow() {
/* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: {
email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void;
onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; invited?: boolean;
}) {
const [showEmail, setShowEmail] = useState(false);
const [showEmail, setShowEmail] = useState(!!invited);
const valid = EMAIL_RE.test(email);
return (
<div>
<div className="kicker">Welcome back</div>
<h1>Sign in to LynkedUp</h1>
<p className="sub">Drone inspections, AI estimates and insurance-ready reports all in one place.</p>
<div className="kicker">{invited ? "You're invited" : "Welcome back"}</div>
<h1>{invited ? "Sign in to join the team" : "Sign in to LynkedUp"}</h1>
<p className="sub">{invited ? "You already have an account — sign in to accept your invitation." : "Drone inspections, AI estimates and insurance-ready reports — all in one place."}</p>
<div style={{ marginTop: 22 }}>
<SocialButtons onPick={onSocial} />
@@ -263,6 +321,12 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
</button>
</form>
)}
{/* Phone (SMS) sign-in needs a deliverable one-time code hidden while SMS is
mocked, since a faked code can't mint a real session. Email + password + Google
all work without SMS. */}
{isShellConfigured() && !MOCK_OTP && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
@@ -271,7 +335,6 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span>
</div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div>
);
}
@@ -306,7 +369,6 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
<button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button>
</div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} />
</div>
);
@@ -357,20 +419,19 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
</form>
<div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button>
<button className="link" onClick={onOtp}>Sign in with a one-time code</button>
</div>
{!isShellConfigured() && <p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>}
</div>
);
}
function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
}) {
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
const shell = isShellConfigured();
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email");
const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
const [phone, setPhone] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
@@ -393,7 +454,14 @@ function OtpVerify({ account, email, remember, setRemember, onBack, onVerified,
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
await sendPhoneOtp(phone); setSent(true);
}
} catch (e) { setError((e as Error).message); }
} catch (e) {
// gotrue can throw an error whose message is an unhelpful "{}"/JSON blob (e.g. a
// 500 "Error sending magic link email" when SMTP isn't set up). Show something
// readable and actionable instead of the raw payload.
const raw = (e as { message?: unknown })?.message;
const readable = typeof raw === "string" && raw.trim() && !raw.trim().startsWith("{") ? raw.trim() : "";
setError(readable || "We couldn't send your sign-in code right now. Please try again shortly, or sign in with your password.");
}
}
async function complete(code: string) {
@@ -412,12 +480,22 @@ function OtpVerify({ account, email, remember, setRemember, onBack, onVerified,
<div>
<StepBack onClick={onBack} />
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<p className="sub">{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}</p>
<p className="sub">
{!shell
? "Enter the 6-digit code we sent you to finish signing in."
: channel === "sms"
? "We'll text a 6-digit code to sign in."
: "We'll email you a 6-digit code to sign in."}
</p>
{/* In Shell mode the channel is fixed by how you signed in (email vs phone)
no toggle, so an email sign-in never shows a stray SMS option. */}
{!shell && (
<div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
{!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
</div>
)}
{shell && channel === "sms" && !sent ? (
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
@@ -440,7 +518,6 @@ function OtpVerify({ account, email, remember, setRemember, onBack, onVerified,
<span />
</div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
{!shell && <p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>}
</div>
);
}
@@ -495,7 +572,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button>
<RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div>
);
}
@@ -514,7 +590,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div>
);
}
@@ -598,7 +673,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div>
);
}
+237 -153
View File
@@ -5,26 +5,35 @@ import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons";
import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal,
PasswordStrength, LegalModal,
} from "./bits";
import {
countryCodes, relationshipOptions, addressCountries,
countryCodes, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
import { MOCK_OTP, DEMO_OTP } from "@/lib/otp";
type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
const STEPS = ["Account", "Verify", "Address"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function RegisterFlow() {
export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
// profile: email is skipped (Google-verified), the auth account already exists so
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter();
const { register } = useAuth();
const { register, setPassword, getUserEmail, addPhone, verifyPhone } = useAuth();
const { sdk } = useAppShell();
const [step, setStep] = useState(0);
const [submitErr, setSubmitErr] = useState("");
// Verifying a phone via Supabase needs a live session. Onboarding already has one
// (Google OAuth); registration creates the account when leaving the Account step,
// then attaches + verifies the phone on it. `creating` guards the Account button.
const [creating, setCreating] = useState(false);
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
const [regAddr, setRegAddr] = useState<Addr>({});
@@ -39,29 +48,91 @@ export function RegisterFlow() {
const [cc, setCc] = useState("+1");
const [phone, setPhone] = useState("");
const [pw, setPw] = useState("");
const [relationship, setRelationship] = useState("Customer");
const [alloeNo, setAlloeNo] = useState("");
const [alloeFirst, setAlloeFirst] = useState("");
const [alloeLast, setAlloeLast] = useState("");
const [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = useState(false);
// verify step
const [emailVerified, setEmailVerified] = useState(false);
// verify step (email is trusted without an OTP — see verifyValid below)
const [phoneVerified, setPhoneVerified] = useState(false);
// When arriving from a team invite, the email is fixed to the invited address.
const [invitedEmail, setInvitedEmail] = useState("");
// Paid founder-access token (from the ?ft= link / the /thanks redirect / welcome email).
const [founderToken, setFounderToken] = useState("");
// Registration is founder-access-only right now: a paid founder (ft token) or a team
// invitee may register; open sign-ups are blocked. "used" = the founder link is spent.
const [gate, setGate] = useState<"loading" | "allowed" | "blocked" | "used">(onboard ? "allowed" : "loading");
useEffect(() => {
if (onboard) return;
const ft = new URLSearchParams(window.location.search).get("ft") ?? "";
let inviteEmail = "";
try { inviteEmail = sessionStorage.getItem("invite_email") ?? ""; } catch { /* ignore */ }
if (ft) {
// Validate + prefill from the paid founder record before letting them register.
fetch(`/api/founder/lookup?token=${encodeURIComponent(ft)}`, { cache: "no-store" })
.then((r) => r.json())
.then((d: { ok?: boolean; status?: string; email?: string; firstName?: string; lastName?: string; phone?: string }) => {
if (d?.ok && d.status === "valid") {
setFounderToken(ft);
if (d.email) { setEmail(d.email); setInvitedEmail(d.email); } // reuse the email-lock UI
if (d.firstName) setFirst(d.firstName);
if (d.lastName) setLast(d.lastName);
if (d.phone) setPhone(String(d.phone).replace(/\D/g, "").slice(-10));
setGate("allowed");
} else if (d?.status === "used") {
setGate("used");
} else {
setGate("blocked");
}
})
.catch(() => setGate("blocked"));
} else if (inviteEmail) {
setEmail(inviteEmail); setInvitedEmail(inviteEmail);
setGate("allowed"); // invited team member
} else {
setGate("blocked"); // open registration is disabled
}
}, [onboard]);
// Onboarding: prefill the verified email — from the session-storage hint the login
// page stashed at OAuth time, then confirmed via the live Supabase session.
useEffect(() => {
if (!onboard) return;
try { const cached = sessionStorage.getItem("onboard_email"); if (cached) setEmail(cached); } catch { /* ignore */ }
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS;
const isSelf = relationship === "Customer" || relationship === "Owner";
const isEmployee = relationship === "Employee";
const country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{4,}$/.test(alloeNo);
const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
termsOk && privacyOk;
const step2Valid = emailVerified && phoneVerified;
// Email is already trusted in both flows (registration: Supabase auto-confirms on
// signup; onboarding: Google-verified), so the Verify step only gates on the phone.
const verifyValid = phoneVerified;
// Registration: create the auth account when leaving the Account step, so the phone
// can be attached + verified against a live session at the Verify step. Onboarding
// is already authenticated, so it just advances.
async function leaveAccountStep() {
if (onboard || !isShellConfigured() || sso) { setStep(1); return; }
setSubmitErr("");
setCreating(true);
try {
await register(email, pw);
setStep(1);
} catch {
setSubmitErr("We couldn't create that account. The email may already be registered.");
} finally {
setCreating(false);
}
}
async function finish() {
const finalEmail = sso?.email || email;
@@ -69,66 +140,97 @@ export function RegisterFlow() {
name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: finalEmail,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
};
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
if (isShellConfigured()) {
setSubmitErr("");
// 1) Auth account — appshell → Supabase (SSO users already have a session).
if (!sso) {
try { await register(finalEmail, pw); }
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
// 1) Auth account already exists at this point — registration created it when
// leaving the Account step; onboarding users signed in via Google. Onboarding
// additionally sets a password so email+password login works too (the phone was
// just verified against the same live session).
if (onboard && pw) {
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
}
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
// 2) Persist the CRM registration payload to be-crm (name, phone, addresses,
// consent). No role/persona — a new user has no permissions until invited.
// Non-fatal: the auth account exists either way.
try {
const mailing = mailingSame ? regAddr : mailAddr;
await sdk.command("crm.account.register", {
email: finalEmail,
firstName: first, lastName: last,
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
persona: relationship,
isAllottee: isSelf,
...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }),
registeredAddress: regAddr,
mailingAddress: mailing,
mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk,
// Paid-founder registration: claims the token + provisions owner in be-crm.
...(founderToken ? { founderToken } : {}),
});
} catch (e) {
console.warn("crm.account.register failed (continuing):", e);
} catch {
// For onboarding OR a paid founder this step matters (owner provisioning), so
// surface it. In plain register mode it's non-fatal — the auth account exists.
if (onboard || founderToken) { setSubmitErr("Couldn't finish setting up your account. Please try again."); return; }
}
// 3) If the user arrived from a team invitation link, redeem it now — this
// creates their membership with the invited role(s). Non-fatal.
try {
const inviteToken = sessionStorage.getItem("invite_token");
if (inviteToken) {
await sdk.command("crm.team.invitation.accept", { token: inviteToken });
sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
}
} catch { /* invitation expired/used — they can still be invited again */ }
}
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
router.push("/dashboard");
}
// Founder-access gate: only a paid founder or a team invitee may register right now.
if (gate === "loading") {
return <div className="card anim-fade-up" style={{ textAlign: "center", padding: "44px 0", color: "var(--muted, #94a3b8)" }}>Checking your access</div>;
}
if (gate === "used") {
return <FounderNotice icon="check" title="This link has already been used"
body="Your Founders account is already set up. Please sign in to continue."
cta="Go to sign in" onCta={() => router.push("/portal/login")} />;
}
if (gate === "blocked") {
return <FounderNotice icon="lock" title="Founder access required"
body="Registration is currently open to Founding Members only. Get founder access to create your account, or sign in if you already have one."
cta="Get founder access" onCta={() => { window.location.href = "/"; }}
secondaryCta="Sign in" onSecondary={() => router.push("/portal/login")} />;
}
return (
<div className="card anim-fade-up">
<Stepper current={step} />
<Stepper current={step} labels={STEP_LABELS} />
{step === 0 && (
<>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAccount
onboard={onboard} creating={creating} invited={!!invitedEmail}
sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw}
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid}
onContinue={() => setStep(1)} toLogin={() => router.push("/portal/login")}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
/>
</>
)}
{step === 1 && (
<StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified}
cc={cc} phone={phone} country={country}
phoneVerified={phoneVerified} setPhoneVerified={setPhoneVerified}
valid={step2Valid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
valid={verifyValid} onBack={() => setStep(0)} onContinue={() => setStep(2)}
/>
)}
@@ -142,6 +244,28 @@ export function RegisterFlow() {
);
}
/* ====================== FOUNDER-ACCESS GATE ====================== */
function FounderNotice({ icon, title, body, cta, onCta, secondaryCta, onSecondary }: {
icon: string; title: string; body: string; cta: string; onCta: () => void;
secondaryCta?: string; onSecondary?: () => void;
}) {
return (
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
<div style={{ display: "flex", justifyContent: "center", marginBottom: 14 }}>
<span className="addr-tile addr-tile-amber" style={{ width: 52, height: 52 }}><Icon name={icon} size={22} /></span>
</div>
<h1>{title}</h1>
<p className="sub" style={{ marginTop: 8 }}>{body}</p>
<button className="btn btn-primary" style={{ marginTop: 20 }} onClick={onCta}>{cta} <Icon name="arrowR" size={16} /></button>
{secondaryCta && onSecondary && (
<p className="foot-note" style={{ marginTop: 14 }}>
Already a member? <button className="link" onClick={onSecondary}>{secondaryCta}</button>
</p>
)}
</div>
);
}
/* ====================== STEP 0 — ACCOUNT ====================== */
function StepAccount(p: {
sso: { provider: string; email: string } | null; setSso: (v: { provider: string; email: string } | null) => void;
@@ -149,59 +273,53 @@ function StepAccount(p: {
first: string; setFirst: (v: string) => void; last: string; setLast: (v: string) => void;
cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
pw: string; setPw: (v: string) => void;
relationship: string; setRelationship: (v: string) => void; isSelf: boolean;
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
valid: boolean; onContinue: () => void; toLogin: () => void;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; invited?: boolean;
}) {
const [phase, setPhase] = useState<"sso" | "profile">("sso");
// Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
const [modal, setModal] = useState<null | "terms" | "privacy">(null);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") {
const valid = EMAIL_RE.test(p.email);
return (
<div>
<h1>Create your account</h1>
<p className="sub">Sign up with a provider or your email to get started.</p>
<StepBack onClick={p.toLogin} />
<h1>{p.invited ? "Accept your invitation" : "Create your account"}</h1>
<p className="sub">{p.invited ? "You've been invited to the team. Set up your account to join." : "Enter your email to get started."}</p>
<div style={{ marginTop: 20 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field">
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus />
<input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!p.invited} disabled={p.invited} readOnly={p.invited} />
</div>
{p.invited && <span className="faint" style={{ fontSize: 12 }}>This is the address you were invited with.</span>}
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button>
</form>
</div>
<p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p>
{!p.invited && <p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p>}
</div>
);
}
return (
<div>
{/* Non-onboarding users can step back to the email screen; onboarding starts
here (email came from Google), so there's nothing to go back to. */}
{!p.onboard && <StepBack onClick={() => setPhase("sso")} />}
<h1>Complete your profile</h1>
<p className="sub">Tell us a bit about you to set up your account.</p>
<div style={{ marginTop: 16 }}>
{p.sso ? (
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
) : (
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div>
)}
<div className="field" style={{ marginTop: 16 }}>
<label className="label">Email address</label>
<div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} disabled readOnly aria-label="Email address" />
</div>
<span className="faint" style={{ fontSize: 12 }}>{p.onboard ? "Verified with Google — this can't be changed." : "The email you're registering with."}</span>
</div>
<div className="row gap-3" style={{ marginTop: 18, alignItems: "center" }}>
@@ -245,47 +363,6 @@ function StepAccount(p: {
</div>
)}
<div className="field" style={{ marginTop: 14 }}>
<label className="label">Your role</label>
<select className="input" value={p.relationship} onChange={(e) => p.setRelationship(e.target.value)}>
{relationshipOptions.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
{p.isSelf ? (
<div style={{ marginTop: 12 }}><FlashNote tone="success">{p.relationship === "Owner" ? "Owner" : "Customer"} account you own this property.</FlashNote></div>
) : (() => {
const isEmployee = p.relationship === "Employee";
const cfg = ({
Employee: { banner: "Registering as a LynkedUp Pro team member.", title: "Employee details", idLabel: "Employee ID", idPh: "EMP-12345" },
Contractor:{ banner: "Registering on behalf of the property owner.", title: "Contractor details", idLabel: "Contractor ID", idPh: "Alphanumeric ID" },
"Sub-Con": { banner: "Registering on behalf of the property owner.", title: "Sub-contractor details", idLabel: "Sub-contractor ID", idPh: "Alphanumeric ID" },
Vendor: { banner: "Registering on behalf of the property owner.", title: "Vendor details", idLabel: "Vendor ID", idPh: "Alphanumeric ID" },
} as Record<string, { banner: string; title: string; idLabel: string; idPh: string }>)[p.relationship]
?? { banner: "Registering on behalf of the property owner.", title: "Property Owner Details", idLabel: "Owner / Property ID", idPh: "Alphanumeric ID" };
return (
<div style={{ marginTop: 12 }}>
<FlashNote tone="info">{cfg.banner}</FlashNote>
<div className="dashed-block" style={{ marginTop: 12 }}>
<div className="row between" style={{ marginBottom: 12 }}>
<strong style={{ fontSize: 13.5 }}>{cfg.title}</strong>
{p.alloeNo && (p.alloeIdOk ? <Badge tone="green"><Icon name="check" size={12} /> Valid</Badge> : <Badge tone="gray">Checking</Badge>)}
</div>
<div className="field">
<label className="label">{cfg.idLabel}</label>
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder={cfg.idPh} />
</div>
{!isEmployee && (
<div className="row gap-3" style={{ marginTop: 12 }}>
<div className="field grow"><label className="label">Owner first name</label><input className="input" value={p.alloeFirst} onChange={(e) => p.setAlloeFirst(e.target.value)} /></div>
<div className="field grow"><label className="label">Owner last name</label><input className="input" value={p.alloeLast} onChange={(e) => p.setAlloeLast(e.target.value)} /></div>
</div>
)}
</div>
</div>
);
})()}
<div className="col gap-2" style={{ marginTop: 16 }}>
<label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
@@ -299,7 +376,9 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account &amp; Verify <Icon name="arrowR" size={16} /></button>
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid || p.creating} onClick={p.onContinue}>
{p.creating ? "Creating account…" : p.onboard ? "Continue" : "Create Account & Verify"} {!p.creating && <Icon name="arrowR" size={16} />}
</button>
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -309,10 +388,13 @@ function StepAccount(p: {
// module-level reviewed flags (per mount lifetime) — enables the checkboxes after a doc is read
const reviewed = { terms: false, privacy: false };
/* ====================== STEP 1 — VERIFY ====================== */
/* ====================== STEP 1 VERIFY PHONE ======================
Email is already trusted (registration: Supabase auto-confirms on signup;
onboarding: Google-verified), so this step only verifies the phone via a real
SMS OTP: addPhone() Twilio texts a code verifyPhone() confirms it. Both
run against the live Supabase session established in the previous step. */
function StepVerify(p: {
emailValue: string; cc: string; phone: string; country: typeof countryCodes[number];
emailVerified: boolean; setEmailVerified: (v: boolean) => void;
cc: string; phone: string; country: typeof countryCodes[number];
phoneVerified: boolean; setPhoneVerified: (v: boolean) => void;
valid: boolean; onBack: () => void; onContinue: () => void;
}) {
@@ -320,12 +402,11 @@ function StepVerify(p: {
return (
<div>
<StepBack onClick={p.onBack} />
<h1>Verify email &amp; phone</h1>
<p className="sub">Confirm both so we can secure your account.</p>
<h1>Verify your phone</h1>
<p className="sub">We&apos;ll text a one-time code to confirm your number. Your email is already verified.</p>
<div className="col gap-4" style={{ marginTop: 16 }}>
<VerifyChannel kind="email" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.emailVerified} onVerified={() => p.setEmailVerified(true)} />
<VerifyChannel kind="phone" initial={p.emailValue} country={p.country} cc={p.cc} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
<PhoneVerify cc={p.cc} country={p.country} initialPhone={p.phone} verified={p.phoneVerified} onVerified={() => p.setPhoneVerified(true)} />
</div>
<div style={{ marginTop: 14 }}><RememberDevice checked={remember} onChange={setRemember} /></div>
@@ -334,40 +415,53 @@ function StepVerify(p: {
);
}
type SendState = "idle" | "sending" | "ok" | "invalid_email" | "mailbox_full" | "bounce_risk" | "invalid_mobile";
function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onVerified }: {
kind: "email" | "phone"; initial: string; country: typeof countryCodes[number]; cc: string; initialPhone: string;
type SendState = "idle" | "sending" | "sent" | "invalid_mobile" | "send_error";
function PhoneVerify({ cc, country, initialPhone, verified, onVerified }: {
cc: string; country: typeof countryCodes[number]; initialPhone: string;
verified: boolean; onVerified: () => void;
}) {
const [value, setValue] = useState(kind === "email" ? initial : initialPhone);
const [via, setVia] = useState<"primary" | "wa">("primary");
const { addPhone, verifyPhone } = useAuth();
const [value, setValue] = useState(initialPhone);
const [state, setState] = useState<SendState>("idle");
const [otpError, setOtpError] = useState(false);
const primaryLabel = kind === "email" ? "Email" : "SMS";
const [otpError, setOtpError] = useState("");
const e164 = `${cc}${value.replace(/\D/g, "")}`;
function send() {
async function send() {
if (value.replace(/\D/g, "").length !== country.digits) { setState("invalid_mobile"); return; }
setState("sending");
setTimeout(() => {
if (kind === "email") {
if (!EMAIL_RE.test(value)) return setState("invalid_email");
if (value.includes("full")) return setState("mailbox_full");
if (value.includes("bo")) return setState("bounce_risk");
return setState("ok");
} else {
if (value.replace(/\D/g, "").length !== country.digits) return setState("invalid_mobile");
return setState("ok");
setOtpError("");
if (MOCK_OTP) { setState("sent"); return; } // demo: skip Twilio entirely
try {
await addPhone(e164); // Supabase → Twilio sends the SMS OTP
setState("sent");
} catch {
setState("send_error");
}
}
async function submit(code: string) {
setOtpError("");
if (MOCK_OTP) {
if (code === DEMO_OTP) onVerified();
else setOtpError(`Demo mode — enter ${DEMO_OTP} to verify.`);
return;
}
try {
await verifyPhone(e164, code); // confirm the OTP (phone_change)
onVerified();
} catch {
setOtpError("That code didn't match. Check the SMS and try again.");
}
}, 700);
}
if (verified) {
return (
<div className="vchannel verified">
<div className="row between">
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email" : "Mobile"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile</span>
<Badge tone="green"><Icon name="check" size={12} /> Verified</Badge>
</div>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{kind === "email" ? value : `${cc} ${value}`}</p>
<p className="faint" style={{ fontSize: 12.5, marginTop: 8 }}>{cc} {value}</p>
</div>
);
}
@@ -375,46 +469,36 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
return (
<div className="vchannel">
<div className="row between" style={{ marginBottom: 12 }}>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name={kind === "email" ? "mail" : "phone"} size={16} /> {kind === "email" ? "Email address" : "Mobile number"}</span>
<span className="row gap-2" style={{ fontWeight: 600, fontSize: 14 }}><Icon name="phone" size={16} /> Mobile number</span>
</div>
{kind === "email" ? (
<input className="input" value={value} onChange={(e) => { setValue(e.target.value); setState("idle"); }} placeholder="you@example.com" />
) : (
<div className="phone-row">
<span className="input cc-select" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 7 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="cc-flag-img static" src={flagUrl(country.flag)} alt={country.name} width={22} height={16} />
{cc}
</span>
<input className="input grow" inputMode="numeric" value={value} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
<input className="input grow" inputMode="numeric" value={value} disabled={state === "sent"} onChange={(e) => { setValue(e.target.value.replace(/\D/g, "")); setState("idle"); }} placeholder={country.example} />
</div>
)}
<div className="row between" style={{ marginTop: 12 }}>
<div className="seg">
<button className={via === "primary" ? "on" : ""} onClick={() => setVia("primary")}>{primaryLabel}</button>
<button className={via === "wa" ? "on" : ""} onClick={() => setVia("wa")}><Icon name="whatsapp" size={14} /> WhatsApp</button>
</div>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "ok"} onClick={send}>
{state === "sending" ? "Sending…" : "Send code"}
<span className="faint" style={{ fontSize: 12 }}>{MOCK_OTP ? "Demo verification (no SMS sent)." : "Standard SMS rates may apply."}</span>
<button className="btn btn-sm" style={{ width: "auto" }} disabled={state === "sending" || state === "sent"} onClick={send}>
{state === "sending" ? "Sending…" : state === "sent" ? "Sent" : "Send code"}
</button>
</div>
{state === "ok" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>OTP sent via {via === "wa" ? "WhatsApp" : primaryLabel}.</p>}
{state === "invalid_email" && <div style={{ marginTop: 10 }}><FlashNote tone="error">That email address looks invalid.</FlashNote></div>}
{state === "mailbox_full" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">This mailbox appears full try another email.</FlashNote></div>}
{state === "bounce_risk" && <div style={{ marginTop: 10 }}><FlashNote tone="warn">High bounce risk for this address.</FlashNote></div>}
{state === "sent" && <p className="faint" style={{ fontSize: 12, marginTop: 10 }}>{MOCK_OTP ? `Demo mode — enter ${DEMO_OTP} to verify (SMS temporarily disabled).` : `Code sent to ${cc} ${value} by SMS.`}</p>}
{state === "invalid_mobile" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Enter a valid {country.digits}-digit mobile number.</FlashNote></div>}
{state === "send_error" && <div style={{ marginTop: 10 }}><FlashNote tone="error">Couldn&apos;t send the code. Check the number and try again.</FlashNote></div>}
{state === "ok" && (
{state === "sent" && (
<div style={{ marginTop: 14 }}>
<OtpBoxes onComplete={(c) => { if (c === "000000") setOtpError(true); else { setOtpError(false); onVerified(); } }} error={otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
<OtpBoxes onComplete={submit} error={!!otpError} />
{otpError && <div style={{ marginTop: 10 }}><FlashNote tone="error">{otpError}</FlashNote></div>}
<div style={{ marginTop: 12 }}><ResendLink seconds={60} onResend={send} /></div>
</div>
)}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div>
);
}
@@ -592,10 +676,10 @@ function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a:
}
/* ====================== stepper ====================== */
function Stepper({ current }: { current: number }) {
function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
return (
<div className="steps">
{STEPS.map((label, i) => {
{labels.map((label, i) => {
const done = i < current, on = i === current;
return (
<div key={label} style={{ display: "contents" }}>
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
/**
* The signed-in user's effective CRM access, from crm.account.me. Drives navigation
* gating: a user with no membership/permissions sees only Dashboard + Profile; members
* see the areas their permissions allow.
*
* Every CRM permission id, granted to superadmins / owners. Used as the mock default
* when the Shell isn't configured (local demo shows everything).
*/
export const ALL_CRM_PERMISSIONS = [
"leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage",
"reports.view", "billing.view", "team.manage", "roles.manage", "settings.manage",
] as const;
export interface MyAccess {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
/** True while the access query is still resolving (nav stays minimal until then). */
loading: boolean;
can: (permission: string) => boolean;
}
interface MeDTO {
registered: boolean;
isMember: boolean;
roleSlugs: string[];
permissions: string[];
isSuperadmin: boolean;
}
function useLiveAccess(): MyAccess {
const { data, loading } = useQuery<MeDTO>("crm.account.me");
const permissions = data?.permissions ?? [];
return {
registered: data?.registered ?? false,
isMember: data?.isMember ?? false,
roleSlugs: data?.roleSlugs ?? [],
permissions,
isSuperadmin: data?.isSuperadmin ?? false,
loading,
can: (p: string) => permissions.includes(p),
};
}
function useMockAccess(): MyAccess {
// No Shell (local demo): show everything so the mock UI is fully browsable.
const permissions = [...ALL_CRM_PERMISSIONS];
return {
registered: true,
isMember: true,
roleSlugs: ["superadmin"],
permissions,
isSuperadmin: true,
loading: false,
can: () => true,
};
}
export const useMyAccess: () => MyAccess = isShellConfigured() ? useLiveAccess : useMockAccess;
+123
View File
@@ -0,0 +1,123 @@
// Shared founder-access lead shape + server-side validation. Ported from the
// landing's api/_lead.js: we collect the buyer's details on our own form and only
// then hand off to Stripe, so the payment method (incl. Apple Pay express) can
// never skip contact capture. The client form validates for UX; this validates
// for real. Keep in sync with public/square-checkout.js.
export const SALES_REP_BUCKETS = ["1", "2-5", "6-10", "11-25", "26-50", "51+"] as const;
// 1..SELF_SERVE_MAX -> self-serve Stripe checkout. >SELF_SERVE_MAX -> sales.
export const MAX_LICENSES = 10;
export const SELF_SERVE_MAX = 5;
export interface Lead {
firstName: string; lastName: string; contactName: string;
companyName: string; salesReps: string;
streetAddress: string; city: string; state: string; zip: string; businessAddress: string;
phone: string; email: string; smsConsent: boolean; licenses: number;
}
export type ValidateResult =
| { ok: true; lead: Lead }
| { ok: false; errors: Record<string, string> };
const FIELD_LABELS: Record<string, string> = {
firstName: "First name", lastName: "Last name", companyName: "Company name",
salesReps: "Number of sales reps", streetAddress: "Street address", city: "City",
state: "State", zip: "ZIP code", phone: "Phone number", email: "Email address",
smsConsent: "SMS consent",
};
function str(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
// Deliberately permissive — a marketing lead form, not an auth system.
function isEmail(v: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v);
}
function digitCount(v: string): number {
return (v.match(/\d/g) || []).length;
}
/** Validate + normalize the payload posted from the checkout form. */
export function validateLead(body: Record<string, unknown>): ValidateResult {
const errors: Record<string, string> = {};
const firstName = str(body.firstName);
const lastName = str(body.lastName);
const companyName = str(body.companyName);
const salesReps = str(body.salesReps);
const streetAddress = str(body.streetAddress);
const city = str(body.city);
const state = str(body.state);
const zip = str(body.zip);
const phone = str(body.phone);
const email = str(body.email).toLowerCase();
const smsConsent = body.smsConsent === true || body.smsConsent === "true" || body.smsConsent === "on";
const contactName = `${firstName} ${lastName}`.trim();
const businessAddress = [streetAddress, city, [state, zip].filter(Boolean).join(" ")]
.filter(Boolean)
.join(", ");
// Absent -> 1 (older clients / the $1 demo). Present but out of range -> error,
// so the buyer sees it rather than being silently clamped to a different charge.
let licenses = 1;
const raw = body.licenses;
if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
const n = Number(raw);
if (!Number.isInteger(n) || n < 1 || n > MAX_LICENSES) {
errors.licenses = `Please choose between 1 and ${MAX_LICENSES} licenses.`;
} else {
licenses = n;
}
}
if (firstName.length < 1) errors.firstName = "Please enter your first name.";
if (lastName.length < 1) errors.lastName = "Please enter your last name.";
if (companyName.length < 2) errors.companyName = "Please enter your company name.";
if (!SALES_REP_BUCKETS.includes(salesReps as (typeof SALES_REP_BUCKETS)[number])) {
errors.salesReps = "Please select how many sales reps you have.";
}
if (streetAddress.length < 3) errors.streetAddress = "Please enter your street address.";
if (city.length < 2) errors.city = "Please enter your city.";
if (state.length < 2) errors.state = "Please enter your state.";
if (!/^\d{5}(-\d{4})?$/.test(zip)) errors.zip = "Please enter a valid ZIP code.";
const digits = digitCount(phone);
if (digits < 10 || digits > 15) errors.phone = "Please enter a valid phone number.";
if (!isEmail(email)) errors.email = "Please enter a valid email address.";
// SMS consent is required — the founder flow depends on being able to SMS members.
if (!smsConsent) errors.smsConsent = "Please agree to receive SMS updates to continue.";
// Guard against absurd input reaching Stripe metadata (500-char cap) or the CRM.
for (const [key, label] of Object.entries(FIELD_LABELS)) {
const value = str(body[key]);
if (!errors[key] && value.length > 300) errors[key] = `${label} is too long.`;
}
if (Object.keys(errors).length) return { ok: false, errors };
return {
ok: true,
lead: {
firstName, lastName, contactName, companyName, salesReps,
streetAddress, city, state, zip, businessAddress,
phone, email, smsConsent, licenses,
},
};
}
/** Stripe metadata (strings only; <=50 keys, values <=500 chars). Stripe is the
* system of record for what was captured at purchase, so a failed fulfill can be
* replayed from the webhook. */
export function leadToStripeMetadata(lead: Lead, extra?: Record<string, string>): Record<string, string> {
return {
first_name: lead.firstName, last_name: lead.lastName, contact_name: lead.contactName,
company_name: lead.companyName, sales_reps: lead.salesReps,
street_address: lead.streetAddress, city: lead.city, state: lead.state, zip: lead.zip,
business_address: lead.businessAddress, phone: lead.phone, email: lead.email,
sms_consent: lead.smsConsent ? "true" : "false", num_licenses: String(lead.licenses || 1),
...(extra ?? {}),
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Demo OTP fallback, shared by the login and registration flows.
*
* While the Twilio SMS sender is down we can't deliver real one-time codes. When
* MOCK_OTP is on, phone verification steps skip Supabase/Twilio and accept a fixed
* DEMO_OTP instead. Flip NEXT_PUBLIC_MOCK_OTP to "false" (or remove it) to restore
* real SMS no other change needed.
*
* IMPORTANT: this only substitutes for a *secondary* verification (e.g. confirming a
* phone during registration/onboarding, where the session already exists). It cannot
* mock a *login* whose sole credential is the OTP there the OTP verification is what
* mints the session, and a faked code produces no session (the dashboard's AuthGate
* would bounce the user straight back). Passwordless login therefore stays real.
*/
export const MOCK_OTP = process.env.NEXT_PUBLIC_MOCK_OTP === "true";
export const DEMO_OTP = "123456";
+373
View File
@@ -0,0 +1,373 @@
"use client";
// Profile data layer. Serves EITHER the local mock (when the Shell isn't configured
// — the polished demo keeps working) OR the live be-crm data door, behind one
// interface so the Profile screens are mode-agnostic. Mirrors team-api.ts.
//
// Live contract (be-crm profile module):
// query crm.account.read -> AccountReadDTO
// cmd crm.account.revealField { houseNumber } -> { revealed }
// cmd crm.account.update { …fields } -> AccountReadDTO
// cmd crm.account.requestContactChangeOtp { channel, newValue } -> { challengeId }
// cmd crm.account.changeContact { channel, newValue, challengeId, otp }
// query crm.kyc.list -> KycListDTO
// cmd crm.kyc.upload { slot, docTypeId?, file }
// cmd crm.kyc.remove { slot }
// query crm.notifications.get -> NotificationsGetDTO
// cmd crm.notifications.setPreference/setTimezone/setContactWindow
// cmd crm.notifications.addDestination/verifyDestination
// query crm.consent.list -> ConsentGroupDTO[]
// cmd crm.consent.set { groupId, itemId, granted }
//
// Security (password / 2FA / login alerts) and Devices are Shell-owned and are
// intentionally absent here — those tabs stay on the local mock.
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
user as seedUser, kyc as seedKyc, kycDocTypes as seedDocTypes,
contactPrefs as seedPrefs, contactDestinations as seedDests,
contactTimezones as seedTz, contactTimes as seedWindows, consentGroups as seedConsent,
} from "@/components/dashboard/account-data";
/* ---- UI-facing shapes ---------------------------------------------------- */
export type ContactKind = "email" | "mobile" | "whatsapp";
export type KycKey = "photo" | "id" | "address";
export type KycStatus = "missing" | "uploaded" | "verified" | "rejected";
export type NotifChannel = "email" | "sms" | "whatsapp" | "push";
export type DestChannel = "email" | "sms" | "whatsapp";
export interface UiAccount {
principalId: string;
name: string; role: string; initials: string; avatarGradient: string;
memberSince: string; status: "active" | "suspended";
allotmentNo: string | null; plot: string | null; sector: string | null;
pocket: string | null; category: string | null; size: string | null;
isAllottee: boolean; relationship: string | null; allotteeName: string | null;
// Display values — masked unless `revealed`.
email: string; mobile: string; whatsapp: string; pan: string; aadhaar: string;
revealed: boolean; kycCompletionPct: number;
}
export interface UiKycSlot {
key: KycKey; title: string; required: boolean;
docId: string | null; fileName: string | null; status: KycStatus; note?: string;
}
export interface UiDocType { id: string; label: string; use: string; hint: string; formats: string; maxMb: number }
export interface UiKyc { slots: UiKycSlot[]; docTypes: UiDocType[]; completionPct: number }
export interface UiNotifCategory { id: string; label: string; desc: string; locked: boolean; email: boolean; sms: boolean; whatsapp: boolean; push: boolean }
export interface UiWindow { id: string; label: string; range: string; startTime: string; endTime: string; enabled: boolean }
export interface UiDestination { id: string; channel: DestChannel; label: string; value: string; verified: boolean; primary: boolean }
export interface UiNotifications { prefs: UiNotifCategory[]; timezone: string; timezoneOptions: string[]; windows: UiWindow[]; destinations: UiDestination[] }
export interface UiConsentItem { id: string; label: string; desc: string; granted: boolean; locked: boolean }
export interface UiConsentGroup { id: string; title: string; items: UiConsentItem[] }
export interface UploadFile { fileName: string; contentType: string; base64: string }
export interface ProfileData {
live: boolean; loading: boolean; error: string | null;
account: UiAccount;
kyc: UiKyc;
notifications: UiNotifications;
consent: UiConsentGroup[];
// Account
reveal: (houseNumber: string) => Promise<boolean>;
requestContactChangeOtp: (channel: ContactKind, newValue: string) => Promise<string>;
changeContact: (channel: ContactKind, newValue: string, challengeId: string, otp: string) => Promise<void>;
// KYC
uploadKyc: (slot: KycKey, docTypeId: string | null, file: UploadFile) => Promise<void>;
removeKyc: (slot: KycKey) => Promise<void>;
// Notifications
setPreference: (categoryId: string, channel: NotifChannel, value: boolean) => Promise<void>;
setTimezone: (timezone: string) => Promise<void>;
setContactWindow: (label: string, patch: { startTime: string; endTime: string; enabled: boolean }) => Promise<void>;
addDestination: (channel: DestChannel, label: string, value: string) => Promise<void>;
requestDestinationOtp: (id: string) => Promise<string>;
verifyDestination: (id: string, challengeId: string, otp: string) => Promise<void>;
// Consent
setConsent: (groupId: string, itemId: string, granted: boolean) => Promise<void>;
refetch: () => void;
}
/* ---- UI copy + static presentation maps ---------------------------------- */
const SLOT_META: Record<KycKey, { title: string }> = {
photo: { title: "Live Photo / Selfie" },
id: { title: "Identity Proof" },
address: { title: "Address Proof" },
};
const MIME_LABEL: Record<string, string> = { "application/pdf": "PDF", "image/jpeg": "JPG", "image/png": "PNG" };
const friendlyFormats = (mimes: string[]) => mimes.map((m) => MIME_LABEL[m] ?? m).join(", ");
const DOC_HINT: Record<string, string> = {
aadhaar: "Front & back, masked first 8 digits", pan: "Clear photo of the front",
passport: "Photo page, must be valid", voter_id: "Front & back", driving_license: "Front & back, not expired",
utility_bill: "Electricity / water, < 3 months old", bank_statement: "First page with address, < 3 months",
rent_agreement: "All pages, registered copy",
};
const CATEGORY_DESC: Record<string, string> = {
security: "New device, password and 2FA changes",
account: "Profile changes and account activity",
payments: "Invoices, payment reminders and receipts",
meetings: "Upcoming visits and rescheduling",
documents: "Document status and re-verification requests",
promotions: "Promotions, surveys and newsletters",
};
const CONSENT_DESC: Record<string, string> = {
terms_of_service: "Required to operate your account.",
kyc_verification: "Process KYC documents to meet regulatory obligations.",
marketing_email: "Offers, product news and updates by email.",
marketing_sms: "Offers and reminders by SMS/WhatsApp.",
partner_sharing: "Allow vetted partner brokers to process your data.",
};
// The four standard contact-time windows the UI offers. Live windows are these
// definitions overlaid with whatever be-crm has stored (times/enabled by label).
const WINDOW_DEFS: { label: string; startTime: string; endTime: string }[] = [
{ label: "Morning", startTime: "08:00", endTime: "12:00" },
{ label: "Afternoon", startTime: "12:00", endTime: "16:00" },
{ label: "Evening", startTime: "16:00", endTime: "20:00" },
{ label: "Night", startTime: "20:00", endTime: "22:00" },
];
const GRADIENTS = [
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#8b5cf6,#d946ef)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
/* ---- be-crm DTO types ---------------------------------------------------- */
interface AccountReadDTO {
principalId: string;
allotmentNo: string | null; plot: string | null; sector: string | null; pocket: string | null;
category: string | null; size: string | null; isAllottee: boolean;
relationship: string | null; allotteeName: string | null;
email: string | null; mobile: string | null; whatsapp: string | null;
pan: string | null; aadhaar: string | null;
revealed: boolean; memberSince: string; status: "active" | "suspended"; kycCompletionPct: number;
}
interface KycSlotDTO { slot: KycKey; docTypeId: string | null; fileName: string | null; status: KycStatus; rejectReason: string | null; uploadedAt: string | null }
interface KycListDTO { slots: KycSlotDTO[]; docTypes: { id: string; label: string; use: string; formats: string[]; maxMb: number }[]; completionPct: number }
interface PrefDTO { categoryId: string; label: string; locked: boolean; email: boolean; sms: boolean; whatsapp: boolean; push: boolean }
interface WindowDTO { id: string; label: string; startTime: string; endTime: string; enabled: boolean }
interface DestDTO { id: string; channel: DestChannel; label: string | null; value: string; verified: boolean; isPrimary: boolean }
interface NotificationsGetDTO { preferences: PrefDTO[]; timezone: string; timezoneOptions: string[]; windows: WindowDTO[]; destinations: DestDTO[] }
interface ConsentItemDTO { itemId: string; label: string; statutory: boolean; granted: boolean }
interface ConsentGroupDTO { groupId: string; label: string; items: ConsentItemDTO[] }
/* ---- shared derivations -------------------------------------------------- */
function kycNote(status: KycStatus, rejectReason: string | null): string | undefined {
if (rejectReason) return rejectReason;
if (status === "uploaded") return "Under review";
if (status === "verified") return "Verified";
return undefined;
}
function mergeWindows(stored: WindowDTO[]): UiWindow[] {
const byLabel = new Map(stored.map((w) => [w.label, w]));
return WINDOW_DEFS.map((def) => {
const w = byLabel.get(def.label);
const startTime = w?.startTime ?? def.startTime;
const endTime = w?.endTime ?? def.endTime;
return { id: w?.id ?? def.label, label: def.label, startTime, endTime, range: `${startTime} ${endTime}`, enabled: w?.enabled ?? false };
});
}
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
function useMockProfile(): ProfileData {
const [revealed, setRevealed] = useState(false);
const [contacts, setContacts] = useState({ email: seedUser.email.full, mobile: seedUser.mobile.full, whatsapp: seedUser.whatsapp.full });
const [slots, setSlots] = useState<UiKycSlot[]>(() =>
seedKyc.slots.map((s) => ({ key: s.key, title: SLOT_META[s.key].title, required: s.required, docId: s.docId, fileName: s.fileName, status: s.status, note: s.note })));
const [prefs, setPrefs] = useState<UiNotifCategory[]>(() =>
seedPrefs.map((c) => ({ id: c.id, label: c.label, desc: c.desc, locked: Boolean(c.locked), email: c.email, sms: c.sms, whatsapp: c.whatsapp, push: c.push })));
const [timezone, setTz] = useState(seedTz[0]);
const [windows, setWindows] = useState<UiWindow[]>(() =>
seedWindows.map((w) => ({ id: w.id, label: w.label, range: w.range, startTime: w.range.split(" ")[0], endTime: w.range.split(" ")[1], enabled: w.enabled })));
const [destinations, setDestinations] = useState<UiDestination[]>(() =>
seedDests.map((d) => ({ id: d.id, channel: d.channel, label: d.label, value: d.value, verified: d.verified, primary: d.primary })));
const [consent, setConsent] = useState<UiConsentGroup[]>(() =>
seedConsent.map((g) => ({ id: g.id, title: g.title, items: g.items.map((it) => ({ id: it.id, label: it.label, desc: it.desc, granted: it.granted, locked: Boolean(it.locked) })) })));
const docTypes: UiDocType[] = useMemo(() =>
seedDocTypes.map((d) => ({ id: d.id, label: d.label, use: d.use, hint: d.hint, formats: d.formats, maxMb: d.maxMb })), []);
const account: UiAccount = {
principalId: seedUser.id, name: seedUser.name, role: seedUser.role, initials: seedUser.initials,
avatarGradient: seedUser.avatarGradient, memberSince: seedUser.memberSince, status: seedUser.status,
allotmentNo: seedUser.allotmentNo, plot: seedUser.plot, sector: seedUser.sector, pocket: seedUser.pocket,
category: seedUser.category, size: seedUser.size, isAllottee: seedUser.isAllottee,
relationship: seedUser.relationship, allotteeName: seedUser.allotteeName,
email: revealed ? contacts.email : seedUser.email.masked,
mobile: revealed ? contacts.mobile : seedUser.mobile.masked,
whatsapp: revealed ? contacts.whatsapp : seedUser.whatsapp.masked,
pan: revealed ? seedUser.pan.full : seedUser.pan.masked,
aadhaar: revealed ? seedUser.aadhaar.full : seedUser.aadhaar.masked,
revealed, kycCompletionPct: Math.round((slots.filter((s) => s.status !== "missing").length / slots.length) * 100),
};
const reveal = useCallback(async (houseNumber: string) => {
const ok = houseNumber.trim() === seedUser.plot;
if (ok) setRevealed(true);
return ok;
}, []);
return {
live: false, loading: false, error: null, account,
kyc: { slots, docTypes, completionPct: account.kycCompletionPct },
notifications: { prefs, timezone, timezoneOptions: seedTz, windows, destinations },
consent,
reveal,
requestContactChangeOtp: async () => "mock-challenge",
changeContact: async (channel, newValue) => { setContacts((c) => ({ ...c, [channel]: newValue })); },
uploadKyc: async (slot, docTypeId, file) => {
setSlots((s) => s.map((x) => x.key === slot ? { ...x, docId: docTypeId ?? x.docId, fileName: file.fileName, status: "uploaded", note: "Under review" } : x));
},
removeKyc: async (slot) => {
setSlots((s) => s.map((x) => x.key === slot ? { ...x, fileName: null, docId: slot === "photo" ? null : x.docId, status: "missing", note: undefined } : x));
},
setPreference: async (categoryId, channel, value) => {
setPrefs((p) => p.map((c) => c.id === categoryId ? { ...c, [channel]: value } : c));
},
setTimezone: async (tz) => setTz(tz),
setContactWindow: async (label, patch) => {
setWindows((w) => w.map((x) => x.label === label ? { ...x, ...patch, range: `${patch.startTime} ${patch.endTime}` } : x));
},
addDestination: async (channel, label, value) => {
setDestinations((d) => [...d, { id: `dest_${d.length + 1}`, channel, label, value, verified: false, primary: false }]);
},
requestDestinationOtp: async () => "mock-challenge",
verifyDestination: async (id) => { setDestinations((d) => d.map((x) => x.id === id ? { ...x, verified: true } : x)); },
setConsent: async (groupId, itemId, granted) => {
setConsent((gs) => gs.map((g) => g.id !== groupId ? g : { ...g, items: g.items.map((it) => it.id === itemId ? { ...it, granted } : it) }));
},
refetch: () => {},
};
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
function useLiveProfile(): ProfileData {
const { sdk } = useAppShell();
const { user, context } = useAuth();
const accountQ = useQuery<AccountReadDTO>("crm.account.read");
const kycQ = useQuery<KycListDTO>("crm.kyc.list");
const notifQ = useQuery<NotificationsGetDTO>("crm.notifications.get");
const consentQ = useQuery<ConsentGroupDTO[]>("crm.consent.list");
const refetch = useCallback(() => { accountQ.refetch(); kycQ.refetch(); notifQ.refetch(); consentQ.refetch(); }, [accountQ, kycQ, notifQ, consentQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables); refetch();
}, [sdk, refetch]);
const a = accountQ.data;
const displayName = (user?.displayName?.trim() || a?.allotteeName?.trim() || user?.email || "My account");
// Role for the header: derive from the profile itself, not a hardcoded label.
const role = a?.isAllottee ? "Property Owner" : (a?.relationship?.trim() || "Representative");
const roleFromContext = (context as { scope?: { role?: string } } | null)?.scope?.role;
const account: UiAccount = {
principalId: a?.principalId ?? user?.id ?? "",
name: displayName, role: roleFromContext || role, initials: initialsOf(displayName),
avatarGradient: gradientFor(a?.principalId ?? user?.id ?? "x"),
memberSince: a?.memberSince ?? "—", status: a?.status ?? "active",
allotmentNo: a?.allotmentNo ?? null, plot: a?.plot ?? null, sector: a?.sector ?? null,
pocket: a?.pocket ?? null, category: a?.category ?? null, size: a?.size ?? null,
isAllottee: a?.isAllottee ?? false, relationship: a?.relationship ?? null, allotteeName: a?.allotteeName ?? null,
email: a?.email ?? "—", mobile: a?.mobile ?? "—", whatsapp: a?.whatsapp ?? "—",
pan: a?.pan ?? "—", aadhaar: a?.aadhaar ?? "—",
revealed: a?.revealed ?? false, kycCompletionPct: a?.kycCompletionPct ?? 0,
};
const kyc: UiKyc = useMemo(() => {
const d = kycQ.data;
const slots: UiKycSlot[] = (d?.slots ?? []).map((s) => ({
key: s.slot, title: SLOT_META[s.slot].title, required: true,
docId: s.docTypeId, fileName: s.fileName, status: s.status, note: kycNote(s.status, s.rejectReason),
}));
const docTypes: UiDocType[] = (d?.docTypes ?? []).map((t) => ({
id: t.id, label: t.label, use: t.use, hint: DOC_HINT[t.id] ?? "", formats: friendlyFormats(t.formats), maxMb: t.maxMb,
}));
return { slots, docTypes, completionPct: d?.completionPct ?? account.kycCompletionPct };
}, [kycQ.data, account.kycCompletionPct]);
const notifications: UiNotifications = useMemo(() => {
const d = notifQ.data;
const prefs: UiNotifCategory[] = (d?.preferences ?? []).map((p) => ({
id: p.categoryId, label: p.label, desc: CATEGORY_DESC[p.categoryId] ?? "", locked: p.locked,
email: p.email, sms: p.sms, whatsapp: p.whatsapp, push: p.push,
}));
const destinations: UiDestination[] = (d?.destinations ?? []).map((x) => ({
id: x.id, channel: x.channel, label: x.label ?? x.channel, value: x.value, verified: x.verified, primary: x.isPrimary,
}));
return {
prefs, timezone: d?.timezone ?? "Asia/Kolkata",
timezoneOptions: d?.timezoneOptions ?? ["Asia/Kolkata"],
windows: mergeWindows(d?.windows ?? []), destinations,
};
}, [notifQ.data]);
const consent: UiConsentGroup[] = useMemo(() => (consentQ.data ?? []).map((g) => ({
id: g.groupId, title: g.label,
items: g.items.map((it) => ({ id: it.itemId, label: it.label, desc: CONSENT_DESC[it.itemId] ?? "", granted: it.granted, locked: it.statutory })),
})), [consentQ.data]);
const reveal = useCallback(async (houseNumber: string): Promise<boolean> => {
try {
await sdk.command("crm.account.revealField", { houseNumber });
accountQ.refetch();
return true;
} catch { return false; }
}, [sdk, accountQ]);
return {
live: true,
loading: accountQ.loading || kycQ.loading || notifQ.loading || consentQ.loading,
error: (accountQ.error ?? kycQ.error ?? notifQ.error ?? consentQ.error)?.message ?? null,
account, kyc, notifications, consent, reveal,
requestContactChangeOtp: async (channel, newValue) => {
const r = await sdk.command<{ challengeId: string }>("crm.account.requestContactChangeOtp", { channel, newValue });
return r.challengeId;
},
changeContact: (channel, newValue, challengeId, otp) =>
cmd("crm.account.changeContact", { channel, newValue, challengeId, otp }),
uploadKyc: (slot, docTypeId, file) =>
cmd("crm.kyc.upload", { slot, ...(docTypeId ? { docTypeId } : {}), file }),
removeKyc: (slot) => cmd("crm.kyc.remove", { slot }),
setPreference: (categoryId, channel, value) => cmd("crm.notifications.setPreference", { categoryId, channel, value }),
setTimezone: (timezone) => cmd("crm.notifications.setTimezone", { timezone }),
setContactWindow: (label, patch) => cmd("crm.notifications.setContactWindow", { label, ...patch }),
addDestination: (channel, label, value) => cmd("crm.notifications.addDestination", { channel, label, value }),
requestDestinationOtp: async (id) => {
const r = await sdk.command<{ challengeId?: string }>("crm.notifications.verifyDestination", { id });
return r.challengeId ?? "";
},
verifyDestination: (id, challengeId, otp) => cmd("crm.notifications.verifyDestination", { id, challengeId, otp }),
setConsent: (groupId, itemId, granted) => cmd("crm.consent.set", { groupId, itemId, granted }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useProfileData(): ProfileData {
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
// so the same hook path runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveProfile() : useMockProfile();
}
+15 -7
View File
@@ -38,6 +38,8 @@ export interface UiMember {
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
/** Accept token for the invite link (pending invites only; no email delivery yet). */
token?: string;
}
export interface TeamData {
@@ -46,7 +48,9 @@ export interface TeamData {
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
removeMember: (id: string) => Promise<void>;
/** Create an invite. be-crm emails the invitee automatically. */
invite: (email: string, roleIds: string[]) => Promise<void>;
/** Resend an invite (regenerates the token + re-emails it). */
resendInvite: (id: string) => Promise<void>;
revokeInvite: (id: string) => Promise<void>;
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
@@ -68,9 +72,9 @@ const initialsOf = (name: string) =>
/* ---- be-crm DTO types (subset used here) -------------------------------- */
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number }
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number; email?: string | null; firstName?: string | null; lastName?: string | null; displayName?: string | null }
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
@@ -153,12 +157,16 @@ function useLiveTeam(): TeamData {
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
const name = isYou && user?.displayName
? user.displayName
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
// Prefer the member's real name/email from their CRM registration; fall back to the
// ACE (for the current user), then job title, then a short principal id.
const name = m.displayName?.trim()
|| (isYou && user?.displayName)
|| m.jobTitle?.trim()
|| `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`;
const email = m.email || (isYou ? user?.email : undefined) || "";
return {
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
email: isYou && user?.email ? user.email : m.principalId,
email,
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
status: (m.status === "active" ? "active" : "offline") as UiStatus,
lastActive: m.status === "active" ? "Active" : "—",
@@ -169,7 +177,7 @@ function useLiveTeam(): TeamData {
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token,
})), [invitesQ.data]);
return {