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:
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>) => ({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} —{" "}
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user