feat: add purchase wizard flow at /get-started

5-step guided wizard: find business → confirm → email → payment → success.
Includes Google Places search API, Stripe checkout API, i18n (EN/ES),
and CTA links updated to point to the new wizard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-22 23:21:09 +00:00
parent 5242045503
commit 49edf70235
16 changed files with 1193 additions and 2 deletions

View File

@@ -0,0 +1,16 @@
import { getTranslation } from "@turbostarter/i18n/server";
import { withI18n } from "@turbostarter/i18n/with-i18n";
import { getMetadata } from "~/lib/metadata";
import { Wizard } from "~/modules/marketing/get-started/wizard";
export const generateMetadata = getMetadata({
title: "marketing:wizard.pageTitle",
description: "marketing:wizard.pageDescription",
});
const GetStartedPage = async () => {
return <Wizard />;
};
export default withI18n(GetStartedPage);

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import Stripe from "stripe";
const BLUEPRINT_PRICE_CENTS = 4700;
const CURRENCY = "eur";
function getStripe(): Stripe {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error("STRIPE_SECRET_KEY is required");
}
return new Stripe(key);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, businessName, placeId, locale } = body as {
email: string;
businessName: string;
placeId: string;
locale?: string;
};
if (!email || !businessName) {
return NextResponse.json(
{ error: "Email and business name are required" },
{ status: 400 },
);
}
const stripe = getStripe();
const origin = request.headers.get("origin") || "";
const lang = locale || "en";
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
customer_email: email,
line_items: [
{
price_data: {
currency: CURRENCY,
product_data: {
name: "Reputation Blueprint",
description: `Reputation Blueprint for ${businessName}`,
},
unit_amount: BLUEPRINT_PRICE_CENTS,
},
quantity: 1,
},
],
metadata: {
product: "blueprint",
business_name: businessName,
place_id: placeId || "",
email,
},
success_url: `${origin}/${lang}/get-started?step=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/${lang}/get-started?step=payment`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,149 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
interface Business {
place_id: string;
name: string;
address: string;
rating: number;
review_count: number;
category: string;
maps_url: string;
}
const GOOGLE_MAPS_URL_PATTERN =
/(?:google\.com\/maps|maps\.google\.com|goo\.gl\/maps|maps\.app\.goo\.gl)/i;
function isGoogleMapsUrl(input: string): boolean {
return GOOGLE_MAPS_URL_PATTERN.test(input);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { query } = body as { query: string };
if (!query || typeof query !== "string" || query.trim().length < 2) {
return NextResponse.json(
{ error: "Query must be at least 2 characters" },
{ status: 400 },
);
}
const trimmed = query.trim();
if (isGoogleMapsUrl(trimmed)) {
return handleUrlSearch(trimmed);
}
return handleTextSearch(trimmed);
} catch {
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}
}
async function handleUrlSearch(url: string): Promise<NextResponse> {
const engineUrl = process.env.NEXT_PUBLIC_ENGINE_URL;
if (!engineUrl) {
return NextResponse.json(
{ error: "Engine service not configured" },
{ status: 503 },
);
}
try {
const response = await fetch(`${engineUrl}/api/check-reviews`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (!response.ok) {
return NextResponse.json(
{ error: "Could not find business from URL" },
{ status: 404 },
);
}
const data = (await response.json()) as Record<string, unknown>;
const business: Business = {
place_id: (data.place_id as string) || "",
name: (data.name as string) || "Unknown Business",
address: (data.address as string) || "",
rating: (data.rating as number) || 0,
review_count: (data.review_count as number) || (data.reviews_count as number) || 0,
category: (data.category as string) || (data.type as string) || "",
maps_url: url,
};
return NextResponse.json({ businesses: [business] });
} catch {
return NextResponse.json(
{ error: "Failed to fetch business details" },
{ status: 502 },
);
}
}
async function handleTextSearch(query: string): Promise<NextResponse> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "Places search not configured" },
{ status: 503 },
);
}
try {
const response = await fetch(
"https://places.googleapis.com/v1/places:searchText",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": apiKey,
"X-Goog-FieldMask":
"places.id,places.displayName,places.formattedAddress,places.rating,places.userRatingCount,places.primaryTypeDisplayName,places.googleMapsUri",
},
body: JSON.stringify({
textQuery: query,
languageCode: "en",
}),
},
);
if (!response.ok) {
return NextResponse.json(
{ error: "Places search failed" },
{ status: 502 },
);
}
const data = (await response.json()) as Record<string, unknown>;
const businesses: Business[] = ((data.places as any[]) || [])
.slice(0, 5)
.map((place: any) => ({
place_id: place.id || "",
name: place.displayName?.text || "Unknown",
address: place.formattedAddress || "",
rating: place.rating || 0,
review_count: place.userRatingCount || 0,
category: place.primaryTypeDisplayName?.text || "",
maps_url: place.googleMapsUri || "",
}));
return NextResponse.json({ businesses });
} catch {
return NextResponse.json(
{ error: "Failed to search businesses" },
{ status: 502 },
);
}
}

