Initial commit

This commit is contained in:
2026-04-15 10:29:18 -04:00
commit 21c82882f4
46 changed files with 3968 additions and 0 deletions

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# ── Zoho OAuth (Server-to-Server / Client Credentials) ──────────────────────
# Create a "Server-based Application" in Zoho API Console
# https://api-console.zoho.com
ZOHO_CLIENT_ID=
ZOHO_CLIENT_SECRET=
ZOHO_ACCOUNTS_URL=https://accounts.zoho.com/oauth/v2/token
ZOHO_CRM_API_URL=https://www.zohoapis.com/crm/v3
ZOHO_BILLING_API_URL=https://www.zohoapis.com/billing/v1
ZOHO_BOOKING_ORG_ID=
# ── Zoho Bookings ────────────────────────────────────────────────────────────
# The public URL of your Zoho Bookings service page
ZOHO_BOOKINGS_URL=https://bookings.zoho.com/portal/YOUR_PORTAL/book
NEXT_PUBLIC_BOOKINGS_URL=https://bookings.zoho.com/portal/YOUR_PORTAL/book
# ── Zoho SalesIQ Live Chat & Analytics ──────────────────────────────────────
# Find in SalesIQ → Settings → Websites & Mobile Apps → widget embed code
# The widgetcode value inside the <script> snippet
NEXT_PUBLIC_SALESIQ_WIDGET_CODE=
# ── NextAuth (Auth.js v5) ────────────────────────────────────────────────────
# Generate: openssl rand -base64 32
AUTH_SECRET=
AUTH_URL=http://localhost:3000
# Google OAuth provider (for /migrate login)
# https://console.cloud.google.com/apis/credentials
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
middleware.ts Normal file
View File

@@ -0,0 +1,5 @@
export { auth as middleware } from "@/lib/auth";
export const config = {
matcher: ["/migrate/:path*"],
};

9
next.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [],
},
};
export default nextConfig;

1798
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "techsolve-travel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next-auth": "^5.0.0-beta.28",
"@auth/core": "^0.38.0",
"lucide-react": "^0.511.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.4",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 40" fill="none">
<rect width="110" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="55" y="26" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#1E3A5F" text-anchor="middle">Fortinet</text>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40" fill="none">
<rect width="100" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="50" y="26" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#1E3A5F" text-anchor="middle">GL.iNet</text>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40" fill="none">
<rect width="120" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="60" y="26" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#1E3A5F" text-anchor="middle">Peplink</text>
</svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 40" fill="none">
<rect width="110" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="55" y="26" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#1E3A5F" text-anchor="middle">Starlink</text>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 110 40" fill="none">
<rect width="110" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="55" y="26" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="#1E3A5F" text-anchor="middle">Ubiquiti UniFi</text>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40" fill="none">
<rect width="120" height="40" rx="4" fill="#1E3A5F" opacity="0.1"/>
<text x="60" y="26" font-family="Arial, sans-serif" font-size="13" font-weight="bold" fill="#1E3A5F" text-anchor="middle">WireGuard</text>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { createCRMLead } from "@/lib/zoho";
import type { LeadFormData, ZohoCRMLead } from "@/types";
export async function POST(request: Request) {
let body: Partial<LeadFormData>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// Validate required fields
if (!body.firstName?.trim() || !body.lastName?.trim() || !body.email?.trim()) {
return NextResponse.json(
{ error: "firstName, lastName, and email are required" },
{ status: 400 }
);
}
// Basic email format check
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRe.test(body.email)) {
return NextResponse.json({ error: "Invalid email address" }, { status: 400 });
}
const lead: ZohoCRMLead = {
First_Name: body.firstName.trim(),
Last_Name: body.lastName.trim(),
Email: body.email.trim(),
Phone: body.phone?.trim() || undefined,
Lead_Source: "Website",
Description: [
body.currentSetup ? `Current setup: ${body.currentSetup}` : "",
body.message?.trim() ?? "",
]
.filter(Boolean)
.join("\n"),
};
try {
await createCRMLead(lead);
return NextResponse.json({ ok: true }, { status: 201 });
} catch (err) {
console.error("[POST /api/leads]", err);
return NextResponse.json(
{ error: "Failed to submit lead. Please try again." },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { upgradeSubscription } from "@/lib/zoho";
export async function POST(request: Request) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: { subscriptionId?: string; planCode?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.subscriptionId || !body.planCode) {
return NextResponse.json(
{ error: "subscriptionId and planCode are required" },
{ status: 400 }
);
}
try {
await upgradeSubscription(body.subscriptionId, body.planCode);
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[POST /api/zoho/plans]", err);
return NextResponse.json(
{ error: "Upgrade failed. Please try again." },
{ status: 500 }
);
}
}

30
src/app/globals.css Normal file
View File

@@ -0,0 +1,30 @@
@import "tailwindcss";
@theme {
--color-brand-blue: #1e3a5f;
--color-brand-blue-mid: #2d5282;
--color-brand-blue-light: #ebf4ff;
--color-brand-orange: #f97316;
--color-brand-gold: #f59e0b;
--font-sans: var(--font-inter), sans-serif;
--font-display: var(--font-montserrat), sans-serif;
--background-image-hero-gradient: linear-gradient(
to bottom right,
rgba(30, 58, 95, 0.88),
rgba(30, 58, 95, 0.45)
);
}
@layer base {
html {
scroll-behavior: smooth;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-montserrat), sans-serif;
}
}

