feat: onboarding trial flow, Google sign-in, dark mode hotspot fixes

- Move trial creation from auth hook to dedicated /subscriptions/trial endpoint
  with eligibility checks (3-day Full Paket trial)
- Add animated onboarding progress (Remotion) with confetti on completion
- Register redirects to subscription page with welcome flow
- Add Google social login/signup support with config injection
- Improve hotspot overlay visibility in dark mode
- Show "Panele Git" on landing page when authenticated
- Turkish translations for API error messages
- Add toast utility wrapper, UUID generation for auth IDs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-15 14:53:57 +00:00
parent a5ee147675
commit 87d8eea298
50 changed files with 1116 additions and 298 deletions

View File

@@ -20,7 +20,7 @@ import {
Upload,
} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
interface Brand {
id: string;

View File

@@ -1,6 +1,18 @@
import { useSyncExternalStore } from "react";
import { useSchemaStore } from "@/stores/schema.store";
import type { Hotspot } from "@/hooks/use-parts";
function useIsDark() {
return useSyncExternalStore(
(cb) => {
const obs = new MutationObserver(cb);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => obs.disconnect();
},
() => document.documentElement.classList.contains("dark"),
);
}
interface HotspotOverlayProps {
hotspots: Hotspot[];
imageWidth: number;
@@ -11,6 +23,7 @@ function HotspotShape({
hotspot,
isHighlighted,
isSelected,
isDark,
onMouseEnter,
onMouseLeave,
onClick,
@@ -18,22 +31,23 @@ function HotspotShape({
hotspot: Hotspot;
isHighlighted: boolean;
isSelected: boolean;
isDark: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
onClick: () => void;
}) {
const fillOpacity = isSelected ? 0.35 : isHighlighted ? 0.25 : 0.08;
const fillOpacity = isSelected ? 0.4 : isHighlighted ? 0.3 : isDark ? 0.18 : 0.08;
const strokeColor = isSelected
? "#ef4444"
: isHighlighted
? "#3b82f6"
: "#6b7280";
? "#60a5fa"
: isDark ? "#a5b4fc" : "#6b7280";
const fillColor = isSelected
? "#ef4444"
: isHighlighted
? "#3b82f6"
: "#9ca3af";
const strokeWidth = isSelected || isHighlighted ? 2.5 : 1.5;
? "#60a5fa"
: isDark ? "#818cf8" : "#9ca3af";
const strokeWidth = isSelected || isHighlighted ? 2.5 : isDark ? 2 : 1.5;
const commonProps = {
fill: fillColor,
@@ -78,6 +92,7 @@ export function HotspotOverlay({
}: HotspotOverlayProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const isDark = useIsDark();
return (
<svg
@@ -90,12 +105,26 @@ export function HotspotOverlay({
const isHighlighted = highlightedGroup === hotspot.group;
const isSelected = selectedGroup === hotspot.group;
const labelX =
hotspot.shape === "circle"
? hotspot.coordinates[0]
: hotspot.shape === "rect"
? hotspot.coordinates[0] + hotspot.coordinates[2] / 2
: hotspot.coordinates[0];
const labelY =
hotspot.shape === "circle"
? hotspot.coordinates[1] - hotspot.coordinates[2] - 4
: hotspot.shape === "rect"
? hotspot.coordinates[1] - 4
: hotspot.coordinates[1] - 4;
return (
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
<HotspotShape
hotspot={hotspot}
isHighlighted={isHighlighted}
isSelected={isSelected}
isDark={isDark}
onMouseEnter={() => setHighlightedGroup(hotspot.group)}
onMouseLeave={() => setHighlightedGroup(null)}
onClick={() =>
@@ -105,27 +134,26 @@ export function HotspotOverlay({
}
/>
{(isHighlighted || isSelected) && hotspot.label && (
<text
x={
hotspot.shape === "circle"
? hotspot.coordinates[0]
: hotspot.shape === "rect"
? hotspot.coordinates[0] + hotspot.coordinates[2] / 2
: hotspot.coordinates[0]
}
y={
hotspot.shape === "circle"
? hotspot.coordinates[1] - hotspot.coordinates[2] - 4
: hotspot.shape === "rect"
? hotspot.coordinates[1] - 4
: hotspot.coordinates[1] - 4
}
textAnchor="middle"
className="pointer-events-none select-none fill-foreground text-xs font-medium"
style={{ fontSize: 12 }}
>
{hotspot.label}
</text>
<>
<text
x={labelX}
y={labelY}
textAnchor="middle"
className="pointer-events-none select-none text-xs font-medium"
style={{ fontSize: 12, stroke: isDark ? "#000" : "#fff", strokeWidth: 3, strokeLinejoin: "round", fill: isDark ? "#000" : "#fff" }}
>
{hotspot.label}
</text>
<text
x={labelX}
y={labelY}
textAnchor="middle"
className="pointer-events-none select-none text-xs font-medium"
style={{ fontSize: 12, fill: isDark ? "#e0e7ff" : "#1e293b" }}
>
{hotspot.label}
</text>
</>
)}
</g>
);

View File

@@ -21,7 +21,7 @@ import {
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
export function SettingsContent() {
const { t } = useTranslation();

View File

@@ -77,6 +77,19 @@
}
.animate-fade-in-up { animation: fade-in-up 0.3s ease-out; }
/* Sileo toast: deeper state colors for light mode */
:root {
--sileo-state-success: oklch(0.52 0.24 142);
--sileo-state-error: oklch(0.48 0.26 25);
--sileo-state-warning: oklch(0.58 0.2 70);
--sileo-state-info: oklch(0.50 0.2 237);
--sileo-state-loading: oklch(0.40 0 0);
}
[data-sileo-description] {
color: #333;
}
.dark {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
@@ -100,4 +113,20 @@
--color-card-foreground: #fafafa;
--color-popover: #0a0a0a;
--color-popover-foreground: #fafafa;
/* Sileo toast: dark pill/body, light text, subtler shadow */
--sileo-state-loading: oklch(0.7 0 0);
}
.dark [data-sileo-pill],
.dark [data-sileo-body] {
fill: #1c1c1e !important;
}
.dark [data-sileo-description] {
color: #d4d4d4;
}
.dark [data-sileo-toast] {
filter: drop-shadow(0 0 12px rgba(0, 0, 0, 0.4));
}

View File

@@ -30,7 +30,7 @@ class ApiClient {
if (!res.ok) {
throw new ApiError(
data?.error?.message || "Request failed",
data?.error?.message || "İstek başarısız",
data?.error?.code || "UNKNOWN",
res.status,
);
@@ -66,7 +66,7 @@ class ApiClient {
if (!res.ok) {
throw new ApiError(
data?.error?.message || "Upload failed",
data?.error?.message || "Yükleme başarısız",
data?.error?.code || "UNKNOWN",
res.status,
);

25
apps/web/src/lib/toast.ts Normal file
View File

@@ -0,0 +1,25 @@
import { sileo } from "sileo";
export { Toaster } from "sileo";
interface ToastOptions {
description?: string;
duration?: number;
position?:
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
}
export const toast = {
success: (title: string, opts?: ToastOptions) =>
sileo.success({ title, ...opts }),
error: (title: string, opts?: ToastOptions) =>
sileo.error({ title, ...opts }),
info: (title: string, opts?: ToastOptions) =>
sileo.info({ title, ...opts }),
warning: (title: string, opts?: ToastOptions) =>
sileo.warning({ title, ...opts }),
};

View File

@@ -118,11 +118,28 @@
},
"statusLabels": {
"active": "Active",
"trial": "Trial",
"pending": "Pending",
"cancelled": "Cancelled",
"expired": "Expired"
},
"popular": "Popular"
"popular": "Popular",
"trialTitle": "3-Day Full Package Trial",
"trialDescription": "Free access to all brands for 3 days. No credit card required.",
"startTrial": "Start Free Trial",
"trialStarted": "Your 3-day Full Package trial has started!",
"onboarding": {
"provisioning": "Setting up your free trial",
"step1": "Verifying account",
"step2": "Preparing catalogs",
"step3": "Activating Full Package",
"step4": "Completed!",
"completed": "You can test all catalogs without limits!",
"trialDuration": "3-Day Trial",
"startSearching": "Start Searching",
"error": "An error occurred while starting your trial.",
"retry": "Try Again"
}
},
"payment": {
"title": "Payment",

View File

@@ -118,11 +118,28 @@
},
"statusLabels": {
"active": "Aktif",
"trial": "Deneme",
"pending": "Bekliyor",
"cancelled": "İptal Edildi",
"expired": "Süresi Doldu"
},
"popular": "Popüler"
"popular": "Popüler",
"trialTitle": "3 Gün Full Paket Denemesi",
"trialDescription": "Tüm markalara 3 gün boyunca ücretsiz erişim. Kredi kartı gerekmez.",
"startTrial": "Ücretsiz Denemeyi Başlat",
"trialStarted": "3 günlük Full Paket denemeniz başlatıldı!",
"onboarding": {
"provisioning": "Ücretsiz kullanım hakkınız tanımlanıyor",
"step1": "Hesap doğrulanıyor",
"step2": "Kataloglar hazırlanıyor",
"step3": "Full Paket aktif ediliyor",
"step4": "Tamamlandı!",
"completed": "Tüm katalogları sınırsız test edebilirsiniz!",
"trialDuration": "3 Gün Deneme",
"startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene"
}
},
"payment": {
"title": "Ödeme",

View File

@@ -0,0 +1,238 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from "remotion";
function getColors(isDark: boolean) {
return {
bg: isDark ? "#1a1a1a" : "#f5f5f5",
fg: isDark ? "#fafafa" : "#0a0a0a",
muted: isDark ? "#262626" : "#e5e5e5",
mutedFg: isDark ? "#a3a3a3" : "#737373",
border: isDark ? "#333333" : "#d4d4d4",
emerald: "#10b981",
emeraldDark: "#059669",
surface: isDark ? "#1f1f1f" : "#ffffff",
trackBg: isDark ? "#262626" : "#e5e5e5",
checkBg: isDark ? "#1f1f1f" : "#ffffff",
};
}
const STEPS = [
{ label: "Hesap doğrulanıyor", endFrame: 60 },
{ label: "Kataloglar hazırlanıyor", endFrame: 120 },
{ label: "Full Paket aktif ediliyor", endFrame: 175 },
{ label: "Tamamlandı!", endFrame: 210 },
];
export const OnboardingProgress: React.FC<{
isDark: boolean;
stepLabels?: string[];
}> = ({ isDark, stepLabels }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
const labels = stepLabels ?? STEPS.map((s) => s.label);
// Overall progress 0→1 over 210 frames with ease-in acceleration
const rawProgress = interpolate(frame, [0, 210], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const progress = rawProgress * rawProgress * (3 - 2 * rawProgress); // smoothstep
const barWidth = 600;
const barHeight = 8;
const barX = 100;
const barY = 100;
// Determine active step index
const activeStepIndex = STEPS.findIndex((s) => frame < s.endFrame);
const currentStep = activeStepIndex === -1 ? STEPS.length - 1 : activeStepIndex;
return (
<AbsoluteFill
style={{
backgroundColor: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, -apple-system, sans-serif",
}}
>
{/* Active step label */}
<div
style={{
position: "absolute",
top: barY - 48,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
}}
>
{labels.map((label, i) => {
const isActive = i === currentStep;
const labelOpacity = isActive
? spring({
frame: frame - Math.max(0, i === 0 ? 0 : STEPS[i - 1].endFrame),
fps,
config: { damping: 20, stiffness: 120 },
})
: 0;
return (
<div
key={i}
style={{
position: "absolute",
opacity: labelOpacity,
fontSize: 16,
fontWeight: 600,
color: i === STEPS.length - 1 && isActive ? c.emerald : c.fg,
whiteSpace: "nowrap",
}}
>
{label}
</div>
);
})}
</div>
{/* Track background */}
<div
style={{
position: "absolute",
top: barY,
left: barX,
width: barWidth,
height: barHeight,
borderRadius: barHeight / 2,
backgroundColor: c.trackBg,
}}
/>
{/* Track fill */}
<div
style={{
position: "absolute",
top: barY,
left: barX,
width: barWidth * progress,
height: barHeight,
borderRadius: barHeight / 2,
background: `linear-gradient(90deg, ${c.emerald}, ${c.emeraldDark})`,
}}
/>
{/* Checkpoints */}
{STEPS.map((step, i) => {
const stepX = barX + (barWidth / (STEPS.length - 1)) * i;
const stepY = barY + barHeight / 2;
const reached = frame >= step.endFrame;
const reaching = frame >= (i === 0 ? 0 : STEPS[i - 1].endFrame);
const fillProgress = reached
? 1
: reaching
? spring({
frame: frame - (i === 0 ? 0 : STEPS[i - 1].endFrame),
fps,
config: { damping: 12, stiffness: 100 },
durationInFrames: step.endFrame - (i === 0 ? 0 : STEPS[i - 1].endFrame),
})
: 0;
const checkScale = reached
? spring({
frame: frame - step.endFrame,
fps,
config: { damping: 10, stiffness: 200 },
})
: 0;
const circleSize = 28;
return (
<div key={i}>
{/* Outer circle */}
<div
style={{
position: "absolute",
left: stepX - circleSize / 2,
top: stepY - circleSize / 2,
width: circleSize,
height: circleSize,
borderRadius: "50%",
backgroundColor: c.checkBg,
border: `2px solid ${fillProgress > 0 ? c.emerald : c.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
{/* Fill background */}
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: c.emerald,
opacity: fillProgress,
borderRadius: "50%",
}}
/>
{/* Checkmark */}
<svg
viewBox="0 0 24 24"
width={14}
height={14}
style={{
position: "relative",
zIndex: 1,
transform: `scale(${checkScale})`,
}}
>
<path
d="M5 13l4 4L19 7"
fill="none"
stroke="white"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{/* Step number below (visible when not yet reached) */}
{!reached && (
<div
style={{
position: "absolute",
left: stepX - circleSize / 2,
top: stepY - circleSize / 2,
width: circleSize,
height: circleSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontWeight: 700,
color: fillProgress > 0.5 ? "white" : c.mutedFg,
zIndex: 2,
opacity: 1 - checkScale,
}}
>
{i + 1}
</div>
)}
</div>
);
})}
</AbsoluteFill>
);
};

View File

@@ -1,5 +1,5 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { Toaster } from "sonner";
import { Toaster } from "@/lib/toast";
import type { QueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { getUserSettings } from "@/lib/user-settings";
@@ -36,7 +36,7 @@ function RootComponent() {
return (
<>
<Outlet />
<Toaster position="top-right" richColors />
<Toaster position="top-center" />
</>
);
}

View File

@@ -102,7 +102,7 @@ function AuthLayout() {
{/* Bottom trial badge */}
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
<ShieldCheck className="size-4" />
7 gün ücretsiz deneyin kredi kartı gerekmez
3 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { Link, createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
export const Route = createFileRoute("/_auth/forgot-password")({
component: ForgotPasswordPage,

View File

@@ -4,7 +4,7 @@ import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn } from "@/lib/auth-client";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
export const Route = createFileRoute("/_auth/login")({
component: LoginPage,
@@ -104,7 +104,7 @@ function LoginPage() {
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "google" })}
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/search" })}
>
Google ile Giriş Yap
</Button>

View File

@@ -1,10 +1,10 @@
import { useState } from "react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signUp } from "@/lib/auth-client";
import { toast } from "sonner";
import { signIn, signUp } from "@/lib/auth-client";
import { toast } from "@/lib/toast";
import { ShieldCheck } from "lucide-react";
export const Route = createFileRoute("/_auth/register")({
@@ -12,8 +12,6 @@ export const Route = createFileRoute("/_auth/register")({
});
function RegisterPage() {
const navigate = useNavigate();
const vin = new URLSearchParams(window.location.search).get("vin");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -26,11 +24,7 @@ function RegisterPage() {
try {
await signUp.email({ name, email, password });
toast.success("Hesap oluşturuldu!");
if (vin) {
navigate({ to: "/dashboard/search" });
} else {
navigate({ to: "/dashboard/search" });
}
window.location.href = "/dashboard/subscription?welcome=1";
} catch {
toast.error("Kayıt başarısız. Bu e-posta zaten kullanılıyor olabilir.");
} finally {
@@ -52,7 +46,7 @@ function RegisterPage() {
{/* Trial messaging */}
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="size-4 shrink-0" />
7 gün ücretsiz deneyin kredi kartı gerekmez
3 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>
@@ -97,6 +91,28 @@ function RegisterPage() {
</Button>
</form>
{/* Divider + Google */}
<div className="space-y-3">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
veya
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" })}
>
Google ile Kayıt Ol
</Button>
</div>
<p className="text-center text-xs text-muted-foreground">
Kayıt olunca hemen VIN aramaya başlayın
</p>

View File

@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
export const Route = createFileRoute("/_auth/reset-password")({
validateSearch: (search: Record<string, unknown>) => ({

View File

@@ -31,7 +31,6 @@ interface QueryLogItem {
vin: string;
brandId: string | null;
brandName: string | null;
source: string | null;
success: boolean;
errorMessage: string | null;
responseTimeMs: number | null;
@@ -158,11 +157,10 @@ function AdminAnalyticsPage() {
<CardContent className="p-0 overflow-x-auto">
{/* Table Header */}
<div className="min-w-[900px]">
<div className="grid grid-cols-8 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<div className="grid grid-cols-7 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>Kullanici</span>
<span>Şase</span>
<span>Marka</span>
<span>Kaynak</span>
<span className="text-center">Durum</span>
<span className="text-right">Yanit Suresi</span>
<span>Tarih</span>
@@ -174,7 +172,7 @@ function AdminAnalyticsPage() {
{data.items.map((log) => (
<div
key={log.id}
className="grid grid-cols-8 items-center gap-4 px-6 py-3 text-sm"
className="grid grid-cols-7 items-center gap-4 px-6 py-3 text-sm"
>
<div className="truncate">
<p className="truncate font-medium">{log.userName}</p>
@@ -192,13 +190,6 @@ function AdminAnalyticsPage() {
{log.brandName || "-"}
</span>
</div>
<div>
{log.source ? (
<Badge variant="outline">{log.source}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="text-center">
{log.success ? (
<CheckCircle className="mx-auto h-4 w-4 text-green-500" />

View File

@@ -17,7 +17,7 @@ import {
DialogTitle,
} from "@sase/ui";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
import {
Search,
ChevronLeft,

View File

@@ -174,17 +174,19 @@ function DashboardHome() {
},
});
const { data: subscription, isLoading: subLoading } = useQuery({
const { data: subData, isLoading: subLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: async () => {
try {
return await api.get<Subscription>("/subscriptions/me");
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me");
} catch {
return null;
}
},
});
const subscription = subData?.subscription ?? null;
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: async () => {
@@ -292,7 +294,7 @@ function DashboardHome() {
{subLoading ? (
<Skeleton className="h-48 w-full rounded-2xl" />
) : subscription && subscription.status === "active" ? (
) : subscription && (subscription.status === "active" || (subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
{/* Plan Info */}
@@ -307,7 +309,7 @@ function DashboardHome() {
{subscription.plan?.name ?? "Aktif Plan"}
</h3>
<Badge variant="default" className="bg-emerald-600 text-xs">
Aktif
{subscription.status === "trial" ? "Deneme" : "Aktif"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">

View File

@@ -10,7 +10,7 @@ import {
AlertCircle,
} from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "sonner";
import { toast } from "@/lib/toast";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -52,7 +52,6 @@ function SearchPage() {
model: string;
year: string;
engine: string;
source?: string;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
@@ -88,7 +87,7 @@ function SearchPage() {
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("Not found");
if (!res.ok) throw new Error("Bulunamadı");
return res.json();
})
.then((data) => {
@@ -99,7 +98,6 @@ function SearchPage() {
model: r.model || "—",
year: r.year ? String(r.year) : "—",
engine: r.engine || "—",
source: r.source,
});
} else {
setPreviewError(true);
@@ -260,7 +258,22 @@ function SearchPage() {
{error && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-sm text-destructive">{error}</p>
<p className="text-sm text-destructive">
{error.includes("abone olun") ? (
<>
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
<Link
to="/dashboard/subscription"
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
>
abone olun
</Link>
.
</>
) : (
error
)}
</p>
</div>
)}
</form>
@@ -297,11 +310,6 @@ function SearchPage() {
>
Araç tanımlandı
</Badge>
{preview.source && (
<Badge variant="outline" className="text-xs">
Kaynak: {preview.source}
</Badge>
)}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { lazy, Suspense } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
@@ -17,9 +17,10 @@ import {
DialogTrigger,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Crown } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Crown, Sparkles, ShieldCheck, Loader2, CheckCircle2, ArrowRight } from "lucide-react";
import { toast } from "@/lib/toast";
import { getUserSettings } from "@/lib/user-settings";
import confetti from "canvas-confetti";
const BrandSelector = lazy(() =>
import("@/components/subscription/brand-selector").then((mod) => ({
@@ -27,6 +28,16 @@ const BrandSelector = lazy(() =>
})),
);
const LazyPlayer = lazy(() =>
import("@remotion/player").then((mod) => ({ default: mod.Player })),
);
const LazyOnboardingProgress = lazy(() =>
import("@/remotion/OnboardingProgress").then((mod) => ({
default: mod.OnboardingProgress as React.FC<Record<string, unknown>>,
})),
);
function BrandSelectorFallback() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
@@ -106,16 +117,36 @@ function SubscriptionPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [welcome] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get("welcome") === "1";
});
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const { data: subscription, isLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<Subscription>("/subscriptions/me"),
// Onboarding state
const [onboardingPhase, setOnboardingPhase] = useState<"provisioning" | "completed" | null>(null);
const [animationEnded, setAnimationEnded] = useState(false);
const hasFiredRef = useRef(false);
// Dark mode detection for Remotion Player (computed once)
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: theme === "dark";
});
const { data: subData, isLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me"),
});
const subscription = subData?.subscription;
const eligibleForTrial = subData?.eligibleForTrial ?? false;
const cancelMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/cancel"),
onSuccess: () => {
@@ -139,6 +170,51 @@ function SubscriptionPage() {
},
});
const trialMutation = useMutation({
mutationFn: () => api.post("/subscriptions/trial"),
onSuccess: () => {
// Don't toast or invalidate here — completion handler does it
},
onError: () => {
// Error shown in provisioning UI
},
});
// Auto-trigger onboarding when welcome param is present and eligible
useEffect(() => {
if (!welcome || !subData || hasFiredRef.current) return;
if (!eligibleForTrial) {
// Not eligible — strip param silently
window.history.replaceState({}, "", window.location.pathname);
return;
}
hasFiredRef.current = true;
setOnboardingPhase("provisioning");
trialMutation.mutate();
window.history.replaceState({}, "", window.location.pathname);
}, [welcome, subData, eligibleForTrial]);
// Transition from provisioning → completed when both animation and mutation are done
useEffect(() => {
if (onboardingPhase !== "provisioning") return;
if (!animationEnded || !trialMutation.isSuccess) return;
setOnboardingPhase("completed");
queryClient.invalidateQueries({ queryKey: ["subscription"] });
confetti({
particleCount: 150,
spread: 80,
origin: { y: 0.6 },
});
}, [onboardingPhase, animationEnded, trialMutation.isSuccess]);
// Animation timer: 210 frames / 30fps = 7s + small buffer
useEffect(() => {
if (onboardingPhase !== "provisioning") return;
const timer = setTimeout(() => setAnimationEnded(true), 7500);
return () => clearTimeout(timer);
}, [onboardingPhase]);
function handleSelectPlan(planKey: string) {
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
@@ -168,11 +244,13 @@ function SubscriptionPage() {
const statusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
active: "default",
trial: "default",
pending: "secondary",
cancelled: "destructive",
expired: "outline",
};
// ─── Loading state ─────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="mx-auto max-w-5xl space-y-4">
@@ -185,12 +263,130 @@ function SubscriptionPage() {
);
}
// ─── Onboarding: Provisioning ──────────────────────────────────────────────
if (onboardingPhase === "provisioning") {
return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<CardContent className="relative flex flex-col items-center gap-6 py-10">
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 animate-pulse text-emerald-600 dark:text-emerald-400" />
<h2 className="text-xl font-bold text-emerald-900 dark:text-emerald-100">
{t("subscription.onboarding.provisioning")}
</h2>
</div>
<Suspense
fallback={
<div className="flex h-[200px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</div>
}
>
<OnboardingPlayer
isDark={isDark}
stepLabels={[
t("subscription.onboarding.step1"),
t("subscription.onboarding.step2"),
t("subscription.onboarding.step3"),
t("subscription.onboarding.step4"),
]}
/>
</Suspense>
{/* If animation finished but mutation still pending */}
{animationEnded && trialMutation.isPending && (
<div className="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-300">
<Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}...
</div>
)}
{/* Mutation error */}
{trialMutation.isError && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-red-600 dark:text-red-400">
{t("subscription.onboarding.error")}
</p>
<Button
variant="outline"
onClick={() => trialMutation.mutate()}
>
{t("subscription.onboarding.retry")}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ─── Onboarding: Completed ─────────────────────────────────────────────────
if (onboardingPhase === "completed") {
const freshSub = subData?.subscription;
return (
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-12">
<Card className="relative w-full overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<CardContent className="relative flex flex-col items-center gap-6 py-10">
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
{t("subscription.onboarding.completed")}
</h2>
{/* Subscription info box */}
<div className="w-full max-w-md space-y-4 rounded-xl border border-emerald-200 bg-white/60 p-5 dark:border-emerald-800 dark:bg-white/5">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.currentPlan")}</span>
<Badge className="bg-emerald-600 text-white">Full Paket</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.billingPeriod")}</span>
<span className="text-sm font-medium">{t("subscription.onboarding.trialDuration")}</span>
</div>
{freshSub?.endDate && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("subscription.endDate")}</span>
<span className="text-sm font-medium">
{new Date(freshSub.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
<Separator />
<ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => navigate({ to: "/dashboard/search" })}
>
{t("subscription.onboarding.startSearching")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
);
}
// ─── Normal subscription page ──────────────────────────────────────────────
return (
<div className="mx-auto max-w-5xl space-y-8">
<h2 className="text-2xl font-bold">{t("subscription.title")}</h2>
{/* Active Subscription Status */}
{subscription && (
{subscription && !(eligibleForTrial && subscription.status === "trial") && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -204,7 +400,11 @@ function SubscriptionPage() {
</div>
<CardDescription>
{subscription.plan?.name} &mdash;{" "}
{subscription.billingPeriod === "yearly" ? t("common.yearly") : t("common.monthly")}
{subscription.status === "trial"
? t("subscription.onboarding.trialDuration")
: subscription.billingPeriod === "yearly"
? t("common.yearly")
: t("common.monthly")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -240,6 +440,16 @@ function SubscriptionPage() {
)}
</div>
{subscription.status === "trial" && subscription.endDate && (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
<Sparkles className="h-4 w-4" />
{(() => {
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
return `${days} gün kaldı`;
})()}
</div>
)}
<div className="flex gap-3">
{subscription.status === "active" && (
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
@@ -282,8 +492,44 @@ function SubscriptionPage() {
</Card>
)}
{/* Trial CTA Card */}
{eligibleForTrial && (!subscription || subscription.status === "expired" || subscription.status === "trial") && (
<Card className="relative overflow-hidden border-emerald-500/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/20 dark:to-teal-950/20">
<div className="absolute inset-0 bg-gradient-to-r from-emerald-500/5 to-teal-500/5" />
<CardHeader className="relative">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
<CardTitle className="text-emerald-900 dark:text-emerald-100">
{t("subscription.trialTitle")}
</CardTitle>
</div>
<CardDescription className="text-emerald-700/80 dark:text-emerald-300/80">
{t("subscription.trialDescription")}
</CardDescription>
</CardHeader>
<CardContent className="relative space-y-4">
<ul className="space-y-2 text-sm">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-emerald-800 dark:text-emerald-200">
<Check className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
<Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => trialMutation.mutate()}
disabled={trialMutation.isPending}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{trialMutation.isPending ? t("common.loading") : t("subscription.startTrial")}
</Button>
</CardContent>
</Card>
)}
{/* No Subscription Banner */}
{!subscription && (
{!eligibleForTrial && !subscription && (
<Card className="border-dashed">
<CardContent className="py-8 text-center">
<p className="mb-2 text-lg font-medium text-muted-foreground">
@@ -419,3 +665,30 @@ function SubscriptionPage() {
</div>
);
}
// Separate component so Suspense boundary works for both Player + OnboardingProgress
function OnboardingPlayer({
isDark,
stepLabels,
}: {
isDark: boolean;
stepLabels: string[];
}) {
return (
<LazyPlayer
component={LazyOnboardingProgress}
inputProps={{ isDark, stepLabels }}
durationInFrames={210}
fps={30}
compositionWidth={800}
compositionHeight={200}
autoPlay
style={{
width: "100%",
maxWidth: 600,
aspectRatio: "800 / 200",
}}
controls={false}
/>
);
}

View File

@@ -74,7 +74,7 @@ function DemoPage() {
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("Not found");
if (!res.ok) throw new Error("Bulunamadı");
return res.json();
})
.then((data) => {

View File

@@ -30,6 +30,7 @@ import { SchemaDemo } from "@/remotion/SchemaDemo";
import { DashboardDemo } from "@/remotion/DashboardDemo";
import { EcommerceDemo } from "@/remotion/EcommerceDemo";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { useAuth } from "@/hooks/use-auth";
// ─── DATA ────────────────────────────────────────────────────────────────────
@@ -330,6 +331,7 @@ export const Route = createFileRoute("/")({
function HomePage() {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [vin, setVin] = useState("");
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isDark, setIsDark] = useState(() => {
@@ -369,7 +371,7 @@ function HomePage() {
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("Not found");
if (!res.ok) throw new Error("Bulunamadı");
return res.json();
})
.then((data) => {
@@ -439,19 +441,29 @@ function HomePage() {
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<Link to="/login">
<Button
variant="outline"
className="rounded-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register">
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
7 Gün Ücretsiz Deneyin
</Button>
</Link>
{isAuthenticated ? (
<Link to="/dashboard/search">
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
Panele Git
</Button>
</Link>
) : (
<>
<Link to="/login">
<Button
variant="outline"
className="rounded-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register">
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
7 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
)}
</div>
{/* Mobile toggle */}
@@ -499,19 +511,29 @@ function HomePage() {
Fiyatlar
</Link>
<Separator className="bg-border" />
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
<Button
variant="outline"
className="w-full rounded-full border-border text-muted-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
7 Gün Ücretsiz Deneyin
</Button>
</Link>
{isAuthenticated ? (
<Link to="/dashboard/search" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
Panele Git
</Button>
</Link>
) : (
<>
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
<Button
variant="outline"
className="w-full rounded-full border-border text-muted-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
7 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
)}
</nav>
</div>
)}