Initial commit
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal 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
37
.gitignore
vendored
Normal 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
5
middleware.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { auth as middleware } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/migrate/:path*"],
|
||||||
|
};
|
||||||
9
next.config.ts
Normal file
9
next.config.ts
Normal 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
1798
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
4
public/images/logo-fortinet.svg
Normal file
4
public/images/logo-fortinet.svg
Normal 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 |
4
public/images/logo-gliinet.svg
Normal file
4
public/images/logo-gliinet.svg
Normal 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 |
4
public/images/logo-peplink.svg
Normal file
4
public/images/logo-peplink.svg
Normal 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 |
4
public/images/logo-starlink.svg
Normal file
4
public/images/logo-starlink.svg
Normal 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 |
4
public/images/logo-ubiquiti-unifi.svg
Normal file
4
public/images/logo-ubiquiti-unifi.svg
Normal 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 |
4
public/images/logo-wireguard.svg
Normal file
4
public/images/logo-wireguard.svg
Normal 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 |
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 {};
|
||||||
33
tailwind.config.ts
Normal file
33
tailwind.config.ts
Normal 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
40
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user