View File

@@ -17,6 +17,7 @@ const DEMO_PREFIX = "/demo";
const pathsConfig = {
index: "/",
getStarted: "/get-started",
demo: {
index: DEMO_PREFIX,
report: "/report-demo",

View File

@@ -0,0 +1,83 @@
"use client";
import { cn } from "@turbostarter/ui";
import type { BusinessData } from "./wizard";
interface BusinessCardProps {
business: BusinessData;
onClick?: () => void;
selected?: boolean;
className?: string;
}
export const BusinessCard = ({
business,
onClick,
selected,
className,
}: BusinessCardProps) => {
return (
<button
type="button"
onClick={onClick}
className={cn(
"bg-background flex w-full items-start gap-4 rounded-xl border p-4 text-left transition-colors",
onClick && "hover:border-primary/50 cursor-pointer",
selected && "border-primary bg-primary/5",
className,
)}
>
<div className="bg-primary/10 flex size-10 shrink-0 items-center justify-center rounded-lg">
<svg
className="text-primary size-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.015a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"
/>
</svg>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-foreground truncate font-medium">
{business.name}
</span>
<span className="text-muted-foreground truncate text-sm">
{business.address}
</span>
<div className="mt-1 flex items-center gap-3">
{business.rating > 0 && (
<span className="flex items-center gap-1 text-sm">
<svg
className="size-3.5 text-amber-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span className="text-foreground font-medium">
{business.rating}
</span>
</span>
)}
{business.review_count > 0 && (
<span className="text-muted-foreground text-sm">
({business.review_count})
</span>
)}
{business.category && (
<span className="text-muted-foreground text-xs">
{business.category}
</span>
)}
</div>
</div>
</button>
);
};

View File

@@ -0,0 +1,78 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import { BusinessCard } from "./business-card";
import type { BusinessData } from "./wizard";
interface StepConfirmProps {
business: BusinessData;
onConfirm: () => void;
onBack: () => void;
}
export const StepConfirm = ({
business,
onConfirm,
onBack,
}: StepConfirmProps) => {
const { t } = useTranslation("marketing");
return (
<div className="flex w-full max-w-lg flex-col items-center gap-6">
<div className="text-center">
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("wizard.confirm.title")}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t("wizard.confirm.description")}
</p>
</div>
<BusinessCard business={business} selected className="pointer-events-none" />
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">
{t("wizard.confirm.reviews", { count: business.review_count })}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{t("wizard.confirm.rating", { rating: business.rating })}
</span>
{business.maps_url && (
<>
<span className="text-muted-foreground">·</span>
<a
href={business.maps_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t("wizard.confirm.viewOnGoogle")}
</a>
</>
)}
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onConfirm}
className={cn(buttonVariants({ size: "lg" }), "w-full")}
>
{t("wizard.confirm.confirmButton")}
</button>
<button
type="button"
onClick={onBack}
className="text-muted-foreground hover:text-foreground text-center text-sm transition-colors"
>
{t("wizard.confirm.wrongBusiness")}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
interface StepEmailProps {
initialEmail: string;
initialName: string;
onSubmit: (email: string, name: string) => void;
onBack: () => void;
}
export const StepEmail = ({
initialEmail,
initialName,
onSubmit,
onBack,
}: StepEmailProps) => {
const { t } = useTranslation("marketing");
const [email, setEmail] = useState(initialEmail);
const [name, setName] = useState(initialName);
const [emailError, setEmailError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setEmailError("");
const trimmed = email.trim();
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setEmailError("Please enter a valid email address");
return;
}
onSubmit(trimmed, name.trim());
};
return (
<div className="flex w-full max-w-lg flex-col items-center gap-6">
<div className="text-center">
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("wizard.email.title")}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t("wizard.email.description")}
</p>
</div>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
<div>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setEmailError("");
}}
placeholder={t("wizard.email.placeholder")}
className={cn(
"border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-xl border py-3 px-4 text-sm shadow-sm outline-none transition-colors",
"focus:border-primary focus:ring-primary/20 focus:ring-2",
emailError && "border-red-500",
)}
autoFocus
/>
{emailError && (
<p className="mt-1.5 text-xs text-red-500">{emailError}</p>
)}
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("wizard.email.namePlaceholder")}
className={cn(
"border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-xl border py-3 px-4 text-sm shadow-sm outline-none transition-colors",
"focus:border-primary focus:ring-primary/20 focus:ring-2",
)}
/>
<button
type="submit"
className={cn(buttonVariants({ size: "lg" }), "w-full")}
>
{t("wizard.email.continueButton")}
</button>
<button
type="button"
onClick={onBack}
className="text-muted-foreground hover:text-foreground text-center text-sm transition-colors"
>
Back
</button>
</form>
</div>
);
};

