Compare commits
10 Commits
49edf70235
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d92124211 | ||
|
|
c642218bf8 | ||
|
|
3eb38b75f9 | ||
|
|
0c66bab042 | ||
|
|
b82358a934 | ||
|
|
f04e8639da | ||
|
|
99ec41bd87 | ||
|
|
35779e45d9 | ||
|
|
b92dfecac4 | ||
|
|
c7ee5ce269 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -67,3 +67,6 @@ dist/
|
|||||||
|
|
||||||
# Auto Claude data directory
|
# Auto Claude data directory
|
||||||
.auto-claude/
|
.auto-claude/
|
||||||
|
|
||||||
|
# Moat task system
|
||||||
|
.moat/
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Providers } from "~/lib/providers/providers";
|
|||||||
import { ImpersonatingBanner } from "~/modules/admin/users/user/impersonating-banner";
|
import { ImpersonatingBanner } from "~/modules/admin/users/user/impersonating-banner";
|
||||||
import { BaseLayout } from "~/modules/common/layout/base";
|
import { BaseLayout } from "~/modules/common/layout/base";
|
||||||
import { Toaster } from "~/modules/common/toast";
|
import { Toaster } from "~/modules/common/toast";
|
||||||
import { BuyCtaDialog } from "~/modules/marketing/layout/buy-cta-dialog";
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return config.locales.map((locale) => ({ locale }));
|
return config.locales.map((locale) => ({ locale }));
|
||||||
@@ -33,7 +32,6 @@ export default async function RootLayout({
|
|||||||
<Providers locale={locale}>
|
<Providers locale={locale}>
|
||||||
<ImpersonatingBanner />
|
<ImpersonatingBanner />
|
||||||
{children}
|
{children}
|
||||||
<BuyCtaDialog />
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</Providers>
|
</Providers>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
import { db } from "@turbostarter/db/server";
|
||||||
|
import { blueprintOrder } from "@turbostarter/db/schema";
|
||||||
|
|
||||||
const BLUEPRINT_PRICE_CENTS = 4700;
|
const BLUEPRINT_PRICE_CENTS = 4700;
|
||||||
const CURRENCY = "eur";
|
const CURRENCY = "eur";
|
||||||
|
|
||||||
@@ -16,10 +20,11 @@ function getStripe(): Stripe {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { email, businessName, placeId, locale } = body as {
|
const { email, businessName, placeId, mapsUrl, locale } = body as {
|
||||||
email: string;
|
email: string;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
placeId: string;
|
placeId: string;
|
||||||
|
mapsUrl?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,10 +35,87 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripe();
|
const origin =
|
||||||
const origin = request.headers.get("origin") || "";
|
request.headers.get("origin") ||
|
||||||
|
process.env.NEXT_PUBLIC_URL ||
|
||||||
|
"http://localhost:3000";
|
||||||
const lang = locale || "en";
|
const lang = locale || "en";
|
||||||
|
|
||||||
|
// ── Bypass Stripe: create order + trigger engine directly ──
|
||||||
|
if (process.env.BYPASS_STRIPE === "true") {
|
||||||
|
const [order] = await db
|
||||||
|
.insert(blueprintOrder)
|
||||||
|
.values({
|
||||||
|
email,
|
||||||
|
businessName,
|
||||||
|
placeId: placeId || null,
|
||||||
|
mapsUrl: mapsUrl || null,
|
||||||
|
language: locale || "en",
|
||||||
|
stripeSessionId: `test_${Date.now()}`,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: "Failed to create order" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const engineUrl = process.env.ENGINE_API_URL;
|
||||||
|
const callbackSecret = process.env.ENGINE_CALLBACK_SECRET;
|
||||||
|
|
||||||
|
if (!engineUrl || !callbackSecret) {
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({ status: "failed", errorMessage: "ENGINE_API_URL or ENGINE_CALLBACK_SECRET not set" })
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Engine not configured" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = process.env.NEXT_PUBLIC_URL || origin;
|
||||||
|
const fulfillUrl =
|
||||||
|
mapsUrl ||
|
||||||
|
`https://www.google.com/maps/place/?q=place_id:${placeId}`;
|
||||||
|
|
||||||
|
const fulfillRes = await fetch(`${engineUrl}/api/blueprint/fulfill`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: fulfillUrl,
|
||||||
|
business_name: businessName,
|
||||||
|
place_id: placeId,
|
||||||
|
email,
|
||||||
|
language: locale || "en",
|
||||||
|
callback_url: `${publicUrl}/api/webhooks/engine`,
|
||||||
|
callback_secret: callbackSecret,
|
||||||
|
order_id: order.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fulfillRes.ok) {
|
||||||
|
const data = (await fulfillRes.json()) as { job_id: string };
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({ status: "processing", engineJobId: data.job_id })
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
} else {
|
||||||
|
const errText = await fulfillRes.text();
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({ status: "failed", errorMessage: errText })
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to success page with the order ID as session_id
|
||||||
|
const successUrl = `${origin}/${lang}/get-started?step=success&session_id=${order.id}`;
|
||||||
|
return NextResponse.json({ url: successUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normal Stripe checkout ──
|
||||||
|
const stripe = getStripe();
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
payment_method_types: ["card"],
|
payment_method_types: ["card"],
|
||||||
@@ -56,6 +138,8 @@ export async function POST(request: NextRequest) {
|
|||||||
business_name: businessName,
|
business_name: businessName,
|
||||||
place_id: placeId || "",
|
place_id: placeId || "",
|
||||||
email,
|
email,
|
||||||
|
maps_url: mapsUrl || "",
|
||||||
|
language: locale || "en",
|
||||||
},
|
},
|
||||||
success_url: `${origin}/${lang}/get-started?step=success&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${origin}/${lang}/get-started?step=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${origin}/${lang}/get-started?step=payment`,
|
cancel_url: `${origin}/${lang}/get-started?step=payment`,
|
||||||
|
|||||||
125
apps/web/src/app/api/webhooks/stripe-blueprint/route.ts
Normal file
125
apps/web/src/app/api/webhooks/stripe-blueprint/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
import { db } from "@turbostarter/db/server";
|
||||||
|
import { blueprintOrder } from "@turbostarter/db/schema";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const stripe = getStripe();
|
||||||
|
const body = await request.text();
|
||||||
|
const signature = request.headers.get("stripe-signature");
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
return NextResponse.json({ error: "No signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookSecret = process.env.STRIPE_BLUEPRINT_WEBHOOK_SECRET || process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook signature verification failed:", err);
|
||||||
|
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "checkout.session.completed") {
|
||||||
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
|
|
||||||
|
// Only handle blueprint purchases
|
||||||
|
if (session.metadata?.product !== "blueprint") {
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { business_name, place_id, email, maps_url, language } = session.metadata;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create blueprint order
|
||||||
|
const [order] = await db
|
||||||
|
.insert(blueprintOrder)
|
||||||
|
.values({
|
||||||
|
email: email || session.customer_email || "",
|
||||||
|
businessName: business_name || "Unknown",
|
||||||
|
placeId: place_id || null,
|
||||||
|
mapsUrl: maps_url || null,
|
||||||
|
language: language || "en",
|
||||||
|
stripeSessionId: session.id,
|
||||||
|
stripePaymentIntentId:
|
||||||
|
typeof session.payment_intent === "string"
|
||||||
|
? session.payment_intent
|
||||||
|
: session.payment_intent?.id || null,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.error("Failed to create blueprint order");
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger engine fulfillment
|
||||||
|
const engineUrl = process.env.ENGINE_API_URL;
|
||||||
|
const callbackSecret = process.env.ENGINE_CALLBACK_SECRET;
|
||||||
|
const publicUrl = process.env.NEXT_PUBLIC_URL || "https://whyrating.com";
|
||||||
|
|
||||||
|
if (!engineUrl || !callbackSecret) {
|
||||||
|
console.error("ENGINE_API_URL or ENGINE_CALLBACK_SECRET not configured");
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({ status: "failed", errorMessage: "Engine not configured" })
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulfillUrl = maps_url || `https://www.google.com/maps/place/?q=place_id:${place_id}`;
|
||||||
|
|
||||||
|
const fulfillResponse = await fetch(`${engineUrl}/api/blueprint/fulfill`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: fulfillUrl,
|
||||||
|
business_name,
|
||||||
|
place_id,
|
||||||
|
email: email || session.customer_email,
|
||||||
|
language: language || "en",
|
||||||
|
callback_url: `${publicUrl}/api/webhooks/engine`,
|
||||||
|
callback_secret: callbackSecret,
|
||||||
|
order_id: order.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fulfillResponse.ok) {
|
||||||
|
const errText = await fulfillResponse.text();
|
||||||
|
console.error("Engine fulfill failed:", errText);
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({ status: "failed", errorMessage: `Engine error: ${errText}` })
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
} else {
|
||||||
|
const fulfillData = (await fulfillResponse.json()) as { job_id: string };
|
||||||
|
await db
|
||||||
|
.update(blueprintOrder)
|
||||||
|
.set({
|
||||||
|
status: "processing",
|
||||||
|
engineJobId: fulfillData.job_id,
|
||||||
|
})
|
||||||
|
.where(eq(blueprintOrder.id, order.id));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Blueprint order creation failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 33 KiB |
@@ -34,7 +34,7 @@ export const BaseLayout = ({ children, locale }: BaseLayoutProps) => {
|
|||||||
<html lang={locale} suppressHydrationWarning className={cn(sans.variable, wordmark.variable, mono.variable)}>
|
<html lang={locale} suppressHydrationWarning className={cn(sans.variable, wordmark.variable, mono.variable)}>
|
||||||
<body
|
<body
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center font-sans antialiased"
|
className="bg-background text-foreground flex min-h-screen w-full flex-col overflow-x-hidden font-sans antialiased"
|
||||||
data-theme={appConfig.theme.color}
|
data-theme={appConfig.theme.color}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ComponentProps } from "react";
|
|||||||
type TurboLinkProps = ComponentProps<typeof Link>;
|
type TurboLinkProps = ComponentProps<typeof Link>;
|
||||||
|
|
||||||
export const TurboLink = ({
|
export const TurboLink = ({
|
||||||
|
onClick,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onTouchStart,
|
onTouchStart,
|
||||||
@@ -25,10 +26,26 @@ export const TurboLink = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
onClick?.(e);
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
|
||||||
|
const hash = strHref?.split("#")[1];
|
||||||
|
if (hash) {
|
||||||
|
const el = document.getElementById(hash);
|
||||||
|
if (el) {
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollIntoView({ behavior: "smooth" });
|
||||||
|
window.history.pushState(null, "", `#${hash}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
{...props}
|
{...props}
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
|
onClick={handleClick}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
conditionalPrefetch();
|
conditionalPrefetch();
|
||||||
onMouseEnter?.(e);
|
onMouseEnter?.(e);
|
||||||
|
|||||||
@@ -3,90 +3,78 @@ import { cn } from "@turbostarter/ui";
|
|||||||
interface WhyRatingLogoProps {
|
interface WhyRatingLogoProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
|
wordmarkClassName?: string;
|
||||||
showWordmark?: boolean;
|
showWordmark?: boolean;
|
||||||
|
colorScheme?: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
className={cn("h-8 w-8", className)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="whyrating-clip">
|
||||||
|
<circle cx="60" cy="62" r="21" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<polygon
|
||||||
|
points="60,15 71.5,42 101,46 79.5,66 85,95 60,82 35,95 40.5,66 19,46 48.5,42"
|
||||||
|
fill="#FBBC05"
|
||||||
|
stroke="#FBBC05"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<g>
|
||||||
|
<line
|
||||||
|
x1="83"
|
||||||
|
y1="81"
|
||||||
|
x2="95"
|
||||||
|
y2="91"
|
||||||
|
stroke="#1E293B"
|
||||||
|
strokeWidth="9"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx="60" cy="62" r="27" fill="#1E293B" />
|
||||||
|
<circle cx="60" cy="62" r="21" fill="#FEF3C7" />
|
||||||
|
<g clipPath="url(#whyrating-clip)">
|
||||||
|
<rect x="42" y="58" width="11" height="35" rx="1.5" ry="1.5" fill="#86EFAC" />
|
||||||
|
<rect x="55" y="51" width="11" height="42" rx="1.5" ry="1.5" fill="#22C55E" />
|
||||||
|
<rect x="68" y="44" width="11" height="49" rx="1.5" ry="1.5" fill="#15803D" />
|
||||||
|
</g>
|
||||||
|
<rect x="68" y="44" width="11" height="18" rx="1.5" ry="1.5" fill="#15803D" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhyRatingLogo({
|
export function WhyRatingLogo({
|
||||||
className,
|
className,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
|
wordmarkClassName,
|
||||||
showWordmark = true,
|
showWordmark = true,
|
||||||
|
colorScheme = "light",
|
||||||
}: WhyRatingLogoProps) {
|
}: WhyRatingLogoProps) {
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
<svg
|
<LogoIcon className={iconClassName} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 120 120"
|
|
||||||
className={cn("h-8 w-8", iconClassName)}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="whyrating-clip">
|
|
||||||
<circle cx="60" cy="62" r="21" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<polygon
|
|
||||||
points="60,15 71.5,42 101,46 79.5,66 85,95 60,82 35,95 40.5,66 19,46 48.5,42"
|
|
||||||
fill="#FBBC05"
|
|
||||||
stroke="#FBBC05"
|
|
||||||
strokeWidth="6"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
<g>
|
|
||||||
<line
|
|
||||||
x1="83"
|
|
||||||
y1="81"
|
|
||||||
x2="95"
|
|
||||||
y2="91"
|
|
||||||
stroke="#1E293B"
|
|
||||||
strokeWidth="9"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="60" cy="62" r="27" fill="#1E293B" />
|
|
||||||
<circle cx="60" cy="62" r="21" fill="#FEF3C7" />
|
|
||||||
<g clipPath="url(#whyrating-clip)">
|
|
||||||
<rect
|
|
||||||
x="42"
|
|
||||||
y="58"
|
|
||||||
width="11"
|
|
||||||
height="35"
|
|
||||||
rx="1.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#86EFAC"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x="55"
|
|
||||||
y="51"
|
|
||||||
width="11"
|
|
||||||
height="42"
|
|
||||||
rx="1.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#22C55E"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x="68"
|
|
||||||
y="44"
|
|
||||||
width="11"
|
|
||||||
height="49"
|
|
||||||
rx="1.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#15803D"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<rect
|
|
||||||
x="68"
|
|
||||||
y="44"
|
|
||||||
width="11"
|
|
||||||
height="18"
|
|
||||||
rx="1.5"
|
|
||||||
ry="1.5"
|
|
||||||
fill="#15803D"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
{showWordmark && (
|
{showWordmark && (
|
||||||
<span className="font-wordmark font-bold text-foreground text-xl">
|
<span
|
||||||
whyrating.com
|
className={cn(
|
||||||
|
"font-wordmark font-bold text-xl",
|
||||||
|
isDark ? "text-zinc-50" : "text-foreground",
|
||||||
|
wordmarkClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
whyrating<span className="text-amber-500">.com</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { LogoIcon };
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "@turbostarter/i18n";
|
import { useTranslation } from "@turbostarter/i18n";
|
||||||
import { cn } from "@turbostarter/ui";
|
import { cn } from "@turbostarter/ui";
|
||||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||||
@@ -8,51 +10,216 @@ interface StepSuccessProps {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OrderStatus {
|
||||||
|
orderId: string;
|
||||||
|
status: "pending" | "processing" | "completed" | "failed";
|
||||||
|
businessName: string;
|
||||||
|
reputationScore: number | null;
|
||||||
|
reviewsCount: number | null;
|
||||||
|
pdfUrl: string | null;
|
||||||
|
executionId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES = [
|
||||||
|
{ key: "scrape", label: "Collecting reviews" },
|
||||||
|
{ key: "classify", label: "Analyzing sentiment" },
|
||||||
|
{ key: "synthesize", label: "Generating report" },
|
||||||
|
{ key: "done", label: "Complete" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getStageIndex(status: string): number {
|
||||||
|
if (status === "pending") return 0;
|
||||||
|
if (status === "processing") return 1;
|
||||||
|
if (status === "completed") return 3;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
export const StepSuccess = ({ email }: StepSuccessProps) => {
|
export const StepSuccess = ({ email }: StepSuccessProps) => {
|
||||||
const { t } = useTranslation("marketing");
|
const { t } = useTranslation("marketing");
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const sessionId = searchParams.get("session_id");
|
||||||
|
|
||||||
|
const [orderStatus, setOrderStatus] = useState<OrderStatus | null>(null);
|
||||||
|
const [polling, setPolling] = useState(true);
|
||||||
|
|
||||||
|
const pollStatus = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/blueprint/status/${sessionId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as OrderStatus;
|
||||||
|
setOrderStatus(data);
|
||||||
|
|
||||||
|
if (data.status === "completed" || data.status === "failed") {
|
||||||
|
setPolling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently retry on next interval
|
||||||
|
}
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pollStatus();
|
||||||
|
|
||||||
|
if (!polling) return;
|
||||||
|
|
||||||
|
const interval = setInterval(pollStatus, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [pollStatus, polling]);
|
||||||
|
|
||||||
|
const isCompleted = orderStatus?.status === "completed";
|
||||||
|
const isFailed = orderStatus?.status === "failed";
|
||||||
|
const stageIndex = orderStatus ? getStageIndex(orderStatus.status) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-lg flex-col items-center gap-8">
|
<div className="flex w-full max-w-lg flex-col items-center gap-8">
|
||||||
{/* Success icon */}
|
{/* Header icon */}
|
||||||
<div className="flex size-16 items-center justify-center rounded-full bg-green-500/10">
|
<div
|
||||||
<svg
|
className={cn(
|
||||||
className="size-8 text-green-500"
|
"flex size-16 items-center justify-center rounded-full",
|
||||||
fill="none"
|
isCompleted
|
||||||
viewBox="0 0 24 24"
|
? "bg-green-500/10"
|
||||||
stroke="currentColor"
|
: isFailed
|
||||||
strokeWidth={2}
|
? "bg-red-500/10"
|
||||||
>
|
: "bg-blue-500/10",
|
||||||
<path
|
)}
|
||||||
strokeLinecap="round"
|
>
|
||||||
strokeLinejoin="round"
|
{isCompleted ? (
|
||||||
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
|
||||||
/>
|
className="size-8 text-green-500"
|
||||||
</svg>
|
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>
|
||||||
|
) : isFailed ? (
|
||||||
|
<svg
|
||||||
|
className="size-8 text-red-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="border-primary size-8 animate-spin rounded-full border-3 border-t-transparent" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
|
<h2 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
{t("wizard.success.title")}
|
{isCompleted
|
||||||
|
? t("wizard.success.readyTitle")
|
||||||
|
: isFailed
|
||||||
|
? t("wizard.success.failedTitle")
|
||||||
|
: t("wizard.success.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{t("wizard.success.description", {
|
{isCompleted
|
||||||
email: email || "your email",
|
? t("wizard.success.readyDescription", {
|
||||||
})}
|
email: email || "your email",
|
||||||
|
})
|
||||||
|
: isFailed
|
||||||
|
? orderStatus?.error || "We encountered an error processing your report."
|
||||||
|
: t("wizard.success.description", {
|
||||||
|
email: email || "your email",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processing indicator */}
|
{/* Score highlight (when complete) */}
|
||||||
<div className="bg-background w-full rounded-xl border p-6">
|
{isCompleted && orderStatus?.reputationScore && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="bg-background flex w-full flex-col items-center rounded-xl border p-6">
|
||||||
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
|
<span className="text-muted-foreground text-sm">Reputation Score</span>
|
||||||
<span className="text-foreground text-sm font-medium">
|
<span className="text-foreground mt-1 text-5xl font-bold">
|
||||||
{t("wizard.success.processing")}
|
{orderStatus.reputationScore}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">/100</span>
|
||||||
|
{orderStatus.reviewsCount && (
|
||||||
|
<span className="text-muted-foreground mt-2 text-xs">
|
||||||
|
Based on {orderStatus.reviewsCount} reviews
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-3 text-xs">
|
)}
|
||||||
{t("wizard.success.delivery")}
|
|
||||||
</p>
|
{/* Progress stages (when processing) */}
|
||||||
</div>
|
{!isCompleted && !isFailed && (
|
||||||
|
<div className="bg-background w-full rounded-xl border p-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{STAGES.map((stage, i) => {
|
||||||
|
const isActive = i === stageIndex || (i === 1 && stageIndex < 3);
|
||||||
|
const isDone = i < stageIndex || (isCompleted && i <= 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage.key} className="flex items-center gap-3">
|
||||||
|
{isDone ? (
|
||||||
|
<svg
|
||||||
|
className="size-5 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.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : isActive ? (
|
||||||
|
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted size-5 rounded-full" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
isDone
|
||||||
|
? "text-muted-foreground line-through"
|
||||||
|
: isActive
|
||||||
|
? "text-foreground font-medium"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stage.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-4 text-xs">
|
||||||
|
{t("wizard.success.delivery")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download / Dashboard CTAs (when complete) */}
|
||||||
|
{isCompleted && orderStatus?.pdfUrl && (
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<a
|
||||||
|
href={orderStatus.pdfUrl}
|
||||||
|
className={cn(buttonVariants({ size: "lg" }), "w-full text-center")}
|
||||||
|
>
|
||||||
|
Download Report (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create account section */}
|
{/* Create account section */}
|
||||||
<div className="bg-background w-full rounded-xl border p-6">
|
<div className="bg-background w-full rounded-xl border p-6">
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export const ReportFan = () => {
|
|||||||
<div className="animate-fade-in relative mx-auto mt-12 -translate-y-4 opacity-0 [--animation-delay:800ms] sm:mt-16 md:mt-20">
|
<div className="animate-fade-in relative mx-auto mt-12 -translate-y-4 opacity-0 [--animation-delay:800ms] sm:mt-16 md:mt-20">
|
||||||
{/* Ambient glow behind the fan */}
|
{/* Ambient glow behind the fan */}
|
||||||
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
||||||
<div className="absolute top-1/2 left-1/2 h-[500px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
<div className="absolute top-1/2 left-1/2 h-[500px] w-full max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
||||||
<div className="absolute top-1/3 left-1/3 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
<div className="absolute top-1/3 left-1/3 h-[300px] w-full max-w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fan container with perspective */}
|
{/* Fan container with perspective */}
|
||||||
@@ -15,13 +15,13 @@ export const ReportFan = () => {
|
|||||||
style={{ perspective: "1200px" }}
|
style={{ perspective: "1200px" }}
|
||||||
>
|
>
|
||||||
{/* Floating grid dots decoration */}
|
{/* Floating grid dots decoration */}
|
||||||
<div className="pointer-events-none absolute -top-8 -right-12 h-24 w-24 opacity-[0.04]"
|
<div className="pointer-events-none absolute -top-8 -right-12 hidden h-24 w-24 opacity-[0.04] sm:block"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
||||||
backgroundSize: "8px 8px",
|
backgroundSize: "8px 8px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute -bottom-6 -left-10 h-20 w-20 opacity-[0.04]"
|
<div className="pointer-events-none absolute -bottom-6 -left-10 hidden h-20 w-20 opacity-[0.04] sm:block"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
||||||
backgroundSize: "8px 8px",
|
backgroundSize: "8px 8px",
|
||||||
|
|||||||
@@ -580,8 +580,8 @@ export const ReportPreview = () => {
|
|||||||
<div className="relative w-full max-w-3xl">
|
<div className="relative w-full max-w-3xl">
|
||||||
{/* Ambient glow */}
|
{/* Ambient glow */}
|
||||||
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
<div className="pointer-events-none absolute inset-0 -bottom-10">
|
||||||
<div className="absolute top-1/2 left-1/2 h-[400px] w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
<div className="absolute top-1/2 left-1/2 h-[400px] w-full max-w-[500px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/8 blur-[100px]" />
|
||||||
<div className="absolute top-1/3 left-1/3 h-[250px] w-[250px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
<div className="absolute top-1/3 left-1/3 h-[250px] w-full max-w-[250px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useTranslation } from "@turbostarter/i18n";
|
import { useTranslation } from "@turbostarter/i18n";
|
||||||
import { cn } from "@turbostarter/ui";
|
import { cn } from "@turbostarter/ui";
|
||||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||||
@@ -33,9 +35,27 @@ const links = [
|
|||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { t } = useTranslation("marketing");
|
const { t } = useTranslation("marketing");
|
||||||
|
const [hidden, setHidden] = useState(false);
|
||||||
|
const lastY = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const y = window.scrollY;
|
||||||
|
// Only hide after scrolling past 100px, show immediately on scroll up
|
||||||
|
setHidden(y > 100 && y > lastY.current);
|
||||||
|
lastY.current = y;
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/80 sticky inset-0 top-[var(--banner-height)] z-40 w-full py-3 backdrop-blur-sm">
|
<header
|
||||||
|
className={cn(
|
||||||
|
"bg-background sticky top-[var(--banner-height)] z-40 w-full py-3 transition-transform duration-300 lg:bg-background/80 lg:backdrop-blur-sm",
|
||||||
|
hidden && "-translate-y-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-6 pr-4 sm:container">
|
<div className="flex items-center justify-between px-6 pr-4 sm:container">
|
||||||
<TurboLink
|
<TurboLink
|
||||||
href={pathsConfig.index}
|
href={pathsConfig.index}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if (typeof window !== "undefined" && isValidPosthogConfig) {
|
|||||||
person_profiles: "always",
|
person_profiles: "always",
|
||||||
capture_pageview: false,
|
capture_pageview: false,
|
||||||
disable_external_dependency_loading: true,
|
disable_external_dependency_loading: true,
|
||||||
disable_session_recording: true,
|
disable_session_recording: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -440,6 +440,9 @@
|
|||||||
"success": {
|
"success": {
|
||||||
"title": "Your Blueprint is on the way!",
|
"title": "Your Blueprint is on the way!",
|
||||||
"description": "We're analyzing your reviews now. Your Reputation Blueprint will be delivered to {{email}} within 24 hours.",
|
"description": "We're analyzing your reviews now. Your Reputation Blueprint will be delivered to {{email}} within 24 hours.",
|
||||||
|
"readyTitle": "Your Report is Ready!",
|
||||||
|
"readyDescription": "We've sent the report to {{email}}",
|
||||||
|
"failedTitle": "Something went wrong",
|
||||||
"processing": "Analyzing reviews...",
|
"processing": "Analyzing reviews...",
|
||||||
"createAccount": "Create an account",
|
"createAccount": "Create an account",
|
||||||
"createAccountDescription": "Track your report status and download it when ready.",
|
"createAccountDescription": "Track your report status and download it when ready.",
|
||||||
|
|||||||
@@ -440,6 +440,9 @@
|
|||||||
"success": {
|
"success": {
|
||||||
"title": "¡Tu Radiografía está en camino!",
|
"title": "¡Tu Radiografía está en camino!",
|
||||||
"description": "Estamos analizando tus reseñas. Tu Radiografía de Reputación se entregará a {{email}} en 24 horas.",
|
"description": "Estamos analizando tus reseñas. Tu Radiografía de Reputación se entregará a {{email}} en 24 horas.",
|
||||||
|
"readyTitle": "¡Tu Informe está Listo!",
|
||||||
|
"readyDescription": "Hemos enviado el informe a {{email}}",
|
||||||
|
"failedTitle": "Algo salió mal",
|
||||||
"processing": "Analizando reseñas...",
|
"processing": "Analizando reseñas...",
|
||||||
"createAccount": "Crear una cuenta",
|
"createAccount": "Crear una cuenta",
|
||||||
"createAccountDescription": "Sigue el estado de tu informe y descárgalo cuando esté listo.",
|
"createAccountDescription": "Sigue el estado de tu informe y descárgalo cuando esté listo.",
|
||||||
|
|||||||
@@ -87,11 +87,28 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
overscroll-behavior: none;
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
overscroll-behavior: none;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
Reference in New Issue
Block a user