Compare commits

...

10 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
6d92124211 feat: hide header on scroll down, show on scroll up
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Better UX for content-heavy landing page — maximizes viewport
while keeping navigation instantly accessible on scroll up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:00:04 +00:00
Alejandro Gutiérrez
c642218bf8 fix: add missing i18n keys for wizard success states
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:45:34 +00:00
Alejandro Gutiérrez
3eb38b75f9 fix: guard against undefined order in stripe webhook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:30:10 +00:00
Alejandro Gutiérrez
0c66bab042 fix: guard against undefined order in blueprint checkout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:20:07 +00:00
Alejandro Gutiérrez
b82358a934 fix: eliminate mobile horizontal overflow and right-side blank space
Use overflow-x: hidden + max-width: 100% on html/body to prevent
mobile viewport expansion. Cap decorative glow blobs with max-w
instead of fixed widths, and hide decorative dots on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:47:10 +00:00
Alejandro Gutiérrez
f04e8639da fix: replace TurboStarter OG image with WhyRating branding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:37:28 +00:00
Alejandro Gutiérrez
99ec41bd87 fix: improve mobile scroll performance and accessibility
Remove global smooth scroll (caused laggy feel), use solid header bg on
mobile instead of backdrop-blur, respect prefers-reduced-motion, and add
targeted smooth scroll only for anchor nav clicks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:55:24 +00:00
Alejandro Gutiérrez
35779e45d9 fix: use overflow-x-clip on body to prevent horizontal overflow without breaking scroll
- Remove overflow-x-hidden from html (caused vertical scroll issues via CSS spec quirk)
- Remove items-center/justify-center from body (TurboStarter auth defaults breaking full-width layout)
- Use overflow-x-clip on body instead — clips overflowing glow elements without creating a scroll container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:28:09 +00:00
Alejandro Gutiérrez
b92dfecac4 fix: remove disruptive TurboStarter promo dialog and fix horizontal overflow
- Remove BuyCtaDialog import/usage from root layout (TurboStarter boilerplate promo)
- Add overflow-x-hidden on html and body to prevent right-side empty space
- Add w-full on body for proper full-width stretching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:15:46 +00:00
Alejandro Gutiérrez
c7ee5ce269 feat: enable PostHog session replay and fix checkout origin fallback
- Enable session recording in PostHog provider (was disabled by default)
- Add origin URL fallback in checkout route to prevent Stripe "Not a valid URL" error
- Refactor logo component to extract LogoIcon and add color scheme support
- Add .moat/ to gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:48:38 +00:00
16 changed files with 541 additions and 116 deletions

3
.gitignore vendored
View File

@@ -67,3 +67,6 @@ dist/
# Auto Claude data directory # Auto Claude data directory
.auto-claude/ .auto-claude/
# Moat task system
.moat/

View File

@@ -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>

View File

@@ -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`,

View 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

View File

@@ -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}

View File

@@ -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);

View File

@@ -3,20 +3,17 @@ 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";
} }
export function WhyRatingLogo({ function LogoIcon({ className }: { className?: string }) {
className,
iconClassName,
showWordmark = true,
}: WhyRatingLogoProps) {
return ( return (
<div className={cn("flex items-center gap-2", className)}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 120 120" viewBox="0 0 120 120"
className={cn("h-8 w-8", iconClassName)} className={cn("h-8 w-8", className)}
> >
<defs> <defs>
<clipPath id="whyrating-clip"> <clipPath id="whyrating-clip">
@@ -43,50 +40,41 @@ export function WhyRatingLogo({
<circle cx="60" cy="62" r="27" fill="#1E293B" /> <circle cx="60" cy="62" r="27" fill="#1E293B" />
<circle cx="60" cy="62" r="21" fill="#FEF3C7" /> <circle cx="60" cy="62" r="21" fill="#FEF3C7" />
<g clipPath="url(#whyrating-clip)"> <g clipPath="url(#whyrating-clip)">
<rect <rect x="42" y="58" width="11" height="35" rx="1.5" ry="1.5" fill="#86EFAC" />
x="42" <rect x="55" y="51" width="11" height="42" rx="1.5" ry="1.5" fill="#22C55E" />
y="58" <rect x="68" y="44" width="11" height="49" rx="1.5" ry="1.5" fill="#15803D" />
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> </g>
<rect <rect x="68" y="44" width="11" height="18" rx="1.5" ry="1.5" fill="#15803D" />
x="68"
y="44"
width="11"
height="18"
rx="1.5"
ry="1.5"
fill="#15803D"
/>
</g> </g>
</svg> </svg>
);
}
export function WhyRatingLogo({
className,
iconClassName,
wordmarkClassName,
showWordmark = true,
colorScheme = "light",
}: WhyRatingLogoProps) {
const isDark = colorScheme === "dark";
return (
<div className={cn("flex items-center gap-2", className)}>
<LogoIcon className={iconClassName} />
{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 };

View File

@@ -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,13 +10,84 @@ 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
className={cn(
"flex size-16 items-center justify-center rounded-full",
isCompleted
? "bg-green-500/10"
: isFailed
? "bg-red-500/10"
: "bg-blue-500/10",
)}
>
{isCompleted ? (
<svg <svg
className="size-8 text-green-500" className="size-8 text-green-500"
fill="none" fill="none"
@@ -28,31 +101,125 @@ export const StepSuccess = ({ email }: StepSuccessProps) => {
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 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> </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
? 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", email: email || "your email",
})} })}
</p> </p>
</div> </div>
{/* Processing indicator */} {/* Score highlight (when complete) */}
{isCompleted && orderStatus?.reputationScore && (
<div className="bg-background flex w-full flex-col items-center rounded-xl border p-6">
<span className="text-muted-foreground text-sm">Reputation Score</span>
<span className="text-foreground mt-1 text-5xl font-bold">
{orderStatus.reputationScore}
</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>
)}
{/* Progress stages (when processing) */}
{!isCompleted && !isFailed && (
<div className="bg-background w-full rounded-xl border p-6"> <div className="bg-background w-full rounded-xl border p-6">
<div className="flex items-center gap-3"> <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="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")} <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> </span>
</div> </div>
<p className="text-muted-foreground mt-3 text-xs"> );
})}
</div>
<p className="text-muted-foreground mt-4 text-xs">
{t("wizard.success.delivery")} {t("wizard.success.delivery")}
</p> </p>
</div> </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">

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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}

View File

@@ -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,
}); });
} }

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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 {