View File

@@ -0,0 +1,166 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { BusinessCard } from "./business-card";
import type { BusinessData } from "./wizard";
interface StepFindBusinessProps {
onSelect: (business: BusinessData) => void;
}
export const StepFindBusiness = ({ onSelect }: StepFindBusinessProps) => {
const { t } = useTranslation("marketing");
const [query, setQuery] = useState("");
const [results, setResults] = useState<BusinessData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searched, setSearched] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const search = useCallback(async (q: string) => {
if (q.trim().length < 2) {
setResults([]);
setSearched(false);
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/api/business/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q.trim() }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as Record<string, string>;
setError(data.error || t("wizard.findBusiness.error"));
setResults([]);
} else {
const data = (await res.json()) as { businesses: BusinessData[] };
setResults(data.businesses || []);
}
} catch {
setError(t("wizard.findBusiness.error"));
setResults([]);
} finally {
setLoading(false);
setSearched(true);
}
}, [t]);
const handleInputChange = (value: string) => {
setQuery(value);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
const isUrl = /(?:google\.com\/maps|maps\.google\.com|goo\.gl\/maps|maps\.app\.goo\.gl)/i.test(value);
if (isUrl && value.trim().length > 10) {
search(value);
return;
}
debounceRef.current = setTimeout(() => {
if (value.trim().length >= 3) {
search(value);
}
}, 500);
};
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, []);
const isUrl = /(?:google\.com\/maps|maps\.google\.com|goo\.gl\/maps|maps\.app\.goo\.gl)/i.test(query);
return (
<div className="flex w-full max-w-lg flex-col items-center gap-6">
<div className="text-center">
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("wizard.findBusiness.title")}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t("wizard.findBusiness.description")}
</p>
</div>
<div className="relative w-full">
<div className="relative">
<svg
className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
type="text"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={t("wizard.findBusiness.placeholder")}
className={cn(
"border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-xl border py-3 pl-10 pr-4 text-sm shadow-sm outline-none transition-colors",
"focus:border-primary focus:ring-primary/20 focus:ring-2",
)}
autoFocus
/>
{isUrl && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md bg-green-500/10 px-2 py-0.5 text-xs text-green-600">
{t("wizard.findBusiness.urlDetected")}
</span>
)}
</div>
</div>
{loading && (
<div className="flex items-center gap-2">
<div className="border-primary size-4 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-muted-foreground text-sm">
{t("wizard.findBusiness.searching")}
</span>
</div>
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{!loading && searched && results.length === 0 && !error && (
<p className="text-muted-foreground text-center text-sm">
{t("wizard.findBusiness.noResults")}
</p>
)}
{results.length > 0 && (
<div className="flex w-full flex-col gap-2">
{results.map((business) => (
<BusinessCard
key={business.place_id || business.name}
business={business}
onClick={() => onSelect(business)}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
interface StepIndicatorProps {
currentStep: number;
totalSteps: number;
}
export const StepIndicator = ({
currentStep,
totalSteps,
}: StepIndicatorProps) => {
const { t } = useTranslation("marketing");
return (
<div className="flex w-full max-w-md flex-col items-center gap-3">
<p className="text-muted-foreground text-sm">
{t("wizard.step", { current: currentStep, total: totalSteps })}
</p>
<div className="flex w-full gap-2">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
i < currentStep ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
import type { BusinessData } from "./wizard";
interface StepPaymentProps {
business: BusinessData;
email: string;
onBack: () => void;
}
export const StepPayment = ({ business, email, onBack }: StepPaymentProps) => {
const { t } = useTranslation("marketing");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handlePay = async () => {
setLoading(true);
setError("");
try {
const locale = window.location.pathname.split("/")[1] || "en";
const res = await fetch("/api/blueprint/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
businessName: business.name,
placeId: business.place_id,
locale,
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as Record<string, string>;
setError(data.error || "Payment failed");
return;
}
const data = (await res.json()) as { url: string };
if (data.url) {
window.location.href = data.url;
}
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="flex w-full max-w-lg flex-col items-center gap-6">
<div className="text-center">
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("wizard.payment.title")}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t("wizard.payment.description")}
</p>
</div>
{/* Order summary card */}
<div className="bg-background w-full rounded-xl border p-6">
<h3 className="text-foreground mb-4 text-sm font-semibold">
{t("wizard.payment.orderSummary")}
</h3>
<div className="flex items-start justify-between">
<div>
<p className="text-foreground font-medium">
{t("wizard.payment.product")}
</p>
<p className="text-muted-foreground text-sm">
{t("wizard.payment.for", { business: business.name })}
</p>
</div>
<div className="text-right">
<span className="text-muted-foreground mr-2 text-sm line-through">
{t("wizard.payment.originalPrice")}
</span>
<span className="text-foreground text-xl font-bold">
{t("wizard.payment.launchPrice")}
</span>
</div>
</div>
<div className="my-4">
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
{t("wizard.payment.launchBadge")}
</span>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between">
<span className="text-foreground font-semibold">
{t("wizard.payment.total")}
</span>
<span className="text-foreground text-2xl font-bold">
{t("wizard.payment.launchPrice")}
</span>
</div>
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={handlePay}
disabled={loading}
className={cn(
buttonVariants({ size: "lg" }),
"w-full",
loading && "opacity-60",
)}
>
{loading ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t("wizard.payment.payButton")}
</span>
) : (
t("wizard.payment.payButton")
)}
</button>
<div className="text-muted-foreground flex flex-col items-center gap-1 text-xs">
<span className="flex items-center gap-1">
<svg
className="size-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
{t("wizard.payment.securePayment")}
</span>
<span>{t("wizard.payment.guarantee")}</span>
</div>
<button
type="button"
onClick={onBack}
className="text-muted-foreground hover:text-foreground text-center text-sm transition-colors"
>
Back
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,98 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { buttonVariants } from "@turbostarter/ui-web/button";
interface StepSuccessProps {
email: string;
}
export const StepSuccess = ({ email }: StepSuccessProps) => {
const { t } = useTranslation("marketing");
return (
<div className="flex w-full max-w-lg flex-col items-center gap-8">
{/* Success icon */}
<div className="flex size-16 items-center justify-center rounded-full bg-green-500/10">
<svg
className="size-8 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div className="text-center">
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("wizard.success.title")}
</h2>
<p className="text-muted-foreground mt-2 text-sm">
{t("wizard.success.description", {
email: email || "your email",
})}
</p>
</div>
{/* Processing indicator */}
<div className="bg-background w-full rounded-xl border p-6">
<div className="flex items-center gap-3">
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-foreground text-sm font-medium">
{t("wizard.success.processing")}
</span>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("wizard.success.delivery")}
</p>
</div>
{/* Create account section */}
<div className="bg-background w-full rounded-xl border p-6">
<h3 className="text-foreground font-semibold">
{t("wizard.success.createAccount")}
</h3>
<p className="text-muted-foreground mt-1 text-sm">
{t("wizard.success.createAccountDescription")}
</p>
<div className="mt-4 flex flex-col gap-3">
<input
type="email"
value={email || ""}
disabled
className="border-input bg-muted text-muted-foreground w-full rounded-xl border px-4 py-3 text-sm"
/>
<input
type="password"
placeholder={t("wizard.success.passwordPlaceholder")}
className={cn(
"border-input bg-background text-foreground placeholder:text-muted-foreground w-full rounded-xl border py-3 px-4 text-sm shadow-sm outline-none transition-colors",
"focus:border-primary focus:ring-primary/20 focus:ring-2",
)}
/>
<button
type="button"
className={cn(buttonVariants({ size: "lg" }), "w-full")}
>
{t("wizard.success.registerButton")}
</button>
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground mt-3 w-full text-center text-sm transition-colors"
>
{t("wizard.success.skipAccount")}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,120 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useTranslation } from "@turbostarter/i18n";
import { Section } from "~/modules/marketing/layout/section";
import { StepIndicator } from "./step-indicator";
import { StepFindBusiness } from "./step-find-business";
import { StepConfirm } from "./step-confirm";
import { StepEmail } from "./step-email";
import { StepPayment } from "./step-payment";
import { StepSuccess } from "./step-success";
export interface BusinessData {
place_id: string;
name: string;
address: string;
rating: number;
review_count: number;
category: string;
maps_url: string;
}
interface WizardState {
business: BusinessData | null;
email: string;
name: string;
}
const TOTAL_STEPS = 5;
export const Wizard = () => {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const stepParam = searchParams.get("step");
const sessionId = searchParams.get("session_id");
const getInitialStep = (): number => {
if (stepParam === "success" && sessionId) return 5;
if (stepParam === "payment") return 4;
const n = Number(stepParam);
if (n >= 1 && n <= 5) return n;
return 1;
};
const [step, setStep] = useState(getInitialStep);
const [wizardState, setWizardState] = useState<WizardState>({
business: null,
email: "",
name: "",
});
const updateStep = useCallback(
(newStep: number) => {
setStep(newStep);
const params = new URLSearchParams(searchParams.toString());
params.set("step", String(newStep));
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[searchParams, router, pathname],
);
const handleBusinessSelect = (business: BusinessData) => {
setWizardState((prev) => ({ ...prev, business }));
updateStep(2);
};
const handleConfirm = () => {
updateStep(3);
};
const handleEmail = (email: string, name: string) => {
setWizardState((prev) => ({ ...prev, email, name }));
updateStep(4);
};
const handleBack = (toStep: number) => {
updateStep(toStep);
};
return (
<Section className="min-h-[60vh]">
{step < 5 && (
<StepIndicator currentStep={step} totalSteps={TOTAL_STEPS - 1} />
)}
{step === 1 && <StepFindBusiness onSelect={handleBusinessSelect} />}
{step === 2 && wizardState.business && (
<StepConfirm
business={wizardState.business}
onConfirm={handleConfirm}
onBack={() => handleBack(1)}
/>
)}
{step === 3 && (
<StepEmail
initialEmail={wizardState.email}
initialName={wizardState.name}
onSubmit={handleEmail}
onBack={() => handleBack(2)}
/>
)}
{step === 4 && wizardState.business && (
<StepPayment
business={wizardState.business}
email={wizardState.email}
onBack={() => handleBack(3)}
/>
)}
{step === 5 && <StepSuccess email={wizardState.email} />}
</Section>
);
};

View File

@@ -99,7 +99,7 @@ export const Pricing = () => {
{/* CTA */}
<TurboLink
href={pathsConfig.auth.login}
href={pathsConfig.getStarted}
className={cn(
buttonVariants({ size: "lg" }),
"w-full text-base font-semibold",

View File

@@ -20,7 +20,7 @@ export const CtaButton = (
href={
data?.session
? pathsConfig.dashboard.user.index
: pathsConfig.auth.login
: pathsConfig.getStarted
}
className={cn(buttonVariants(), props.className)}
>