47
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Inter, Montserrat } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
import SalesIQWidget from "@/components/analytics/SalesIQWidget";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
const montserrat = Montserrat({
variable: "--font-montserrat",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "TechSolve Travel — Enterprise Connectivity for the Modern Nomad",
description:
"Professional-grade managed connectivity for RVers and full-time nomads. Starlink gap coverage, WireGuard VPN, and a Lifetime Hardware Warranty — starting at $19/mo.",
openGraph: {
title: "TechSolve Travel — Enterprise Connectivity for the Modern Nomad",
description:
"We bridge the Starlink gap with enterprise failover, secure WireGuard tunnels, and unlimited managed data.",
type: "website",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${inter.variable} ${montserrat.variable}`}>
<body className="font-sans antialiased">
<Navbar />
<main>{children}</main>
<Footer />
<SalesIQWidget />
</body>
</html>
);
}

View File

@@ -0,0 +1,14 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function MigrateLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect("/api/auth/signin");
}
return <>{children}</>;
}

138
src/app/migrate/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { getSubscriptionForEmail, upgradeSubscription } from "@/lib/zoho";
import Button from "@/components/ui/Button";
import { ShieldCheck, CheckCircle2 } from "lucide-react";
const ALWAYS_ON_PLAN_CODE = "always-on-99";
export default async function MigratePage() {
const session = await auth();
const email = session?.user?.email ?? "";
const subscription = email
? await getSubscriptionForEmail(email).catch(() => null)
: null;
const isAlreadyUpgraded =
subscription?.plan_code === ALWAYS_ON_PLAN_CODE ||
subscription?.plan_code === "always-on";
async function handleUpgrade(formData: FormData) {
"use server";
const subscriptionId = formData.get("subscriptionId") as string;
if (!subscriptionId) return;
await upgradeSubscription(subscriptionId, ALWAYS_ON_PLAN_CODE);
revalidatePath("/migrate");
}
return (
<main className="min-h-screen bg-slate-50 py-16 px-4">
<div className="max-w-lg mx-auto">
{/* Header */}
<div className="text-center mb-10">
<span className="font-display font-black text-2xl text-brand-blue">
TechSolve<span className="text-brand-orange">Travel</span>
</span>
<h1 className="mt-4 font-display text-3xl font-bold text-brand-blue">
Legacy Member Portal
</h1>
<p className="mt-2 text-slate-500 text-sm">
Signed in as{" "}
<span className="font-medium text-brand-blue">{email}</span>
</p>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-100 p-8">
{!subscription ? (
// No subscription found
<div className="text-center py-6">
<p className="text-slate-500 mb-6">
We couldn&apos;t find an active subscription linked to{" "}
<strong>{email}</strong>. If you believe this is an error,
please contact us.
</p>
<Button href="#contact" variant="outline" size="md">
Contact Support
</Button>
</div>
) : isAlreadyUpgraded ? (
// Already on Always-On
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-green-600" />
</div>
<h2 className="font-display text-xl font-bold text-brand-blue mb-2">
You&apos;re Already Covered
</h2>
<p className="text-slate-500 text-sm">
Your account is on the{" "}
<strong>Always-On Managed Data</strong> plan. Your Lifetime
Hardware Warranty is active.
</p>
</div>
) : (
// Eligible for upgrade
<div>
<h2 className="font-display text-xl font-bold text-brand-blue mb-2">
Upgrade to Always-On
</h2>
<p className="text-slate-500 text-sm mb-6">
You&apos;re currently on the{" "}
<strong>
{subscription.plan_code === "safety-net"
? "Travel Standby ($19/mo)"
: subscription.plan_code}
</strong>{" "}
plan. Upgrade to unlock unlimited data, Starlink management,
WireGuard VPN, and your Lifetime Hardware Warranty.
</p>
{/* Upgrade benefits */}
<ul className="space-y-3 mb-8">
{[
"LIFETIME HARDWARE WARRANTY — immediate coverage",
"Proprietary Unlimited High-Speed Data",
"Starlink Account Management & Vendor Mediation",
"Priority Real-Time Remote Tech Support",
"Pre-configured WireGuard VPN",
].map((benefit) => (
<li key={benefit} className="flex items-start gap-3">
<ShieldCheck className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" />
<span className="text-sm text-slate-600">{benefit}</span>
</li>
))}
</ul>
{/* Upgrade form */}
<form action={handleUpgrade}>
<input
type="hidden"
name="subscriptionId"
value={subscription.subscription_id}
/>
<button
type="submit"
className="w-full bg-brand-orange text-white font-semibold rounded-lg px-6 py-4 text-base hover:bg-brand-gold transition-colors"
>
Upgrade to Always-On $99/mo
</button>
</form>
<p className="text-xs text-center text-slate-400 mt-3">
Your billing cycle adjusts immediately. Cancel anytime.
</p>
</div>
)}
</div>
{/* Back link */}
<div className="text-center mt-6">
<a href="/" className="text-sm text-slate-400 hover:text-brand-blue transition-colors">
Back to TechSolve Travel
</a>
</div>
</div>
</main>
);
}

25
src/app/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
import HeroSection from "@/components/sections/HeroSection";
import TrustBarSection from "@/components/sections/TrustBarSection";
import HowItWorksSection from "@/components/sections/HowItWorksSection";
import PricingSection from "@/components/sections/PricingSection";
import FounderSection from "@/components/sections/FounderSection";
import StarlinkManagedSection from "@/components/sections/StarlinkManagedSection";
import WarrantySection from "@/components/sections/WarrantySection";
import VPNSection from "@/components/sections/VPNSection";
import LeadCaptureSection from "@/components/sections/LeadCaptureSection";
export default function Home() {
return (
<>
<HeroSection />
<TrustBarSection />
<HowItWorksSection />
<PricingSection />
<FounderSection />
<StarlinkManagedSection />
<WarrantySection />
<VPNSection />
<LeadCaptureSection />
</>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import Script from "next/script";
/**
* Loads the Zoho SalesIQ live chat + analytics widget.
* Requires NEXT_PUBLIC_SALESIQ_WIDGET_CODE to be set.
* Renders nothing if the env var is absent (safe for local dev).
*/
export default function SalesIQWidget() {
const widgetCode = process.env.NEXT_PUBLIC_SALESIQ_WIDGET_CODE;
if (!widgetCode) return null;
// The official SalesIQ embed pattern — sets up the init object then
// dynamically injects the widget script. Wrapped in a single
// afterInteractive Script tag so it never blocks rendering.
const initScript = `
(function(){
var $zoho = window.$zoho = window.$zoho || {};
$zoho.salesiq = $zoho.salesiq || {
widgetcode: "${widgetCode}",
values: {},
ready: function(){}
};
var d = document,
s = d.createElement("script");
s.type = "text/javascript";
s.id = "zsiqscript";
s.defer = true;
s.src = "https://salesiq.zoho.com/widget";
var t = d.getElementsByTagName("script")[0];
t.parentNode.insertBefore(s, t);
})();
`;
return (
<Script
id="zsiq-salesiq-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: initScript }}
/>
);
}

View File

@@ -0,0 +1,83 @@
import { NAV_LINKS } from "@/lib/constants";
export default function Footer() {
return (
<footer className="bg-brand-blue border-t border-white/10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
{/* Brand */}
<div>
<span className="font-display font-black text-xl text-white">
TechSolve<span className="text-brand-orange">Travel</span>
</span>
<p className="mt-3 text-sm text-slate-400 leading-relaxed">
Enterprise-grade managed connectivity for the modern nomad.
Bringing 20+ years of IT consultancy to the road.
</p>
</div>
{/* Links */}
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">
Navigation
</h3>
<ul className="space-y-2">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="text-sm text-slate-400 hover:text-white transition-colors"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-sm font-semibold text-white uppercase tracking-wider mb-4">
Get in Touch
</h3>
<ul className="space-y-2 text-sm text-slate-400">
<li>
<a
href="#contact"
className="hover:text-white transition-colors"
>
Contact Us
</a>
</li>
<li>
<a
href={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact"}
className="hover:text-white transition-colors"
>
Book a Connectivity Audit
</a>
</li>
<li>
<a
href="/migrate"
className="hover:text-white transition-colors"
>
Legacy Member Upgrade Portal
</a>
</li>
</ul>
</div>
</div>
<div className="mt-10 pt-6 border-t border-white/10 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-xs text-slate-500">
© {new Date().getFullYear()} TechSolve LLC. All rights reserved.
</p>
<p className="text-xs text-slate-500">
A division of TechSolve LLC Enterprise IT Consultancy
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import Button from "@/components/ui/Button";
import { NAV_LINKS } from "@/lib/constants";
export default function Navbar() {
return (
<header className="sticky top-0 z-50 bg-brand-blue/95 backdrop-blur-sm border-b border-white/10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<nav className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="font-display font-black text-xl text-white">
TechSolve
<span className="text-brand-orange">Travel</span>
</span>
</Link>
{/* Nav links — hidden on mobile */}
<ul className="hidden md:flex items-center gap-6">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="text-sm text-slate-300 hover:text-white transition-colors"
>
{link.label}
</a>
</li>
))}
</ul>
{/* CTA */}
<Button
href={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact"}
variant="primary"
size="sm"
>
Book Audit
</Button>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,108 @@
import Image from "next/image";
import SectionWrapper from "@/components/ui/SectionWrapper";
import Button from "@/components/ui/Button";
export default function FounderSection() {
return (
<SectionWrapper id="founder" className="bg-white">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Photo */}
<div className="relative">
<div className="relative rounded-2xl overflow-hidden aspect-[4/5] bg-slate-100 shadow-xl">
<Image
src="/images/david.jpg"
alt="David, Founder of TechSolve Travel, working from his RV with a Starlink and Peplink setup"
fill
className="object-cover object-top"
sizes="(max-width: 768px) 100vw, 50vw"
/>
{/* Fallback gradient shown when image is missing */}
<div className="absolute inset-0 bg-gradient-to-t from-brand-blue/60 to-transparent" />
</div>
{/* Floating badge */}
<div className="absolute -bottom-4 -right-4 bg-brand-orange text-white rounded-2xl px-5 py-3 shadow-lg">
<p className="font-display font-black text-2xl leading-none">20+</p>
<p className="text-xs font-medium mt-0.5">Years Enterprise IT</p>
</div>
</div>
{/* Copy */}
<div>
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Meet Your Founder
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue leading-tight mb-6">
Enterprise Expertise.{" "}
<span className="text-brand-orange">Nomadic Spirit.</span>
</h2>
{/* Narrative */}
<div className="space-y-4 text-slate-600 leading-relaxed mb-8">
<p>
David is a self-employed entrepreneur and IT consultant with over
two decades of experience building secure, enterprise-grade
networks for businesses that couldn&apos;t afford a second of
downtime specializing in{" "}
<strong className="text-brand-blue">
Ubiquiti UniFi, Peplink, WireGuard, and Fortinet
</strong>
.
</p>
<p>
When David hit the road, he discovered the &ldquo;Travel
WiFi&rdquo; market was full of empty promises. So he didn&apos;t
just join the industry he acquired{" "}
<strong className="text-brand-blue">Travel Data WiFi</strong> to
fix it, merging high-level technical consultancy with mobile
connectivity.
</p>
</div>
{/* Pull quote */}
<blockquote className="border-l-4 border-brand-orange pl-6 italic text-lg text-brand-blue mb-8">
&ldquo;I spent two decades building secure networks for businesses
that couldn&apos;t afford a second of downtime. When I hit the road,
I realized the &lsquo;Travel WiFi&rsquo; market was full of empty
promises. I built TechSolve Travel to change that. I don&apos;t
just sell you a SIM; I design your uptime, secure your data with
WireGuard, and personally guarantee your hardware for life.&rdquo;
</blockquote>
{/* Founder's Promise */}
<div className="bg-brand-blue-light rounded-xl p-5 mb-8">
<p className="text-sm font-semibold text-brand-blue mb-1">
The Founder&apos;s Promise
</p>
<p className="text-sm text-slate-600 leading-relaxed">
When you call for support, you&apos;re not getting a call center.
You&apos;re getting a team led by an enterprise networking expert
who solves problems in real-time via live screen share. The
Lifetime Hardware Warranty is David&apos;s personal guarantee
not a policy, a promise.
</p>
</div>
{/* CTA */}
<div>
<Button
href={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact"}
variant="primary"
size="lg"
>
Book Your Connectivity Audit
</Button>
<p className="text-xs text-slate-400 mt-2">
Let&apos;s design your rig together.
</p>
</div>
{/* Signature */}
<p className="mt-8 font-display italic text-brand-blue text-xl font-semibold">
I&apos;ve got your back on the road. &mdash; David
</p>
</div>
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,76 @@
import Image from "next/image";
import Button from "@/components/ui/Button";
export default function HeroSection() {
return (
<section className="relative min-h-[90vh] flex items-center">
{/* Background image */}
<Image
src="/images/hero-rv-sunset.jpg"
alt="RV parked at sunset with Starlink dish and enterprise networking hardware"
fill
priority
className="object-cover object-center"
sizes="100vw"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-hero-gradient" />
{/* Content */}
<div className="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
<div className="max-w-3xl">
{/* Eyebrow */}
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-4">
Managed Connectivity for Full-Time Nomads
</p>
{/* Headline */}
<h1 className="font-display text-5xl md:text-6xl lg:text-7xl font-black text-white leading-tight mb-6">
Your Office Has No Borders.{" "}
<span className="text-brand-orange">
Your Connection Shouldn&apos;t Either.
</span>
</h1>
{/* Sub-headline */}
<p className="text-lg md:text-xl text-slate-200 leading-relaxed mb-10 max-w-2xl">
Professional-grade connectivity for the modern nomad. We bridge the
Starlink gap with enterprise failover, secure WireGuard tunnels, and
unlimited managed data backed by a Lifetime Hardware Warranty.
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-4">
<Button
href={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact"}
variant="primary"
size="lg"
>
Book Your Connectivity Audit
</Button>
<Button href="#pricing" variant="outline" size="lg">
View Plans
</Button>
</div>
{/* Social proof micro-bar */}
<div className="mt-12 flex flex-wrap gap-6 text-sm text-slate-300">
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-400 inline-block" />
20+ years enterprise IT
</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-400 inline-block" />
Lifetime hardware warranty
</span>
<span className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-400 inline-block" />
WireGuard VPN included
</span>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,66 @@
import { CalendarDays, Package, Wifi } from "lucide-react";
import SectionWrapper from "@/components/ui/SectionWrapper";
import { PROCESS_STEPS } from "@/lib/constants";
import type { ProcessStep } from "@/types";
const iconMap: Record<ProcessStep["icon"], React.ElementType> = {
calendar: CalendarDays,
package: Package,
wifi: Wifi,
};
export default function HowItWorksSection() {
return (
<SectionWrapper id="how-it-works" className="bg-slate-50">
<div className="text-center mb-14">
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
The Process
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue">
Three Steps to Enterprise Connectivity
</h2>
<p className="mt-4 text-slate-500 max-w-xl mx-auto">
No guesswork. No call centers. A human expert designs, ships, and
supports your entire connectivity stack.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{PROCESS_STEPS.map((step) => {
const Icon = iconMap[step.icon];
return (
<div
key={step.step}
className="relative bg-white rounded-2xl p-8 border border-slate-100 shadow-sm flex flex-col items-start"
>
{/* Step number */}
<span className="text-xs font-bold uppercase tracking-widest text-brand-orange mb-4">
Step {step.step}
</span>
{/* Icon */}
<div className="w-12 h-12 rounded-xl bg-brand-blue/10 flex items-center justify-center mb-5">
<Icon className="w-6 h-6 text-brand-blue" />
</div>
{/* Title */}
<h3 className="font-display text-xl font-semibold text-brand-blue mb-3">
{step.title}
</h3>
{/* Description */}
<p className="text-slate-500 leading-relaxed text-sm">
{step.description}
</p>
{/* Connector dot (not shown on last) */}
{step.step < 3 && (
<span className="hidden md:block absolute -right-4 top-1/2 -translate-y-1/2 w-8 h-0.5 bg-brand-orange/30 z-10" />
)}
</div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,60 @@
import SectionWrapper from "@/components/ui/SectionWrapper";
import LeadForm from "@/components/ui/LeadForm";
export default function LeadCaptureSection() {
return (
<SectionWrapper id="contact" dark>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 items-start">
{/* Left: copy */}
<div>
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Free Consultation
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-white leading-tight mb-6">
Let&apos;s Design Your Rig&apos;s Connectivity Stack
</h2>
<p className="text-slate-300 leading-relaxed mb-8">
Every rig is different. Parking patterns, power constraints, budget,
and bandwidth needs all factor into the ideal setup. Book a free
audit and we&apos;ll map out your perfect connectivity solution
no commitment required.
</p>
<ul className="space-y-4">
{[
"Custom hardware recommendations for your exact rig",
"Starlink integration and failover design",
"WireGuard VPN setup and security review",
"Honest pricing — no upsells, no surprises",
].map((point) => (
<li key={point} className="flex items-start gap-3 text-slate-300">
<svg
className="w-5 h-5 text-brand-orange shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-sm">{point}</span>
</li>
))}
</ul>
</div>
{/* Right: form */}
<div className="bg-white rounded-2xl p-8 shadow-xl">
<h3 className="font-display text-xl font-bold text-brand-blue mb-6">
Request Your Free Audit
</h3>
<LeadForm />
</div>
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,90 @@
import { CheckCircle2, ShieldCheck } from "lucide-react";
import SectionWrapper from "@/components/ui/SectionWrapper";
import Card from "@/components/ui/Card";
import Badge from "@/components/ui/Badge";
import Button from "@/components/ui/Button";
import { PRICING_TIERS } from "@/lib/constants";
export default function PricingSection() {
return (
<SectionWrapper id="pricing" className="bg-slate-50">
<div className="text-center mb-14">
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Simple Pricing
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue">
Choose Your Coverage
</h2>
<p className="mt-4 text-slate-500 max-w-xl mx-auto">
No contracts. Cancel anytime. Every plan includes real human support
from an enterprise networking expert.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center py-6">
{PRICING_TIERS.map((tier) => (
<Card key={tier.id} featured={tier.featured} className="relative">
{/* Badge */}
<Badge variant={tier.badgeVariant} pill>
{tier.badge}
</Badge>
{/* Plan name + price */}
<div className="mt-6 mb-2">
<h3 className="font-display text-xl font-bold text-brand-blue">
{tier.name}
</h3>
<p className="text-slate-500 text-sm mt-1">{tier.description}</p>
</div>
<div className="flex items-baseline gap-1 my-5">
<span className="font-display text-5xl font-black text-brand-blue">
${tier.price}
</span>
<span className="text-slate-400 text-lg">/mo</span>
</div>
{/* Features */}
<ul className="space-y-3 flex-1 mb-8">
{tier.features.map((feature, i) => (
<li key={i} className="flex items-start gap-3">
{feature.icon === "shield" ? (
<ShieldCheck className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" />
) : (
<CheckCircle2 className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" />
)}
<span
className={`text-sm leading-snug ${
feature.bold
? "font-bold text-brand-blue text-base"
: "text-slate-600"
}`}
>
{feature.text}
</span>
</li>
))}
</ul>
{/* CTA */}
<Button
href={tier.ctaHref}
variant={tier.featured ? "primary" : "outline"}
size="md"
className="w-full justify-center"
>
{tier.ctaLabel}
</Button>
</Card>
))}
</div>
{/* Footnote */}
<p className="text-center text-xs text-slate-400 mt-10 max-w-2xl mx-auto">
* Starlink Concierge services (available on Failover Bundle &amp;
Always-On plans) require Authorized Technical Contact status on your
Starlink account. We manage the technical headaches so you
don&apos;t have to.
</p>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,112 @@
import { MessageSquare, Activity, Settings2 } from "lucide-react";
import SectionWrapper from "@/components/ui/SectionWrapper";
import Button from "@/components/ui/Button";
const features = [
{
icon: MessageSquare,
title: "Vendor Mediation",
description:
"We open, track, and escalate Starlink support tickets on your behalf. Stop waiting days staring at an app — we do the shouting.",
},
{
icon: Activity,
title: "Technical Advocacy",
description:
"We provide the diagnostic logs and technical language Starlink needs to process hardware RMAs faster — cables, dishes, routers covered.",
},
{
icon: Settings2,
title: "Proactive Management",
description:
'We manage service address updates, monitor dish health and obstructions, and ensure "Bypass Mode" is optimized for your TechSolve hardware stack.',
},
];
export default function StarlinkManagedSection() {
return (
<SectionWrapper id="starlink" className="bg-slate-50">
<div className="text-center mb-14">
{/* Tier badges */}
<div className="flex justify-center gap-2 mb-4">
<span className="bg-emerald-100 text-emerald-700 px-3 py-1 rounded-full text-xs font-bold uppercase">
Failover Bundle
</span>
<span className="bg-brand-orange text-white px-3 py-1 rounded-full text-xs font-bold uppercase">
Always-On
</span>
</div>
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Starlink Concierge Service
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue">
Starlink is the Engine.{" "}
<span className="text-brand-orange">We Are the Pit Crew.</span>
</h2>
<p className="mt-4 text-slate-500 max-w-xl mx-auto">
Starlink offers incredible speeds but their support is famously
unreachable. We bridge that gap so you can focus on the road.
</p>
</div>
{/* Feature cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-14">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="bg-white rounded-2xl p-8 border border-slate-100 shadow-sm"
>
<div className="w-12 h-12 rounded-xl bg-brand-blue/10 flex items-center justify-center mb-5">
<Icon className="w-6 h-6 text-brand-blue" />
</div>
<h3 className="font-display text-lg font-semibold text-brand-blue mb-3">
{feature.title}
</h3>
<p className="text-slate-500 text-sm leading-relaxed">
{feature.description}
</p>
</div>
);
})}
</div>
{/* Authorized user pitch */}
<div className="bg-brand-blue rounded-2xl p-8 md:p-12 text-white">
<div className="max-w-3xl mx-auto text-center">
<h3 className="font-display text-2xl md:text-3xl font-bold mb-6">
Your Dedicated Vendor Manager
</h3>
<blockquote className="text-lg text-slate-200 leading-relaxed italic mb-8">
&ldquo;Starlink offers incredible speeds, but their support is
famously unreachable. When your connection drops or your hardware
fails, don&apos;t spend your travel days shouting into a void. Add
us as an Authorized User on your account, and our team will handle
the vendor mediation, ticket escalation, and technical
troubleshooting on your behalf. We aren&apos;t just your backup
data provider we are your dedicated vendor manager.&rdquo;
</blockquote>
<p className="text-sm text-slate-300 mb-8">
By adding David as an Authorized Technical Contact, you gain an
enterprise-grade advocate.{" "}
<strong className="text-white">
You keep ownership of your account; we provide the management.
</strong>
</p>
<Button
href={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact"}
variant="primary"
size="lg"
>
Delegate My Starlink Management
</Button>
<p className="text-xs text-slate-400 mt-3">
Available on Failover Bundle ($49) and Always-On ($99) plans.
</p>
</div>
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,28 @@
import Image from "next/image";
import { TRUST_LOGOS } from "@/lib/constants";
export default function TrustBarSection() {
return (
<section className="bg-brand-blue py-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-xs font-semibold uppercase tracking-widest text-slate-400 mb-8">
Bringing 20+ years of Enterprise IT Consultancy (TechSolve LLC) to
the nomad community
</p>
<div className="flex flex-wrap justify-center items-center gap-8 md:gap-14">
{TRUST_LOGOS.map((logo) => (
<div key={logo.name} className="relative opacity-70 hover:opacity-100 transition-opacity">
<Image
src={logo.src}
alt={logo.name}
width={logo.width}
height={logo.height}
className="object-contain brightness-0 invert"
/>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
import { Lock } from "lucide-react";
import SectionWrapper from "@/components/ui/SectionWrapper";
import Button from "@/components/ui/Button";
export default function VPNSection() {
return (
<SectionWrapper id="vpn" className="bg-slate-50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Quote side — reversed column order on desktop */}
<div className="bg-brand-blue rounded-2xl p-8 md:p-10 md:order-1">
<blockquote className="border-l-4 border-brand-orange pl-6 italic text-xl text-white leading-relaxed mb-8">
&ldquo;Work like you&apos;re at the office. Our WireGuard tunnels
give you a dedicated IP and secure access back to your home base,
bypassing &lsquo;Travel WiFi&rsquo; restrictions.&rdquo;
</blockquote>
<div className="space-y-4">
{[
"Bypass streaming geo-blocks and corporate VPN requirements.",
"Dedicated IP so banking, remote desktops, and SaaS tools never flag you.",
"Military-grade WireGuard encryption — faster and more modern than OpenVPN.",
].map((point) => (
<div key={point} className="flex items-start gap-3">
<Lock className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" />
<p className="text-sm text-slate-300 leading-snug">{point}</p>
</div>
))}
</div>
</div>
{/* Copy side */}
<div className="md:order-2">
<div className="w-16 h-16 rounded-2xl bg-brand-blue/10 flex items-center justify-center mb-6">
<Lock className="w-8 h-8 text-brand-blue" />
</div>
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Always-On Exclusive
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue leading-tight mb-6">
Pre-Configured WireGuard VPN
</h2>
<p className="text-slate-500 leading-relaxed mb-8">
Most nomads think a travel SIM means accepting public WiFi risks,
geo-blocked content, and being locked out of work systems. The
Always-On plan ships with a fully configured WireGuard tunnel
your private, encrypted highway, no matter where you park.
</p>
<Button href="#pricing" variant="primary" size="md">
Get the Always-On Plan
</Button>
</div>
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,54 @@
import { ShieldCheck } from "lucide-react";
import SectionWrapper from "@/components/ui/SectionWrapper";
import Button from "@/components/ui/Button";
export default function WarrantySection() {
return (
<SectionWrapper id="warranty" className="bg-white">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 lg:gap-20 items-center">
{/* Icon side */}
<div className="flex flex-col items-start">
<div className="w-16 h-16 rounded-2xl bg-brand-orange/10 flex items-center justify-center mb-6">
<ShieldCheck className="w-8 h-8 text-brand-orange" />
</div>
<p className="text-brand-orange font-semibold text-sm uppercase tracking-widest mb-3">
Always-On Exclusive
</p>
<h2 className="font-display text-3xl md:text-4xl font-bold text-brand-blue leading-tight mb-6">
Lifetime Hardware Warranty
</h2>
<p className="text-slate-500 leading-relaxed mb-8">
Hardware fails. It happens to the best equipment in the worst
locations a National Park with zero cell service, a remote campsite
with no FedEx for miles. We eliminate that risk entirely.
</p>
<Button href="#pricing" variant="primary" size="md">
Get the Always-On Plan
</Button>
</div>
{/* Quote side */}
<div className="bg-brand-blue-light rounded-2xl p-8 md:p-10">
<blockquote className="border-l-4 border-brand-orange pl-6 italic text-xl text-brand-blue leading-relaxed mb-8">
&ldquo;Stop worrying about hardware failure in the middle of a
National Park. If we sold it to you and you&apos;re on the
Always-On plan, it&apos;s covered. Forever.&rdquo;
</blockquote>
<div className="space-y-4">
{[
"If your TechSolve-purchased router fails, we replace it — free.",
"No deductibles. No fine print. Active subscription = active coverage.",
"Ships directly to your next campsite.",
].map((point) => (
<div key={point} className="flex items-start gap-3">
<ShieldCheck className="w-5 h-5 text-brand-orange shrink-0 mt-0.5" />
<p className="text-sm text-slate-600 leading-snug">{point}</p>
</div>
))}
</div>
</div>
</div>
</SectionWrapper>
);
}

View File

@@ -0,0 +1,29 @@
interface BadgeProps {
children: React.ReactNode;
variant?: "blue" | "emerald" | "orange";
className?: string;
pill?: boolean;
}
export default function Badge({
children,
variant = "blue",
className = "",
pill = false,
}: BadgeProps) {
const variants = {
blue: "bg-blue-100 text-blue-700",
emerald: "bg-emerald-100 text-emerald-700",
orange: "bg-brand-orange text-white",
};
const shape = pill
? "absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold uppercase whitespace-nowrap"
: "inline-block px-3 py-1 rounded-full text-xs font-bold uppercase";
return (
<span className={`${shape} ${variants[variant]} ${className}`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,51 @@
import { type ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline";
size?: "sm" | "md" | "lg";
href?: string;
asChild?: boolean;
}
export default function Button({
variant = "primary",
size = "md",
href,
className = "",
children,
...props
}: ButtonProps) {
const base =
"inline-flex items-center justify-center font-semibold rounded-lg transition-colors duration-200 cursor-pointer";
const variants = {
primary:
"bg-brand-orange text-white hover:bg-brand-gold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-orange",
secondary:
"bg-brand-blue text-white hover:bg-brand-blue-mid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-blue",
outline:
"border-2 border-brand-orange text-brand-orange hover:bg-brand-orange hover:text-white",
};
const sizes = {
sm: "px-4 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const classes = `${base} ${variants[variant]} ${sizes[size]} ${className}`;
if (href) {
return (
<a href={href} className={classes}>
{children}
</a>
);
}
return (
<button className={classes} {...props}>
{children}
</button>
);
}

View File

@@ -0,0 +1,18 @@
interface CardProps {
children: React.ReactNode;
featured?: boolean;
className?: string;
}
export default function Card({
children,
featured = false,
className = "",
}: CardProps) {
const base = "relative rounded-2xl p-8 flex flex-col";
const style = featured
? "bg-white border-2 border-brand-orange ring-4 ring-brand-orange/20 shadow-2xl scale-105 z-10"
: "bg-white border border-slate-200 shadow-sm";
return <div className={`${base} ${style} ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import Button from "@/components/ui/Button";
import { identifyVisitor } from "@/lib/salesiq";
import type { LeadFormData } from "@/types";
type FormState = "idle" | "submitting" | "success" | "error";
const SETUP_OPTIONS = [
"Starlink Only",
"Cell Data Only",
"Starlink + Cell Backup",
"No Current Setup",
];
const EMPTY: LeadFormData = {
firstName: "",
lastName: "",
email: "",
phone: "",
currentSetup: "",
message: "",
};
export default function LeadForm() {
const [form, setForm] = useState<LeadFormData>(EMPTY);
const [state, setState] = useState<FormState>("idle");
const [errorMsg, setErrorMsg] = useState("");
function update(field: keyof LeadFormData, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.firstName || !form.lastName || !form.email) return;
setState("submitting");
setErrorMsg("");
try {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Submission failed");
}
setState("success");
// Identify visitor in SalesIQ so the operator sees their details
identifyVisitor({
name: `${form.firstName} ${form.lastName}`.trim(),
email: form.email,
phone: form.phone,
plan: form.currentSetup || undefined,
});
setForm(EMPTY);
} catch (err) {
setState("error");
setErrorMsg(
err instanceof Error ? err.message : "Something went wrong. Try again."
);
}
}
if (state === "success") {
return (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="font-display text-2xl font-bold text-brand-blue mb-2">
You&apos;re on the list!
</h3>
<p className="text-slate-500">
We&apos;ll reach out within 24 hours to schedule your free
Connectivity Audit.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-slate-700 mb-1"
>
First Name <span className="text-brand-orange">*</span>
</label>
<input
id="firstName"
type="text"
required
value={form.firstName}
onChange={(e) => update("firstName", e.target.value)}
placeholder="Jane"
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange"
/>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-slate-700 mb-1"
>
Last Name <span className="text-brand-orange">*</span>
</label>
<input
id="lastName"
type="text"
required
value={form.lastName}
onChange={(e) => update("lastName", e.target.value)}
placeholder="Nomad"
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700 mb-1"
>
Email <span className="text-brand-orange">*</span>
</label>
<input
id="email"
type="email"
required
value={form.email}
onChange={(e) => update("email", e.target.value)}
placeholder="jane@example.com"
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange"
/>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-slate-700 mb-1"
>
Phone{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<input
id="phone"
type="tel"
value={form.phone}
onChange={(e) => update("phone", e.target.value)}
placeholder="(555) 000-0000"
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange"
/>
</div>
<div>
<label
htmlFor="currentSetup"
className="block text-sm font-medium text-slate-700 mb-1"
>
Current Connectivity Setup
</label>
<select
id="currentSetup"
value={form.currentSetup}
onChange={(e) => update("currentSetup", e.target.value)}
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange bg-white"
>
<option value="">Select your current setup</option>
{SETUP_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-slate-700 mb-1"
>
Tell us about your rig{" "}
<span className="text-slate-400 font-normal">(optional)</span>
</label>
<textarea
id="message"
rows={4}
value={form.message}
onChange={(e) => update("message", e.target.value)}
placeholder="Type of vehicle, full-time vs part-time, biggest connectivity pain points..."
className="w-full rounded-lg border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-orange/50 focus:border-brand-orange resize-none"
/>
</div>
{state === "error" && (
<p className="text-sm text-red-600 bg-red-50 rounded-lg px-4 py-3">
{errorMsg}
</p>
)}
<Button
type="submit"
variant="primary"
size="lg"
disabled={state === "submitting"}
className="w-full justify-center disabled:opacity-60 disabled:cursor-not-allowed"
>
{state === "submitting" ? "Sending..." : "Get My Free Connectivity Audit"}
</Button>
<p className="text-xs text-center text-slate-400">
No spam. We&apos;ll only reach out about your rig.
</p>
</form>
);
}

View File

@@ -0,0 +1,22 @@
interface SectionWrapperProps {
children: React.ReactNode;
id?: string;
className?: string;
dark?: boolean;
}
export default function SectionWrapper({
children,
id,
className = "",
dark = false,
}: SectionWrapperProps) {
return (
<section
id={id}
className={`py-16 md:py-24 ${dark ? "bg-brand-blue" : "bg-white"} ${className}`}
>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">{children}</div>
</section>
);
}

20
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
],
callbacks: {
authorized({ auth }) {
// Used by middleware to check if the session is valid
return !!auth?.user;
},
},
pages: {
signIn: "/api/auth/signin",
},
});

139
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,139 @@
import type { PricingTier, ProcessStep, TrustLogo } from "@/types";
export const PROCESS_STEPS: ProcessStep[] = [
{
step: 1,
title: "The Audit",
description:
"We analyze your rig's power, hardware, and location needs in a live video consultation. No cookie-cutter solutions — we design your uptime from scratch.",
icon: "calendar",
},
{
step: 2,
title: "The Build",
description:
"Pre-configured Peplink or GL.iNet hardware is shipped directly to your campsite — ready to plug in, zero technical setup required.",
icon: "package",
},
{
step: 3,
title: "The Connection",
description:
"Secure, unlimited managed data with a Lifetime Hardware Warranty. One call away from enterprise-grade support, wherever you park.",
icon: "wifi",
},
];
export const PRICING_TIERS: PricingTier[] = [
{
id: "safety-net",
name: "Travel Standby",
price: 19,
badge: "Safety Net",
badgeVariant: "blue",
description: "Keep the connection alive between adventures.",
features: [
{ text: "Keep your SIM active (no reactivation fees)", icon: "check" },
{ text: "Access to technical knowledge base", icon: "check" },
{ text: "Emergency low-speed data access", icon: "check" },
],
ctaLabel: "Get Started",
ctaHref: "#contact",
featured: false,
},
{
id: "failover-bundle",
name: "Failover Bundle",
price: 49,
badge: "Starlink Essential",
badgeVariant: "emerald",
description: "Vendor management + reliable emergency backup.",
features: [
{
text: "Starlink Account Management & Vendor Mediation",
icon: "check",
},
{ text: "20GB High-Speed LTE/5G Failover Data", icon: "check" },
{ text: "Priority Support Ticket Mediation", icon: "check" },
{
text: 'Automated "Rain & Tree" Failover Configuration',
icon: "check",
},
],
ctaLabel: "Get Started",
ctaHref: "#contact",
featured: false,
},
{
id: "always-on",
name: "Always-On Managed Data",
price: 99,
badge: "Full-Timer's Lifeline",
badgeVariant: "orange",
description: "Ultimate uptime and total hardware protection.",
features: [
{
text: "LIFETIME HARDWARE WARRANTY",
icon: "shield",
bold: true,
},
{ text: "Proprietary Unlimited High-Speed Data", icon: "check" },
{
text: "Starlink Account Management & Vendor Mediation",
icon: "check",
},
{
text: "Priority Real-Time Remote Tech Support (live screen share)",
icon: "check",
},
{
text: "Pre-configured WireGuard VPN (bypass geo-blocks & secure your rig)",
icon: "check",
},
],
ctaLabel: "Book Your Audit",
ctaHref: process.env.NEXT_PUBLIC_BOOKINGS_URL ?? "#contact",
featured: true,
},
];
export const TRUST_LOGOS: TrustLogo[] = [
{ name: "Peplink", src: "/images/logo-peplink.svg", width: 120, height: 40 },
{
name: "GL.iNet",
src: "/images/logo-gliinet.svg",
width: 100,
height: 40,
},
{
name: "Ubiquiti UniFi",
src: "/images/logo-ubiquiti-unifi.svg",
width: 110,
height: 40,
},
{
name: "WireGuard",
src: "/images/logo-wireguard.svg",
width: 120,
height: 40,
},
{
name: "Fortinet",
src: "/images/logo-fortinet.svg",
width: 110,
height: 40,
},
{
name: "Starlink",
src: "/images/logo-starlink.svg",
width: 110,
height: 40,
},
];
export const NAV_LINKS = [
{ label: "How It Works", href: "#how-it-works" },
{ label: "Plans", href: "#pricing" },
{ label: "About", href: "#founder" },
{ label: "Starlink Mgmt", href: "#starlink" },
];

59
src/lib/salesiq.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Client-side helpers for the SalesIQ visitor API.
* Safe to call at any time — all functions no-op if the widget hasn't
* loaded yet or the env var is absent.
*/
function sdk() {
return typeof window !== "undefined" ? window.$zoho?.salesiq : undefined;
}
/**
* Identify the visitor after a successful lead form submission.
* Surfaces their name and email in the SalesIQ operator console
* and links the chat session to the CRM lead.
*/
export function identifyVisitor(opts: {
name: string;
email: string;
phone?: string;
plan?: string;
}) {
const sq = sdk();
if (!sq) return;
try {
sq.visitor.name(opts.name);
sq.visitor.email(opts.email);
if (opts.phone) sq.visitor.contactnumber(opts.phone);
if (opts.plan) {
sq.visitor.info({ interested_plan: opts.plan });
}
} catch {
// Widget may still be initializing — swallow silently
}
}
/**
* Track a custom page-level event (e.g. pricing section viewed).
* Uses the visitor info API since SalesIQ doesn't have a standalone
* custom event method — attaches metadata to the visitor record.
*/
export function trackEvent(key: string, value: string | number | boolean) {
const sq = sdk();
if (!sq) return;
try {
sq.visitor.info({ [key]: value });
} catch {
// Swallow
}
}
/**
* Programmatically open the live chat window.
* Useful for "Chat with us" CTA buttons.
*/
export function openChat() {
sdk()?.chat?.open();
}

134
src/lib/zoho.ts Normal file
View File

@@ -0,0 +1,134 @@
import type { ZohoCRMLead } from "@/types";
// ---------------------------------------------------------------------------
// Token (client_credentials — stateless, correct for serverless)
// ---------------------------------------------------------------------------
async function getZohoAccessToken(scope: string): Promise<string> {
const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.ZOHO_CLIENT_ID!,
client_secret: process.env.ZOHO_CLIENT_SECRET!,
scope,
});
const res = await fetch(
`${process.env.ZOHO_ACCOUNTS_URL ?? "https://accounts.zoho.com/oauth/v2/token"}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
cache: "no-store",
}
);
if (!res.ok) {
throw new Error(`Zoho token request failed: ${res.status}`);
}
const data = await res.json();
if (!data.access_token) {
throw new Error("Zoho token response missing access_token");
}
return data.access_token as string;
}
// ---------------------------------------------------------------------------
// CRM — Leads
// ---------------------------------------------------------------------------
export async function createCRMLead(lead: ZohoCRMLead): Promise<void> {
const token = await getZohoAccessToken("ZohoCRM.modules.leads.CREATE");
const res = await fetch(
`${process.env.ZOHO_CRM_API_URL ?? "https://www.zohoapis.com/crm/v3"}/Leads`,
{
method: "POST",
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ data: [lead] }),
cache: "no-store",
}
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
`Zoho CRM lead creation failed: ${res.status}${JSON.stringify(body)}`
);
}
}
// ---------------------------------------------------------------------------
// Billing / Subscriptions
// ---------------------------------------------------------------------------
export interface ZohoSubscription {
subscription_id: string;
plan_code: string;
status: string;
customer_email: string;
}
export async function getSubscriptionForEmail(
email: string
): Promise<ZohoSubscription | null> {
const token = await getZohoAccessToken(
"ZohoSubscriptions.subscriptions.READ"
);
const url = new URL(
`${process.env.ZOHO_BILLING_API_URL ?? "https://www.zohoapis.com/billing/v1"}/subscriptions`
);
url.searchParams.set("email", email);
const res = await fetch(url.toString(), {
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"X-com-zoho-subscriptions-organizationid":
process.env.ZOHO_BOOKING_ORG_ID ?? "",
},
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
const subs: ZohoSubscription[] = data.subscriptions ?? [];
return subs[0] ?? null;
}
export async function upgradeSubscription(
subscriptionId: string,
planCode: string
): Promise<void> {
const token = await getZohoAccessToken(
"ZohoSubscriptions.subscriptions.UPDATE"
);
const res = await fetch(
`${process.env.ZOHO_BILLING_API_URL ?? "https://www.zohoapis.com/billing/v1"}/subscriptions/${subscriptionId}`,
{
method: "PUT",
headers: {
Authorization: `Zoho-oauthtoken ${token}`,
"Content-Type": "application/json",
"X-com-zoho-subscriptions-organizationid":
process.env.ZOHO_BOOKING_ORG_ID ?? "",
},
body: JSON.stringify({ plan: { plan_code: planCode } }),
cache: "no-store",
}
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(
`Zoho subscription upgrade failed: ${res.status}${JSON.stringify(body)}`
);
}
}

50
src/types/index.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface PricingTier {
id: string;
name: string;
price: number;
badge: string;
badgeVariant: "blue" | "emerald" | "orange";
description: string;
features: PricingFeature[];
ctaLabel: string;
ctaHref: string;
featured: boolean;
}
export interface PricingFeature {
text: string;
icon?: "shield" | "check";
bold?: boolean;
}
export interface ProcessStep {
step: number;
title: string;
description: string;
icon: "calendar" | "package" | "wifi";
}
export interface TrustLogo {
name: string;
src: string;
width: number;
height: number;
}
export interface LeadFormData {
firstName: string;
lastName: string;
email: string;
phone?: string;
currentSetup: string;
message?: string;
}
export interface ZohoCRMLead {
Last_Name: string;
First_Name: string;
Email: string;
Phone?: string;
Lead_Source: string;
Description?: string;
}

37
src/types/salesiq.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
// Type declarations for the Zoho SalesIQ global SDK
// https://www.zoho.com/salesiq/help/developer-guides/javascript-api.html
interface SalesIQVisitor {
name: (name: string) => void;
email: (email: string) => void;
contactnumber: (phone: string) => void;
info: (data: Record<string, string | number | boolean>) => void;
uniqueid: (id: string) => void;
}
interface SalesIQChat {
open: () => void;
close: () => void;
start: () => void;
}
interface SalesIQInstance {
widgetcode: string;
values: Record<string, unknown>;
ready: () => void;
visitor: SalesIQVisitor;
chat: SalesIQChat;
floatbutton: {
visible: (show: boolean) => void;
};
}
declare global {
interface Window {
$zoho?: {
salesiq?: SalesIQInstance;
};
}
}
export {};

33
tailwind.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
blue: "#1E3A5F",
"blue-mid": "#2D5282",
"blue-light": "#EBF4FF",
orange: "#F97316",
gold: "#F59E0B",
},
},
fontFamily: {
sans: ["var(--font-inter)", "sans-serif"],
display: ["var(--font-montserrat)", "sans-serif"],
},
backgroundImage: {
"hero-gradient":
"linear-gradient(to bottom right, rgba(30,58,95,0.88), rgba(30,58,95,0.45))",
},
},
},
plugins: [],
};
export default config;

40
tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}