eSIM store: market audit, marketing site, dashboards, Stripe plumbing, SEO/AI-search engine #3697
15 changed files+918−3
Modifiedapp/admin/actions.ts+10−0View fileUnifiedSplit
@@ -7,6 +7,8 @@ import {
77 getEsim,
88 updateOrder,
99 updateEsim,
10 updateLeadStatus,
11 LeadStatus,
1012 logEvent,
1113} from "@/lib/db";
1214import { getProvider } from "@/lib/provider";
@@ -60,6 +62,14 @@ export async function deactivateEsimAction(formData: FormData) {
6062 revalidatePath("/admin");
6163}
6264
65export async function updateLeadStatusAction(formData: FormData) {
66 await requireAdmin();
67 const status = String(formData.get("status") ?? "") as LeadStatus;
68 if (!["new", "contacted", "won", "lost"].includes(status)) return;
69 updateLeadStatus(String(formData.get("leadId") ?? ""), status);
70 revalidatePath("/admin/partners");
71}
72
6373export async function resendQrAction(formData: FormData) {
6474 await requireAdmin();
6575 const esim = getEsim(String(formData.get("esimId") ?? ""));
Modifiedapp/admin/layout.tsx+1−0View fileUnifiedSplit
@@ -15,6 +15,7 @@ const links = [
1515 { href: "/admin/orders", label: "Orders" },
1616 { href: "/admin/esims", label: "eSIMs" },
1717 { href: "/admin/customers", label: "Customers" },
18 { href: "/admin/partners", label: "Partners" },
1819 { href: "/admin/monitoring", label: "Monitoring" },
1920 { href: "/admin/plans", label: "Plans & pricing" },
2021];
Addedapp/admin/partners/page.tsx+89−0View fileUnifiedSplit
@@ -0,0 +1,89 @@
1import { listLeads, LeadStatus } from "@/lib/db";
2import { updateLeadStatusAction } from "../actions";
3
4export const dynamic = "force-dynamic";
5
6const statusStyles: Record<LeadStatus, string> = {
7 new: "bg-sky-glow/15 text-sky-glow",
8 contacted: "bg-amber-400/15 text-amber-300",
9 won: "bg-aurora-500/15 text-aurora-400",
10 lost: "bg-white/10 text-white/40",
11};
12
13const NEXT_STATUS: Partial<Record<LeadStatus, LeadStatus[]>> = {
14 new: ["contacted", "won", "lost"],
15 contacted: ["won", "lost"],
16 lost: ["contacted"],
17};
18
19export default function AdminPartnersPage() {
20 const leads = listLeads();
21
22 if (leads.length === 0) {
23 return (
24 <div className="glass rounded-2xl p-8 text-center text-white/60">
25 No partner leads yet. They arrive from the quote builder on{" "}
26 <code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">/business</code>.
27 </div>
28 );
29 }
30
31 return (
32 <div className="space-y-4">
33 {leads.map((lead) => (
34 <div key={lead.id} className="glass rounded-2xl p-5">
35 <div className="flex flex-wrap items-start justify-between gap-3">
36 <div>
37 <div className="font-semibold">
38 {lead.company}
39 <span className="ml-2 text-xs font-normal text-white/45">
40 {lead.segment}
41 </span>
42 </div>
43 <div className="mt-0.5 text-sm text-white/60">
44 {lead.contactName} · {lead.email}
45 {lead.estMonthlyTravelers
46 ? ` · ~${lead.estMonthlyTravelers} travelers`
47 : ""}
48 </div>
49 </div>
50 <div className="flex items-center gap-2">
51 <span
52 className={`rounded-full px-3 py-1 text-xs font-medium capitalize ${statusStyles[lead.status]}`}
53 >
54 {lead.status}
55 </span>
56 <span className="text-xs text-white/35">
57 {lead.createdAt.slice(0, 10)}
58 </span>
59 </div>
60 </div>
61 {lead.details && (
62 <p className="mt-3 rounded-xl bg-white/5 p-3 text-sm text-white/65">
63 {lead.details}
64 </p>
65 )}
66 {NEXT_STATUS[lead.status] && (
67 <div className="mt-4 flex gap-2">
68 {NEXT_STATUS[lead.status]!.map((next) => (
69 <form key={next} action={updateLeadStatusAction}>
70 <input type="hidden" name="leadId" value={lead.id} />
71 <input type="hidden" name="status" value={next} />
72 <button className="glass glass-hover rounded-lg px-3 py-1.5 text-xs capitalize">
73 Mark {next}
74 </button>
75 </form>
76 ))}
77 <a
78 href={`mailto:${lead.email}?subject=${encodeURIComponent(`Your Layova group quote — ${lead.company}`)}`}
79 className="btn-primary rounded-lg px-3 py-1.5 text-xs font-semibold"
80 >
81 Reply by email
82 </a>
83 </div>
84 )}
85 </div>
86 ))}
87 </div>
88 );
89}
Addedapp/api/partner-lead/route.ts+37−0View fileUnifiedSplit
@@ -0,0 +1,37 @@
1import { NextRequest, NextResponse } from "next/server";
2import { createLead } from "@/lib/db";
3
4/** Captures a B2B lead / bulk-quote request from /business. */
5export async function POST(req: NextRequest) {
6 let body: Record<string, unknown>;
7 try {
8 body = await req.json();
9 } catch {
10 return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
11 }
12
13 const company = String(body.company ?? "").trim();
14 const contactName = String(body.contactName ?? "").trim();
15 const email = String(body.email ?? "").trim().toLowerCase();
16 const segment = String(body.segment ?? "other").trim();
17 const details = String(body.details ?? "").trim().slice(0, 2000);
18 const estMonthlyTravelers = Number(body.estMonthlyTravelers) || undefined;
19
20 if (!company || !contactName || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
21 return NextResponse.json(
22 { error: "Company, contact name and a valid email are required" },
23 { status: 400 },
24 );
25 }
26
27 const lead = createLead({
28 company,
29 contactName,
30 email,
31 segment,
32 details,
33 estMonthlyTravelers,
34 });
35 // TODO at launch: email notification to sales inbox.
36 return NextResponse.json({ ok: true, id: lead.id });
37}
Addedapp/business/page.tsx+114−0View fileUnifiedSplit
@@ -0,0 +1,114 @@
1import { Metadata } from "next";
2import { site } from "@/lib/site";
3import { BulkQuoteBuilder } from "@/components/BulkQuoteBuilder";
4import { Faq } from "@/components/Faq";
5import { PARTNER_COMMISSION } from "@/lib/bulk";
6
7export const metadata: Metadata = {
8 title: "Business & group eSIMs — bulk pricing, one invoice, partner program",
9 description: `Bulk travel eSIMs for travel agencies, tour operators, schools, cruise retailers and corporate teams. Volume discounts up to 20%, one invoice, QR codes for every traveler, and a ${PARTNER_COMMISSION * 100}% partner commission program.`,
10 alternates: { canonical: "/business" },
11};
12
13const segments = [
14 {
15 icon: "🧳",
16 title: "Travel agencies & tour operators",
17 body: `Add connectivity to every booking and earn ${PARTNER_COMMISSION * 100}% on each sale via your co-branded link — or buy wholesale and set your own margin.`,
18 },
19 {
20 icon: "🏫",
21 title: "School & sports trips",
22 body: "Every student connected and reachable, parents reassured, one invoice for the organizer, group dashboard for the teacher in charge.",
23 },
24 {
25 icon: "🚢",
26 title: "Cruise retailers",
27 body: "Bundle port-day eSIMs with every cruise booking — the upsell that saves your clients from $30/MB maritime roaming bills.",
28 },
29 {
30 icon: "💼",
31 title: "Corporate & SME teams",
32 body: "Stop expensing $10/day roaming passes. Provision staff eSIMs centrally, see usage in one dashboard, get expense-ready receipts.",
33 },
34 {
35 icon: "🎫",
36 title: "Events & conferences",
37 body: "Delegate connectivity as part of the ticket: bulk QR delivery, custom data sizes, one invoice to the organizer.",
38 },
39 {
40 icon: "🤝",
41 title: "White-label & API",
42 body: "Your brand, our engine. Co-branded landing pages today; API provisioning as volume grows.",
43 },
44];
45
46const faqs = [
47 {
48 q: "How does bulk eSIM delivery work?",
49 a: "You receive a QR code per traveler (CSV or printable sheet) plus a group dashboard showing every eSIM's status and usage. Travelers install before departure; data activates on arrival.",
50 },
51 {
52 q: "How do agencies and operators earn?",
53 a: `Two models: referral (your co-branded link, you earn ${PARTNER_COMMISSION * 100}% of every sale, zero handling) or wholesale (volume-discounted bulk purchase, you set the retail price and keep the margin).`,
54 },
55 {
56 q: "Can we get one invoice for a whole group?",
57 a: "Yes — one invoice per group order, in NZD, AUD or USD, with per-traveler line items for easy on-charging.",
58 },
59 {
60 q: "What if a traveler's eSIM doesn't work?",
61 a: "24/7 support direct to the traveler, and a connect-or-refund guarantee on every eSIM, so the problem never lands back on your desk.",
62 },
63];
64
65export default function BusinessPage() {
66 return (
67 <>
68 <section className="aurora relative overflow-hidden border-b border-white/10">
69 <div className="relative mx-auto max-w-5xl px-4 pb-16 pt-20 text-center sm:px-6">
70 <p className="glass mx-auto w-fit rounded-full px-4 py-1.5 text-xs font-medium tracking-wide text-white/80">
71 {site.name} for Business
72 </p>
73 <h1 className="mt-7 text-4xl font-bold leading-tight tracking-tight sm:text-6xl">
74 Connectivity for <span className="text-gradient">every traveler you send</span>
75 </h1>
76 <p className="mx-auto mt-5 max-w-2xl text-lg text-white/65">
77 Bulk eSIMs in {site.countryCount}+ countries with volume discounts
78 up to 20%, one invoice, and a partner program that pays you on
79 every sale.
80 </p>
81 </div>
82 </section>
83
84 <section className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
85 <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
86 {segments.map((s) => (
87 <div key={s.title} className="glass rounded-2xl p-6">
88 <div className="text-2xl">{s.icon}</div>
89 <h2 className="mt-3 font-semibold text-aurora-400">{s.title}</h2>
90 <p className="mt-2 text-sm leading-relaxed text-white/60">{s.body}</p>
91 </div>
92 ))}
93 </div>
94 </section>
95
96 <section id="quote" className="border-y border-white/10 bg-ink-900/60">
97 <div className="mx-auto max-w-4xl px-4 py-16 sm:px-6">
98 <h2 className="text-center text-3xl font-bold tracking-tight">
99 Build your group quote
100 </h2>
101 <p className="mx-auto mt-2 max-w-xl text-center text-sm text-white/55">
102 Live volume pricing — submit it and we'll confirm within one
103 business day.
104 </p>
105 <div className="mt-10">
106 <BulkQuoteBuilder />
107 </div>
108 </div>
109 </section>
110
111 <Faq items={faqs} title="Business & partner FAQ" />
112 </>
113 );
114}
Modifiedapp/esim/[slug]/page.tsx+21−1View fileUnifiedSplit
@@ -11,6 +11,8 @@ import {
1111 breadcrumbLd,
1212} from "@/components/JsonLd";
1313import { site } from "@/lib/site";
14import { corridors, getCorridorBySlug } from "@/lib/corridors";
15import { CorridorPage } from "@/components/CorridorPage";
1416
1517/**
1618 * Programmatic SEO: one statically generated landing page per destination,
@@ -18,7 +20,10 @@ import { site } from "@/lib/site";
1820 */
1921
2022export function generateStaticParams() {
21 return countries.map((c) => ({ slug: c.slug }));
23 return [
24 ...countries.map((c) => ({ slug: c.slug })),
25 ...corridors.map((c) => ({ slug: c.slug })),
26 ];
2227}
2328
2429export const dynamicParams = false;
@@ -27,6 +32,19 @@ type Props = { params: Promise<{ slug: string }> };
2732
2833export async function generateMetadata({ params }: Props): Promise<Metadata> {
2934 const { slug } = await params;
35 const corridor = getCorridorBySlug(slug);
36 if (corridor) {
37 return {
38 title: corridor.title,
39 description: corridor.intro,
40 alternates: { canonical: `/esim/${corridor.slug}` },
41 openGraph: {
42 title: corridor.title,
43 description: corridor.intro,
44 url: `/esim/${corridor.slug}`,
45 },
46 };
47 }
3048 const country = getCountryBySlug(slug);
3149 if (!country) return {};
3250 const cheapest = plansForCountry(country)[0];
@@ -42,6 +60,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
4260
4361export default async function CountryPage({ params }: Props) {
4462 const { slug } = await params;
63 const corridor = getCorridorBySlug(slug);
64 if (corridor) return <CorridorPage corridor={corridor} />;
4565 const country = getCountryBySlug(slug);
4666 if (!country) notFound();
4767
Modifiedapp/sitemap.ts+9−1View fileUnifiedSplit
@@ -1,5 +1,6 @@
11import { MetadataRoute } from "next";
22import { countries } from "@/lib/countries";
3import { corridors } from "@/lib/corridors";
34import { absoluteUrl } from "@/lib/site";
45
56export default function sitemap(): MetadataRoute.Sitemap {
@@ -10,13 +11,20 @@ export default function sitemap(): MetadataRoute.Sitemap {
1011 { url: absoluteUrl("/compatibility"), changeFrequency: "monthly", priority: 0.7 },
1112 { url: absoluteUrl("/help"), changeFrequency: "monthly", priority: 0.6 },
1213 { url: absoluteUrl("/llm-info"), changeFrequency: "monthly", priority: 0.5 },
14 { url: absoluteUrl("/business"), changeFrequency: "monthly", priority: 0.8 },
1315 ];
1416
17 const corridorPages: MetadataRoute.Sitemap = corridors.map((c) => ({
18 url: absoluteUrl(`/esim/${c.slug}`),
19 changeFrequency: "weekly",
20 priority: 0.9,
21 }));
22
1523 const countryPages: MetadataRoute.Sitemap = countries.map((c) => ({
1624 url: absoluteUrl(`/esim/${c.slug}`),
1725 changeFrequency: "weekly",
1826 priority: c.popular ? 0.9 : 0.8,
1927 }));
2028
21 return [...staticPages, ...countryPages];
29 return [...staticPages, ...corridorPages, ...countryPages];
2230}
Addedcomponents/BulkQuoteBuilder.tsx+193−0View fileUnifiedSplit
@@ -0,0 +1,193 @@
1"use client";
2
3import { useMemo, useState } from "react";
4import { countries } from "@/lib/countries";
5import { plansForCountry, formatPrice, formatData } from "@/lib/plans";
6import { BULK_TIERS, bulkDiscount } from "@/lib/bulk";
7
8const SEGMENTS = [
9 "Travel agency",
10 "Tour operator",
11 "School / sports trip",
12 "Cruise retailer",
13 "Corporate / SME",
14 "Event organizer",
15 "Other",
16];
17
18/**
19 * Interactive bulk quote: destination + plan + headcount → live discounted
20 * price, submitted as a B2B lead into the admin pipeline.
21 */
22export function BulkQuoteBuilder() {
23 const [countryCode, setCountryCode] = useState("FJ");
24 const [planId, setPlanId] = useState("");
25 const [quantity, setQuantity] = useState(20);
26 const [form, setForm] = useState({ company: "", contactName: "", email: "", segment: SEGMENTS[0] });
27 const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
28 const [error, setError] = useState<string | null>(null);
29
30 const country = useMemo(
31 () => countries.find((c) => c.code === countryCode) ?? countries[0],
32 [countryCode],
33 );
34 const plans = useMemo(() => plansForCountry(country), [country]);
35 const plan = plans.find((p) => p.id === planId) ?? plans[2] ?? plans[0];
36
37 const discount = bulkDiscount(quantity);
38 const unitPrice = plan.priceUsd * (1 - discount);
39 const total = unitPrice * quantity;
40
41 async function submit(e: React.FormEvent) {
42 e.preventDefault();
43 setState("sending");
44 setError(null);
45 try {
46 const res = await fetch("/api/partner-lead", {
47 method: "POST",
48 headers: { "Content-Type": "application/json" },
49 body: JSON.stringify({
50 ...form,
51 estMonthlyTravelers: quantity,
52 details: `Quote request: ${quantity}× ${country.name} ${plan.label} (${formatData(plan.dataGb)}/${plan.days}d) — ${formatPrice(unitPrice)} each (${Math.round(discount * 100)}% volume discount), total ${formatPrice(total)}.`,
53 }),
54 });
55 const data = await res.json();
56 if (!res.ok) throw new Error(data.error ?? "Something went wrong");
57 setState("done");
58 } catch (err) {
59 setError(err instanceof Error ? err.message : "Something went wrong");
60 setState("error");
61 }
62 }
63
64 if (state === "done") {
65 return (
66 <div className="glass rounded-2xl p-8 text-center">
67 <div className="text-3xl">🤝</div>
68 <h3 className="mt-3 text-xl font-bold">Quote request received</h3>
69 <p className="mx-auto mt-2 max-w-md text-sm text-white/60">
70 We'll come back to {form.email} within one business day with your
71 formal quote for {quantity}× {country.name} eSIMs.
72 </p>
73 </div>
74 );
75 }
76
77 return (
78 <form onSubmit={submit} className="glass rounded-2xl p-6 sm:p-8">
79 <div className="grid gap-5 sm:grid-cols-3">
80 <label className="block text-sm">
81 <span className="text-white/70">Destination</span>
82 <select
83 value={countryCode}
84 onChange={(e) => {
85 setCountryCode(e.target.value);
86 setPlanId("");
87 }}
88 className="mt-2 h-12 w-full rounded-xl border border-white/15 bg-ink-800 px-3 text-white outline-none focus:border-white/35"
89 >
90 {countries.map((c) => (
91 <option key={c.code} value={c.code}>
92 {c.flag} {c.name}
93 </option>
94 ))}
95 </select>
96 </label>
97 <label className="block text-sm">
98 <span className="text-white/70">Plan per traveler</span>
99 <select
100 value={plan.id}
101 onChange={(e) => setPlanId(e.target.value)}
102 className="mt-2 h-12 w-full rounded-xl border border-white/15 bg-ink-800 px-3 text-white outline-none focus:border-white/35"
103 >
104 {plans.map((p) => (
105 <option key={p.id} value={p.id}>
106 {p.label} — {formatData(p.dataGb)} / {p.days}d — {formatPrice(p.priceUsd)}
107 </option>
108 ))}
109 </select>
110 </label>
111 <label className="block text-sm">
112 <span className="text-white/70">Travelers</span>
113 <input
114 type="number"
115 min={1}
116 max={10000}
117 value={quantity}
118 onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
119 className="mt-2 h-12 w-full rounded-xl border border-white/15 bg-white/5 px-4 text-white outline-none focus:border-white/35"
120 />
121 </label>
122 </div>
123
124 <div className="mt-6 rounded-xl bg-white/5 p-5">
125 <div className="flex flex-wrap items-baseline justify-between gap-2">
126 <div className="text-sm text-white/60">
127 {quantity}× {country.flag} {country.name} {plan.label}
128 {discount > 0 && (
129 <span className="ml-2 rounded-full bg-aurora-500/15 px-2.5 py-0.5 text-xs text-aurora-400">
130 {Math.round(discount * 100)}% volume discount
131 </span>
132 )}
133 </div>
134 <div className="text-2xl font-bold text-aurora-400">
135 {formatPrice(total)}
136 <span className="ml-2 text-sm font-normal text-white/45">
137 ({formatPrice(unitPrice)}/traveler)
138 </span>
139 </div>
140 </div>
141 <p className="mt-2 text-xs text-white/40">
142 {BULK_TIERS.map((t) => t.label).join(" · ")}
143 </p>
144 </div>
145
146 <div className="mt-6 grid gap-4 sm:grid-cols-2">
147 <input
148 required
149 placeholder="Company / organization"
150 value={form.company}
151 onChange={(e) => setForm({ ...form, company: e.target.value })}
152 className="h-12 rounded-xl border border-white/15 bg-white/5 px-4 text-sm text-white placeholder-white/35 outline-none focus:border-white/35"
153 />
154 <input
155 required
156 placeholder="Contact name"
157 value={form.contactName}
158 onChange={(e) => setForm({ ...form, contactName: e.target.value })}
159 className="h-12 rounded-xl border border-white/15 bg-white/5 px-4 text-sm text-white placeholder-white/35 outline-none focus:border-white/35"
160 />
161 <input
162 required
163 type="email"
164 placeholder="Work email"
165 value={form.email}
166 onChange={(e) => setForm({ ...form, email: e.target.value })}
167 className="h-12 rounded-xl border border-white/15 bg-white/5 px-4 text-sm text-white placeholder-white/35 outline-none focus:border-white/35"
168 />
169 <select
170 value={form.segment}
171 onChange={(e) => setForm({ ...form, segment: e.target.value })}
172 className="h-12 rounded-xl border border-white/15 bg-ink-800 px-3 text-sm text-white outline-none focus:border-white/35"
173 >
174 {SEGMENTS.map((s) => (
175 <option key={s}>{s}</option>
176 ))}
177 </select>
178 </div>
179
180 {error && <p className="mt-3 text-xs text-red-400">{error}</p>}
181
182 <button
183 disabled={state === "sending"}
184 className="btn-primary mt-6 w-full rounded-xl px-5 py-3.5 text-sm font-semibold disabled:opacity-60"
185 >
186 {state === "sending" ? "Sending…" : "Request formal quote"}
187 </button>
188 <p className="mt-3 text-center text-xs text-white/40">
189 No commitment — we reply within one business day.
190 </p>
191 </form>
192 );
193}
Addedcomponents/CorridorPage.tsx+100−0View fileUnifiedSplit
@@ -0,0 +1,100 @@
1import Link from "next/link";
2import { Corridor, corridorCountries } from "@/lib/corridors";
3import { cheapestPlan, formatPrice } from "@/lib/plans";
4import { Faq } from "@/components/Faq";
5import { JsonLd, breadcrumbLd } from "@/components/JsonLd";
6
7/** Multi-country corridor landing page (Pacific Islands, cruise, etc.). */
8export function CorridorPage({ corridor }: { corridor: Corridor }) {
9 const items = corridorCountries(corridor);
10
11 return (
12 <>
13 <JsonLd
14 data={breadcrumbLd([
15 { name: "Home", path: "/" },
16 { name: "Destinations", path: "/destinations" },
17 { name: corridor.name, path: `/esim/${corridor.slug}` },
18 ])}
19 />
20
21 <section className="aurora relative overflow-hidden border-b border-white/10">
22 <div className="relative mx-auto max-w-7xl px-4 pb-14 pt-14 sm:px-6">
23 <nav className="text-sm text-white/50" aria-label="Breadcrumb">
24 <Link href="/" className="hover:text-white">Home</Link>
25 <span className="mx-2">/</span>
26 <Link href="/destinations" className="hover:text-white">Destinations</Link>
27 <span className="mx-2">/</span>
28 <span className="text-white/80">{corridor.name}</span>
29 </nav>
30 <h1 className="mt-8 text-4xl font-bold tracking-tight sm:text-5xl">
31 {corridor.h1}
32 </h1>
33 <p className="mt-4 max-w-2xl text-lg leading-relaxed text-white/65">
34 {corridor.intro}
35 </p>
36 </div>
37 </section>
38
39 <section className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
40 <div className="grid gap-5 md:grid-cols-3">
41 {corridor.pitch.map((p) => (
42 <div key={p} className="glass rounded-2xl p-6 text-sm leading-relaxed text-white/70">
43 {p}
44 </div>
45 ))}
46 </div>
47 </section>
48
49 <section className="mx-auto max-w-7xl px-4 pb-16 sm:px-6">
50 <h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
51 Pick your destinations
52 </h2>
53 <p className="mt-2 text-sm text-white/55">
54 One eSIM per country — install them all before you leave.
55 </p>
56 <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
57 {items.map((c) => {
58 const plan = cheapestPlan(c);
59 return (
60 <Link
61 key={c.code}
62 href={`/esim/${c.slug}`}
63 className="glass glass-hover group rounded-2xl p-5 transition"
64 >
65 <div className="text-4xl">{c.flag}</div>
66 <div className="mt-3 font-semibold group-hover:text-white">{c.name}</div>
67 <div className="mt-1 text-sm text-white/50">
68 From{" "}
69 <span className="font-semibold text-aurora-400">
70 {formatPrice(plan.priceUsd)}
71 </span>
72 </div>
73 </Link>
74 );
75 })}
76 </div>
77 </section>
78
79 <section className="border-y border-white/10 bg-ink-900/60">
80 <div className="mx-auto max-w-4xl px-4 py-12 text-center sm:px-6">
81 <h2 className="text-xl font-bold">
82 Traveling as a group, school or business?
83 </h2>
84 <p className="mx-auto mt-2 max-w-xl text-sm text-white/60">
85 Bulk pricing, one invoice, and QR codes delivered for every
86 traveler. Tour operators and agencies earn on every sale.
87 </p>
88 <Link
89 href="/business"
90 className="btn-primary mt-6 inline-block rounded-full px-6 py-3 text-sm font-semibold"
91 >
92 Get a group quote →
93 </Link>
94 </div>
95 </section>
96
97 <Faq items={corridor.faqs} title={`${corridor.name} — FAQ`} />
98 </>
99 );
100}
Modifiedcomponents/Header.tsx+1−0View fileUnifiedSplit
@@ -4,6 +4,7 @@ import { site } from "@/lib/site";
44const nav = [
55 { href: "/destinations", label: "Destinations" },
66 { href: "/how-it-works", label: "How it works" },
7 { href: "/business", label: "Business" },
78 { href: "/compatibility", label: "Compatibility" },
89 { href: "/help", label: "Help" },
910];
Addeddocs/B2B-PLAYBOOK.md+116−0View fileUnifiedSplit
@@ -0,0 +1,116 @@
1# Layova B2B Go-To-Market Playbook
2
3*Travel eSIM — B2B/partnership revenue without capital. NZ-based. Researched June 2026.*
4
5The thesis: agencies get only ~10% from Airalo/Holafly affiliates while they're used to 15–35% on ancillaries. eSIM gross margins (60–80% at retail) let a lean brand pay partners 20–25% and still profit. Sell through people who already own travelers.
6
7---
8
9## 1. Competitor B2B/partner programs (verified)
10
11| Player | Offer to partners | Commercials | Friction |
12|---|---|---|---|
13| **Airalo Partners** | Affiliate links, reseller platform (co-brand, assign eSIMs from dashboard), API/SDK, Airalo for Business | Affiliate ~**10%** (via Impact); resellers buy at net pricing, min selling price applies | Low |
14| **Holafly Partners** | Travel Partners affiliate for agencies, API, white-label, co-brand, marketing kits | **10% per eSIM**, free to join, 365-day cookie; general affiliate ~7% | Very low |
15| **1GLOBAL** | Telco-as-a-Service white-label, enterprise plans | Shared pools 50GB–1TB; unlimited ~€57/mo; quote-only | Medium (enterprise motion) |
16| **Jetpac (Circles)** | Consumer-led; B2B co-brands exist (DBS bank) but no public program found | — | High/opaque |
17| **Maya Mobile** | Reseller/white-label; resell pre-made or build own plans | Claims no setup fees / no minimums / no contract; pricing under NDA | Low |
18| **MobiMatter** | Reseller portal (wholesale, bulk, white-label) + affiliate | Affiliate **10%**; reseller wallet min **US$250** — lowest entry found | Very low |
19
20**Insight:** the giants under-pay the channel. Our `/business` page offers **20%** — double the market — and it costs us nothing until a sale happens.
21
22## 2. Aggregator shortlist (our supply, ranked for zero capital)
23
241. **eSIM Access** — near-zero deposit (Stripe accepted), white-label API, 100+ countries. *Start here, launch in days.*
252. **MobiMatter Reseller** — US$250 wallet; multi-supplier marketplace; cherry-pick best per-corridor SKUs with no contract. *Run in parallel.*
263. **eSIM-Go** — US$1,000 top-up commitment over first 3 months, then no minimum on Standard; best platform/docs. *Adopt once 2–3 group bookings cover it.*
274. **Maya Mobile** — claims no fees/minimums; sign the NDA early as second source + Pacific gap-filler.
285. Later: **Telna / 1GLOBAL / BICS** at real volume.
29
30**Rule: never single-source.** Two wallets minimum so a coverage gap never kills a group order.
31
32**Wholesale economics (verified ranges):** ~US$0.60–0.80/GB mid-volume in cheap markets; Japan low-data bundles from ~US$1.50. Retail Fiji eSIMs sell at US$17–25 for 3–10GB → 60–80% gross margins are normal.
33
34**⚠ Pacific caveat (our corridor thesis's biggest risk AND moat):** majors cover Fiji, French Polynesia, Tonga, Vanuatu, PNG; **Samoa is patchy and the Cook Islands are essentially uncovered by international eSIMs today** (local Vodafone SIM only). Verify Pacific coverage country-by-country with each aggregator BEFORE promising it; price Pacific SKUs only after seeing actual wholesale rates.
35
36## 3. The pain we undercut (NZ/AU roaming, verified June 2026)
37
38- **Spark NZ:** NZ$11.50 per 200MB casual (≈ **NZ$57.50/GB**) in Pacific destinations
39- **One NZ:** NZ$10/day · **2degrees:** NZ$8/day (1GB/day then throttled)
40- **Telstra:** AU$10/day (AU$5/day NZ/Pacific) · **Optus:** ~AU$5/day
41- 14-day Japan trip on NZ daily roaming = **NZ$112–140/person**; a 10GB Japan eSIM retails NZ$25–40 and costs ~US$8–15 wholesale. That's the wedge in every pitch.
42
43## 4. First three segments to pitch (in order)
44
451. **School & sports trip organizers** (NZ/AU → Japan, Fiji, Pacific, UK/EU). One decision-maker, 15–50 eSIMs per order, one invoice, genuine safety pitch (parents can reach kids). Named NZ targets: **Tour Time, Haka Sports Tours, Student Horizons, Educating Adventures.** Nobody owns this niche.
462. **Independent travel agencies** (House of Travel owner-operators, helloworld members, YOU Travel, home-based agents). They monetize ancillaries at 15–35%; offer 20–25% co-branded and we beat Airalo/Holafly on the only number they care about. Flight Centre already bundles Hubby eSIM (proof of channel appetite); the independents are unserved — no HOT/helloworld eSIM deal found (recheck before pitching).
473. **SMEs with traveling staff** (exporters, trades, film, agritech, Pacific-facing NGOs/churches). Corporates pay US$80–200/traveler/week roaming; eSIM platforms run US$10–30. Incumbents force quote requests; we win with "one NZD invoice, GST receipt, eSIMs in inboxes today."
48
49*Deprioritize for now:* cruise retail (confusing port-day-only pitch), big chains (procurement, being locked up), DMCs (slow cycles — revisit day 90).
50
51## 5. The pitch (template)
52
53> **Subject: Your clients are paying Spark $57/GB in Fiji — give them a better option and keep 20%**
54>
55> Hi [name] — Spark charges $11.50 per 200MB in Raro/Fiji; One NZ is $10/day. A Layova travel eSIM does the same trip for under $30 total, installed before they leave your office.
56>
57> The offer: a free co-branded page with your logo. Every sale through it pays you **20% commission**, tracked automatically, paid monthly. No stock, no setup cost, no support burden — we handle installs and help. (Airalo and Holafly pay agents ~10%; we pay double.)
58>
59> Group bookings (schools, sports, weddings): one invoice, every traveler gets a QR by email, **15% off retail for 10+ travelers**, and you still earn your 20%.
60>
61> 15 minutes this week to set up your page?
62
63**Commercial menu** (lead with #1):
641. **Co-branded referral:** partner earns 20% (25% at 50+ sales/mo). Zero risk both sides.
652. **Wholesale + markup:** operator buys at retail −30%, one invoice, min 10 eSIMs, sets own price.
663. **White-label portal:** partner's brand end-to-end — only for partners proving 100+/mo.
67
68**Anchor retail (NZD, sanity-checked):** Japan 10GB ~$35 · Europe 10GB ~$39 · Fiji 5GB ~$35. Funds a 20% commission at 40–55% gross on major corridors.
69
70## 6. Revenue per partner (projections, assumptions flagged)
71
72| Partner | Assumption | Monthly result |
73|---|---|---|
74| 1 modest agency | 15 sales × NZ$35, 20% out, ~35% COGS | ~NZ$235 net |
75| 1 active agency | 50 sales × NZ$35 | ~NZ$790 net |
76| 1 school-trip organizer | 4 trips/yr × 25 pax × NZ$30 net | ~NZ$1,500–1,800/yr |
77| 1 SME (10 travelers) | NZ$30/traveler/mo | ~NZ$180–200 |
78
79**Ten mixed partners ≈ NZ$2,500–4,000/mo gross profit** — and it compounds by adding partners, not capital. (Projections; no public per-partner benchmarks exist.)
80
81## 7. 30/60/90-day plan
82
83**Days 1–30 — Supply + proof**
84- Open eSIM Access + MobiMatter wallets (≤US$300 total). Sign Maya NDA.
85- Pull real wholesale rates: Japan, Fiji, Australia, Europe, USA, Samoa/Tonga/French Polynesia. Kill any SKU that can't margin.
86- Test eSIMs on own phones; document install flow.
87- Wire the real provider into `lib/provider.ts`; connect Stripe test keys.
88- List 30 targets: 10 school/sports operators, 10 independent agencies, 10 SMEs from own network.
89
90**Days 31–60 — First partners**
91- Pitch all 30. Goal: **5 referral partners + 1 group booking.**
92- Over-deliver on the first school group (Zoom install session, WhatsApp support line, free teacher eSIM) → case study.
93- Publish two proof pages: "Fiji roaming vs eSIM: what Spark/One NZ really charge" and "Connectivity for school trips to Japan."
94
95**Days 61–90 — Systematize**
96- Move best-corridor volume to eSIM-Go Standard once bookings cover the US$1k.
97- Automate fulfilment + monthly commission report (spreadsheet is fine; pay on time, always).
98- Pitch 3 mid-size wholesale targets.
99- **Day-90 gate:** ≥8 partners and ≥NZ$3k/mo GMV → invest in white-label tooling; otherwise double down on school/sports only.
100
101## 8. Verify before relying on it
102
103eSIM Access deposit floor + per-GB rates (quote-gated) · Maya "no minimums" (marketing claim) · Pacific coverage per aggregator (esp. Samoa, **Cook Islands — likely impossible via eSIM today**) · current NZ carrier rates · whether HOT/helloworld have since signed an eSIM deal.
104
105---
106
107### What's already built in this codebase to support this playbook
108
109- `/business` — segment pitches + live bulk quote builder (volume tiers 10/15/20%) feeding the lead pipeline
110- `/admin/partners` — lead pipeline with new → contacted → won/lost tracking and one-click email reply
111- Corridor SEO pages — `/esim/pacific-islands`, `/esim/southeast-asia`, `/esim/europe`, `/esim/cruise`, `/esim/caribbean`
112- `lib/bulk.ts` — single source of truth for volume tiers + 20% partner commission
113
114### Key sources
115
116Airalo Partners · Holafly Partners · eSIM-Go pricing · eSIM Access docs · MobiMatter reseller · Maya reseller · 1GLOBAL · Telna · Mobilise · Zetexa · Flexiroam · Simology wholesale-pricing guide · Spark/One NZ/2degrees/Telstra/Optus roaming pages · MoneyHub NZ roaming comparison · Flight Centre × Hubby (Travolution) · travel-agent commission guides · esimdb Fiji · UNCTAD Pacific ICT · GigSky cruise · Tour Time / Haka Sports Tours / Student Horizons
Addedlib/bulk.ts+17−0View fileUnifiedSplit
@@ -0,0 +1,17 @@
1/**
2 * Volume discount tiers for group/partner orders. Shared by the public
3 * quote builder and the API so quotes can never drift from what we honor.
4 */
5export const BULK_TIERS = [
6 { min: 50, discount: 0.2, label: "50+ travelers — 20% off" },
7 { min: 20, discount: 0.15, label: "20–49 travelers — 15% off" },
8 { min: 10, discount: 0.1, label: "10–19 travelers — 10% off" },
9 { min: 1, discount: 0, label: "1–9 travelers — standard pricing" },
10] as const;
11
12export function bulkDiscount(quantity: number): number {
13 return BULK_TIERS.find((t) => quantity >= t.min)?.discount ?? 0;
14}
15
16/** Default revenue share offered to referring agencies/operators. */
17export const PARTNER_COMMISSION = 0.2;
Addedlib/corridors.ts+157−0View fileUnifiedSplit
@@ -0,0 +1,157 @@
1import { Country, getCountryByCode } from "./countries";
2
3/**
4 * Corridor / multi-country landing pages — the niche-domination SEO layer.
5 * Each corridor targets a travel pattern global competitors serve only with
6 * generic country pages (Pacific Islands and cruises especially).
7 */
8
9export interface Corridor {
10 slug: string;
11 name: string;
12 title: string;
13 h1: string;
14 intro: string;
15 pitch: string[];
16 countryCodes: string[];
17 faqs: { q: string; a: string }[];
18}
19
20export const corridors: Corridor[] = [
21 {
22 slug: "pacific-islands",
23 name: "Pacific Islands",
24 title: "Pacific Islands eSIM — Fiji, Rarotonga, Samoa, Tonga & more",
25 h1: "Pacific Islands eSIM",
26 intro:
27 "Island-hopping the Pacific? Roaming from NZ or Australia across Fiji, the Cook Islands, Samoa and Tonga is some of the most expensive on Earth. One Layova eSIM per island keeps you connected for a fraction of carrier roaming rates.",
28 pitch: [
29 "Carrier roaming in the Pacific can run NZ$10–40 per day — or worse, per few MB. Prepaid eSIM data costs a fraction of that.",
30 "Install before you fly: Pacific airports rarely have SIM kiosks, and resort WiFi is slow and shared.",
31 "Hotspot included on every plan — share one eSIM with the whole bure.",
32 ],
33 countryCodes: ["FJ", "CK", "WS", "TO", "VU", "PF", "NC", "SB", "NU", "TV", "KI"],
34 faqs: [
35 {
36 q: "Why is roaming so expensive in the Pacific Islands?",
37 a: "Small island carriers charge high wholesale rates to foreign networks, and NZ/AU carriers pass that on — often NZ$10–40 per day, or pay-per-MB rates that can hit hundreds of dollars. A prepaid local-rate eSIM avoids carrier roaming entirely.",
38 },
39 {
40 q: "Does one eSIM cover every Pacific island?",
41 a: "Coverage is per destination — buy one eSIM per country you visit (e.g. one for Fiji, one for the Cook Islands). Each installs before you fly and activates on arrival, so hopping islands stays seamless.",
42 },
43 {
44 q: "Will my eSIM work on outer islands and resorts?",
45 a: "Your eSIM uses the leading local networks, the same coverage locals use — typically strong in towns and resort areas, with 4G in main centres. Remote outer islands can have limited coverage on any network.",
46 },
47 ],
48 },
49 {
50 slug: "southeast-asia",
51 name: "Southeast Asia",
52 title: "Southeast Asia eSIM — Thailand, Bali, Vietnam & beyond",
53 h1: "Southeast Asia eSIM",
54 intro:
55 "Bangkok to Bali to Hanoi: Southeast Asia is the world's favourite backpacking and holiday circuit. Grab an eSIM per country and land connected at every border crossing.",
56 pitch: [
57 "Data in Southeast Asia is some of the world's cheapest — pay local rates, not roaming rates.",
58 "Border-hop friendly: install your next country's eSIM while you're still on the beach in the last one.",
59 "Ride-hailing, translation and maps from the second you land — no airport SIM queue.",
60 ],
61 countryCodes: ["TH", "ID", "VN", "MY", "SG", "PH", "KH", "LA", "MM", "BN"],
62 faqs: [
63 {
64 q: "Which eSIM is best for a multi-country Southeast Asia trip?",
65 a: "Buy one eSIM per country — local-rate plans are so cheap in Southeast Asia that per-country eSIMs almost always beat regional bundles on price, and you get the best local networks in each place.",
66 },
67 {
68 q: "Can I keep WhatsApp while traveling Southeast Asia?",
69 a: "Yes — your home SIM stays installed for calls/texts and WhatsApp keeps your existing number. The eSIM only supplies data.",
70 },
71 ],
72 },
73 {
74 slug: "europe",
75 name: "Europe",
76 title: "Europe eSIM — one continent, every country covered",
77 h1: "Europe eSIM",
78 intro:
79 "From Lisbon to Helsinki, Layova covers every European destination. Perfect for the big OE, a Euro summer, or a business swing through the capitals.",
80 pitch: [
81 "Coverage in 50+ European countries and territories.",
82 "5G in most major cities — faster than hotel WiFi.",
83 "Top up from your dashboard between countries without a new QR code.",
84 ],
85 countryCodes: ["FR", "IT", "ES", "DE", "GR", "PT", "NL", "CH", "GB", "IE", "HR", "CZ", "AT", "TR"],
86 faqs: [
87 {
88 q: "Do I need a different eSIM for every European country?",
89 a: "Buy a plan for each main destination for the best local rates. EU roaming rules mean many local plans also work across borders — check the plan details on each country page.",
90 },
91 {
92 q: "Is eSIM better than buying a SIM at a European airport?",
93 a: "Airport SIMs mean queues, passport registration in many countries, and tourist pricing. An eSIM is installed before you fly and costs less.",
94 },
95 ],
96 },
97 {
98 slug: "cruise",
99 name: "Cruise travel",
100 title: "Cruise eSIM — stay connected port to port",
101 h1: "eSIM for cruise travelers",
102 intro:
103 "Ship WiFi is slow and expensive, and maritime roaming is the most expensive data on the planet. The smart cruise setup: an eSIM for every port country, and airplane mode at sea.",
104 pitch: [
105 "Port days covered: install an eSIM for each country on your itinerary before you sail.",
106 "Avoid bill shock: maritime satellite roaming can cost $30+/MB — turn data roaming off at sea and use your port eSIMs ashore.",
107 "One dashboard for the whole itinerary — top up any port eSIM from the ship's WiFi.",
108 ],
109 countryCodes: ["FJ", "NC", "VU", "AU", "NZ", "IT", "GR", "ES", "HR", "MX", "BS", "JM"],
110 faqs: [
111 {
112 q: "Will my eSIM work on the cruise ship?",
113 a: "At sea, ships use satellite networks that are not included in any travel eSIM — keep data roaming off on the ship and use ship WiFi if needed. Your Layova eSIMs connect automatically each time you reach a port country.",
114 },
115 {
116 q: "How do I avoid huge roaming bills on a cruise?",
117 a: "Turn off data roaming for your home SIM before boarding, never let your phone connect to the ship's maritime network (\"cellular at sea\"), and use a prepaid eSIM in each port country instead.",
118 },
119 {
120 q: "How many eSIMs do I need for my cruise?",
121 a: "One per country on your itinerary. A South Pacific cruise from Auckland might use Fiji, Vanuatu and New Caledonia eSIMs; a Mediterranean cruise might use Italy, Greece and Spain. Install them all before you sail.",
122 },
123 ],
124 },
125 {
126 slug: "caribbean",
127 name: "Caribbean",
128 title: "Caribbean eSIM — island data without the roaming bill",
129 h1: "Caribbean eSIM",
130 intro:
131 "Caribbean roaming is infamously pricey and island WiFi is patchy. Land in Nassau, Montego Bay or San Juan already connected.",
132 pitch: [
133 "Per-island plans at local rates instead of US$10–15/day carrier passes.",
134 "Cruise-friendly: install every port's eSIM before you sail.",
135 "Hotspot included — share data with the whole villa.",
136 ],
137 countryCodes: ["BS", "JM", "DO", "PR", "AW", "BB", "KY", "CW", "TT", "LC"],
138 faqs: [
139 {
140 q: "Why is Caribbean roaming so expensive?",
141 a: "Many islands have a single dominant carrier charging high wholesale roaming rates, which home carriers pass on as $10–15/day passes or steep per-MB rates. Prepaid eSIMs at local rates avoid those charges.",
142 },
143 ],
144 },
145];
146
147const bySlug = new Map(corridors.map((c) => [c.slug, c]));
148
149export function getCorridorBySlug(slug: string): Corridor | undefined {
150 return bySlug.get(slug);
151}
152
153export function corridorCountries(corridor: Corridor): Country[] {
154 return corridor.countryCodes
155 .map((code) => getCountryByCode(code))
156 .filter((c): c is Country => Boolean(c));
157}
Modifiedlib/countries.ts+1−1View fileUnifiedSplit
@@ -42,7 +42,7 @@ const REGION_CODES: Record<Region, string[]> = {
4242 "NE", "NG", "RW", "ST", "SN", "SC", "SL", "SO", "ZA", "SS", "SD", "TZ",
4343 "TG", "TN", "UG", "ZM", "ZW", "RE", "ER",
4444 ],
45 Oceania: ["AS", "AU", "FJ", "PF", "GU", "KI", "NR", "NC", "NZ", "PW", "PG", "WS", "SB", "TO", "TV", "VU", "FM", "MH"],
45 Oceania: ["AS", "AU", "CK", "FJ", "PF", "GU", "KI", "NR", "NC", "NU", "NZ", "PW", "PG", "WS", "SB", "TO", "TV", "VU", "FM", "MH"],
4646 "Middle East": [
4747 "BH", "IR", "IQ", "IL", "JO", "KW", "LB", "OM", "PS", "QA", "SA", "SY",
4848 "TR", "AE", "YE",
Modifiedlib/db.ts+52−0View fileUnifiedSplit
@@ -54,6 +54,21 @@ export interface EsimRec {
5454 createdAt: string;
5555}
5656
57export type LeadStatus = "new" | "contacted" | "won" | "lost";
58
59export interface PartnerLeadRec {
60 id: string;
61 company: string;
62 contactName: string;
63 email: string;
64 segment: string;
65 /** Free-text need + quote summary captured from the quote builder. */
66 details: string;
67 estMonthlyTravelers?: number;
68 status: LeadStatus;
69 createdAt: string;
70}
71
5772export interface EventRec {
5873 id: string;
5974 ts: string;
@@ -67,6 +82,7 @@ interface Store {
6782 orders: OrderRec[];
6883 esims: EsimRec[];
6984 events: EventRec[];
85 leads?: PartnerLeadRec[];
7086}
7187
7288const DATA_FILE = path.join(process.cwd(), ".data", "store.json");
@@ -223,6 +239,42 @@ export function listEsims(email?: string): EsimRec[] {
223239 return email ? esims.filter((e) => e.email === email) : [...esims];
224240}
225241
242// ---- Partner leads (B2B pipeline) ----
243
244export function createLead(
245 input: Omit<PartnerLeadRec, "id" | "status" | "createdAt">,
246): PartnerLeadRec {
247 const store = getStore();
248 const lead: PartnerLeadRec = {
249 ...input,
250 id: randomUUID(),
251 status: "new",
252 createdAt: new Date().toISOString(),
253 };
254 store.leads = [lead, ...(store.leads ?? [])];
255 logEvent("info", "lead.created", `B2B lead: ${lead.company} (${lead.segment}) — ${lead.email}`);
256 persist(store);
257 return lead;
258}
259
260export function listLeads(): PartnerLeadRec[] {
261 return [...(getStore().leads ?? [])];
262}
263
264export function updateLeadStatus(
265 id: string,
266 status: LeadStatus,
267): PartnerLeadRec | undefined {
268 const store = getStore();
269 const lead = (store.leads ?? []).find((l) => l.id === id);
270 if (lead) {
271 lead.status = status;
272 logEvent("info", "lead.status", `Lead ${lead.company} → ${status}`);
273 persist(store);
274 }
275 return lead;
276}
277
226278export function listEvents(limit = 100): EventRec[] {
227279 return getStore().events.slice(0, limit);
228280}
229281
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts