Compare commits
10 Commits
49edf70235
...
6d92124211
| 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/
|
||||
|
||||
# Moat task system
|
||||
.moat/
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Providers } from "~/lib/providers/providers";
|
||||
import { ImpersonatingBanner } from "~/modules/admin/users/user/impersonating-banner";
|
||||
import { BaseLayout } from "~/modules/common/layout/base";
|
||||
import { Toaster } from "~/modules/common/toast";
|
||||
import { BuyCtaDialog } from "~/modules/marketing/layout/buy-cta-dialog";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return config.locales.map((locale) => ({ locale }));
|
||||
@@ -33,7 +32,6 @@ export default async function RootLayout({
|
||||
<Providers locale={locale}>
|
||||
<ImpersonatingBanner />
|
||||
{children}
|
||||
<BuyCtaDialog />
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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";
|
||||
|
||||
const BLUEPRINT_PRICE_CENTS = 4700;
|
||||
const CURRENCY = "eur";
|
||||
|
||||
@@ -16,10 +20,11 @@ function getStripe(): Stripe {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, businessName, placeId, locale } = body as {
|
||||
const { email, businessName, placeId, mapsUrl, locale } = body as {
|
||||
email: string;
|
||||
businessName: string;
|
||||
placeId: string;
|
||||
mapsUrl?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
@@ -30,10 +35,87 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const origin = request.headers.get("origin") || "";
|
||||
const origin =
|
||||
request.headers.get("origin") ||
|
||||
process.env.NEXT_PUBLIC_URL ||
|
||||
"http://localhost:3000";
|
||||
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({
|
||||
mode: "payment",
|
||||
payment_method_types: ["card"],
|
||||
@@ -56,6 +138,8 @@ export async function POST(request: NextRequest) {
|
||||
business_name: businessName,
|
||||
place_id: placeId || "",
|
||||
email,
|
||||
maps_url: mapsUrl || "",
|
||||
language: locale || "en",
|
||||
},
|
||||
success_url: `${origin}/${lang}/get-started?step=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
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)}>
|
||||
<body
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ComponentProps } from "react";
|
||||
type TurboLinkProps = ComponentProps<typeof Link>;
|
||||
|
||||
export const TurboLink = ({
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onPointerEnter,
|
||||
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 (
|
||||
<Link
|
||||
{...props}
|
||||
prefetch={false}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={(e) => {
|
||||
conditionalPrefetch();
|
||||
onMouseEnter?.(e);
|
||||
|
||||
@@ -3,90 +3,78 @@ import { cn } from "@turbostarter/ui";
|
||||
interface WhyRatingLogoProps {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
wordmarkClassName?: string;
|
||||
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({
|
||||
className,
|
||||
iconClassName,
|
||||
wordmarkClassName,
|
||||
showWordmark = true,
|
||||
colorScheme = "light",
|
||||
}: WhyRatingLogoProps) {
|
||||
const isDark = colorScheme === "dark";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<svg
|
||||
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>
|
||||
<LogoIcon className={iconClassName} />
|
||||
{showWordmark && (
|
||||
<span className="font-wordmark font-bold text-foreground text-xl">
|
||||
whyrating.com
|
||||
<span
|
||||
className={cn(
|
||||
"font-wordmark font-bold text-xl",
|
||||
isDark ? "text-zinc-50" : "text-foreground",
|
||||
wordmarkClassName,
|
||||
)}
|
||||
>
|
||||
whyrating<span className="text-amber-500">.com</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { LogoIcon };
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
@@ -8,51 +10,216 @@ interface StepSuccessProps {
|
||||
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) => {
|
||||
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 (
|
||||
<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>
|
||||
{/* Header icon */}
|
||||
<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
|
||||
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>
|
||||
) : 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>
|
||||
|
||||
{/* Title */}
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t("wizard.success.description", {
|
||||
email: email || "your email",
|
||||
})}
|
||||
{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",
|
||||
})}
|
||||
</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")}
|
||||
{/* 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>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("wizard.success.delivery")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress stages (when processing) */}
|
||||
{!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 */}
|
||||
<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">
|
||||
{/* Ambient glow behind the fan */}
|
||||
<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/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/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-full max-w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Fan container with perspective */}
|
||||
@@ -15,13 +15,13 @@ export const ReportFan = () => {
|
||||
style={{ perspective: "1200px" }}
|
||||
>
|
||||
{/* 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={{
|
||||
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
||||
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={{
|
||||
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
|
||||
backgroundSize: "8px 8px",
|
||||
|
||||
@@ -580,8 +580,8 @@ export const ReportPreview = () => {
|
||||
<div className="relative w-full max-w-3xl">
|
||||
{/* Ambient glow */}
|
||||
<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/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/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-full max-w-[250px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/10 blur-[80px]" />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
@@ -33,9 +35,27 @@ const links = [
|
||||
|
||||
export const Header = () => {
|
||||
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 (
|
||||
<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">
|
||||
<TurboLink
|
||||
href={pathsConfig.index}
|
||||
|
||||
@@ -27,7 +27,7 @@ if (typeof window !== "undefined" && isValidPosthogConfig) {
|
||||
person_profiles: "always",
|
||||
capture_pageview: false,
|
||||
disable_external_dependency_loading: true,
|
||||
disable_session_recording: true,
|
||||
disable_session_recording: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -440,6 +440,9 @@
|
||||
"success": {
|
||||
"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.",
|
||||
"readyTitle": "Your Report is Ready!",
|
||||
"readyDescription": "We've sent the report to {{email}}",
|
||||
"failedTitle": "Something went wrong",
|
||||
"processing": "Analyzing reviews...",
|
||||
"createAccount": "Create an account",
|
||||
"createAccountDescription": "Track your report status and download it when ready.",
|
||||
|
||||
@@ -440,6 +440,9 @@
|
||||
"success": {
|
||||
"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.",
|
||||
"readyTitle": "¡Tu Informe está Listo!",
|
||||
"readyDescription": "Hemos enviado el informe a {{email}}",
|
||||
"failedTitle": "Algo salió mal",
|
||||
"processing": "Analizando reseñas...",
|
||||
"createAccount": "Crear una cuenta",
|
||||
"createAccountDescription": "Sigue el estado de tu informe y descárgalo cuando esté listo.",
|
||||
|
||||
@@ -87,11 +87,28 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user