Initial commit
This commit is contained in:
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
52
src/app/api/leads/route.ts
Normal file
52
src/app/api/leads/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/api/zoho/plans/route.ts
Normal file
36
src/app/api/zoho/plans/route.ts
Normal 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
30
src/app/globals.css
Normal 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
47
src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/migrate/layout.tsx
Normal file
14
src/app/migrate/layout.tsx
Normal 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
138
src/app/migrate/page.tsx
Normal 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'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'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'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
25
src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/components/analytics/SalesIQWidget.tsx
Normal file
44
src/components/analytics/SalesIQWidget.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
83
src/components/layout/Footer.tsx
Normal file
83
src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/layout/Navbar.tsx
Normal file
44
src/components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/components/sections/FounderSection.tsx
Normal file
108
src/components/sections/FounderSection.tsx
Normal 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'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 “Travel
|
||||
WiFi” market was full of empty promises. So he didn'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">
|
||||
“I spent two decades building secure networks for businesses
|
||||
that couldn't afford a second of downtime. When I hit the road,
|
||||
I realized the ‘Travel WiFi’ market was full of empty
|
||||
promises. I built TechSolve Travel to change that. I don't
|
||||
just sell you a SIM; I design your uptime, secure your data with
|
||||
WireGuard, and personally guarantee your hardware for life.”
|
||||
</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's Promise
|
||||
</p>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">
|
||||
When you call for support, you're not getting a call center.
|
||||
You'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'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's design your rig together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<p className="mt-8 font-display italic text-brand-blue text-xl font-semibold">
|
||||
I've got your back on the road. — David
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
76
src/components/sections/HeroSection.tsx
Normal file
76
src/components/sections/HeroSection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
66
src/components/sections/HowItWorksSection.tsx
Normal file
66
src/components/sections/HowItWorksSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/sections/LeadCaptureSection.tsx
Normal file
60
src/components/sections/LeadCaptureSection.tsx
Normal 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's Design Your Rig'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'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>
|
||||
);
|
||||
}
|
||||
90
src/components/sections/PricingSection.tsx
Normal file
90
src/components/sections/PricingSection.tsx
Normal 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 &
|
||||
Always-On plans) require Authorized Technical Contact status on your
|
||||
Starlink account. We manage the technical headaches so you
|
||||
don't have to.
|
||||
</p>
|
||||
</SectionWrapper>
|
||||
);
|
||||
}
|
||||
112
src/components/sections/StarlinkManagedSection.tsx
Normal file
112
src/components/sections/StarlinkManagedSection.tsx
Normal 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">
|
||||
“Starlink offers incredible speeds, but their support is
|
||||
famously unreachable. When your connection drops or your hardware
|
||||
fails, don'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't just your backup
|
||||
data provider — we are your dedicated vendor manager.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
28
src/components/sections/TrustBarSection.tsx
Normal file
28
src/components/sections/TrustBarSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/sections/VPNSection.tsx
Normal file
55
src/components/sections/VPNSection.tsx
Normal 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">
|
||||
“Work like you're at the office. Our WireGuard tunnels
|
||||
give you a dedicated IP and secure access back to your home base,
|
||||
bypassing ‘Travel WiFi’ restrictions.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
54
src/components/sections/WarrantySection.tsx
Normal file
54
src/components/sections/WarrantySection.tsx
Normal 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">
|
||||
“Stop worrying about hardware failure in the middle of a
|
||||
National Park. If we sold it to you and you're on the
|
||||
Always-On plan, it's covered. Forever.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/Badge.tsx
Normal file
29
src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/ui/Button.tsx
Normal file
51
src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/ui/Card.tsx
Normal file
18
src/components/ui/Card.tsx
Normal 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>;
|
||||
}
|
||||
236
src/components/ui/LeadForm.tsx
Normal file
236
src/components/ui/LeadForm.tsx
Normal 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're on the list!
|
||||
</h3>
|
||||
<p className="text-slate-500">
|
||||
We'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'll only reach out about your rig.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
22
src/components/ui/SectionWrapper.tsx
Normal file
22
src/components/ui/SectionWrapper.tsx
Normal 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
20
src/lib/auth.ts
Normal 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
139
src/lib/constants.ts
Normal 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
59
src/lib/salesiq.ts
Normal 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
134
src/lib/zoho.ts
Normal 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
50
src/types/index.ts
Normal 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
37
src/types/salesiq.d.ts
vendored
Normal 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 {};
|
||||
Reference in New Issue
Block a user