feat(FN-094): add comment line for deployment verification
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

- Added a comment line to main.ts for deployment verification purposes
This commit is contained in:
Fusion
2026-05-11 02:07:03 +00:00
parent c72f063a25
commit f4fea1e429
274 changed files with 20712 additions and 6305 deletions

View File

@@ -1,10 +1,12 @@
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
import { Toaster } from "@/lib/toast";
import type { QueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { getUserSettings } from "@/lib/user-settings";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { useAuth } from "@/hooks/use-auth";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { Toaster } from "@/lib/toast";
import { getUserSettings } from "@/lib/user-settings";
import { Button } from "@sase/ui";
import type { QueryClient } from "@tanstack/react-query";
import { Link, Outlet, createRootRouteWithContext, useLocation } from "@tanstack/react-router";
import { ArrowLeft, Home, Search } from "lucide-react";
import { useEffect } from "react";
interface RouterContext {
queryClient: QueryClient;
@@ -12,13 +14,75 @@ interface RouterContext {
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
notFoundComponent: NotFoundComponent,
});
function NotFoundComponent() {
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 py-12">
{/* Ambient brand glow */}
<div className="pointer-events-none absolute -left-32 top-1/4 h-[500px] w-[500px] rounded-full bg-brand/8 blur-[140px]" />
<div className="pointer-events-none absolute -right-32 bottom-1/4 h-[400px] w-[400px] rounded-full bg-brand/5 blur-[120px]" />
<div className="relative max-w-xl text-center">
<p className="font-mono text-sm font-medium uppercase tracking-[0.2em] text-muted-foreground">
404 sayfa bulunamadı
</p>
<h1 className="mt-6 font-[family-name:var(--font-display)] text-6xl font-bold tracking-tight sm:text-7xl">
Yanlış parça,
<br />
<span className="text-muted-foreground">yanlış adres.</span>
</h1>
<p className="mx-auto mt-6 max-w-md text-base text-muted-foreground">
Aradığın sayfa silinmiş ya da hiç olmamış olabilir. Aşağıdan ana sayfaya dönebilir veya
doğrudan şase aramaya gidebilirsin.
</p>
<div className="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link to="/">
<Button variant="outline" className="rounded-full">
<ArrowLeft className="size-4" />
Ana sayfaya dön
</Button>
</Link>
<Link to="/dashboard/search">
<Button variant="brand" className="rounded-full">
<Search className="size-4" />
Şase aramaya git
</Button>
</Link>
</div>
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
<Link
to="/"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<Home className="size-3.5" />
Anasayfa
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/pricing" className="transition-colors hover:text-foreground">
Fiyatlandırma
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/demo" className="transition-colors hover:text-foreground">
Demo
</Link>
<span className="size-1 rounded-full bg-border" aria-hidden="true" />
<Link to="/contact" className="transition-colors hover:text-foreground">
İletişim
</Link>
</div>
</div>
</main>
);
}
function applyTheme(theme: "light" | "dark" | "system") {
const isDark =
theme === "dark" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
}
@@ -43,7 +107,7 @@ function RootComponent() {
} else {
resetUser();
}
}, [user?.id]);
}, [user]);
useEffect(() => {
const theme = getUserSettings().theme ?? "dark";
@@ -59,6 +123,9 @@ function RootComponent() {
return (
<>
<a href="#main-content" className="skip-link">
İçeriğe atla
</a>
<Outlet />
<Toaster position="top-center" />
</>

View File

@@ -0,0 +1,115 @@
/**
* Regression tests for landing page VIN decode analytics events.
*
* These tests verify that the correct PostHog events are captured on
* successful VIN decode and on error conditions.
*/
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { vi } from "vitest";
// Mock the PostHog capture function
vi.mock("@/lib/posthog", () => ({
capture: vi.fn(),
}));
// Mock the API client used for VIN decode
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(message: string, public code?: string, public status?: number) {
super(message);
this.name = "ApiError";
}
},
}));
// Mock the auth store to simulate a loggedin user
vi.mock("@/stores/auth.store", () => {
const actual = vi.importActual("@/stores/auth.store");
return {
...actual,
useAuthStore: {
getState: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
};
});
// Mock navigation to avoid real router sideeffects
vi.mock("@tanstack/react-router", async () => {
const actual = await vi.importActual<any>("@tanstack/react-router");
return {
...actual,
useNavigate: () => vi.fn(),
};
});
import { HomePage } from "@/routes/index"; // Exported component
import { capture } from "@/lib/posthog";
import { api, ApiError } from "@/lib/api-client";
import { useAuthStore } from "@/stores/auth.store";
beforeEach(() => {
vi.clearAllMocks();
(useAuthStore.getState as any).mockReturnValue({
user: { id: "user-1" },
isLoading: false,
});
});
test("captures vin_decode_success on successful decode", async () => {
(api.post as any).mockResolvedValue({ id: "test-vehicle-id" });
render(
<BrowserRouter>
<HomePage />
</BrowserRouter>,
);
const input = screen.getByPlaceholderText(/Örnek:/i);
fireEvent.change(input, { target: { value: "WVWZZZ1JZ3W597935" } });
const button = screen.getByRole("button", { name: /Ara/i });
fireEvent.click(button);
await waitFor(() => {
expect(capture).toHaveBeenCalledWith(
"vin_decode_success",
expect.objectContaining({
vin: "WVWZZZ1JZ3W597935",
source: "landing",
vehicle_id: "test-vehicle-id",
}),
);
});
});
test("captures vin_decode_error on API error", async () => {
const error = new ApiError("Invalid VIN", "INVALID", 400);
(api.post as any).mockRejectedValue(error);
render(
<BrowserRouter>
<HomePage />
</BrowserRouter>,
);
const input = screen.getByPlaceholderText(/Örnek:/i);
fireEvent.change(input, { target: { value: "INVALIDVIN1234567" } });
const button = screen.getByRole("button", { name: /Ara/i });
fireEvent.click(button);
await waitFor(async () => {
expect(capture).toHaveBeenCalledWith(
"vin_decode_error",
expect.objectContaining({
vin: "INVALIDVIN1234567",
source: "landing",
error: "Invalid VIN",
}),
);
});
});

View File

@@ -1,4 +1,4 @@
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
import { Database, ShieldCheck, Zap } from "lucide-react";
export const Route = createFileRoute("/_auth")({
@@ -9,7 +9,7 @@ function AuthLayout() {
return (
<div className="flex min-h-screen">
{/* Left Panel — Form */}
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
<main id="main-content" className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-md">
<Outlet />
@@ -23,31 +23,53 @@ function AuthLayout() {
Sase.tr
</Link>
</div>
</div>
</main>
{/* Right Panel — Promo (always dark, hidden on mobile) */}
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
<div className="flex flex-1 flex-col justify-center space-y-8">
{/* Right Panel — Promo (always dark via .dark scope, hidden on mobile) */}
<div className="dark relative hidden overflow-hidden border-l border-border bg-background text-foreground lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
{/* Brand glow ambient */}
<div className="pointer-events-none absolute -left-32 top-0 h-[500px] w-[500px] rounded-full bg-brand/10 blur-[140px]" />
<div className="pointer-events-none absolute -right-24 bottom-0 h-[400px] w-[400px] rounded-full bg-brand/8 blur-[120px]" />
{/* Subtle grid */}
<div className="pointer-events-none absolute inset-0 opacity-[0.04]">
<svg width="100%" height="100%" aria-hidden="true">
<defs>
<pattern id="auth-grid" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-grid)" />
</svg>
</div>
<div className="relative flex flex-1 flex-col justify-center space-y-8">
{/* Heading */}
<div className="space-y-3">
<h2 className="text-3xl font-bold tracking-tight">
Doğru Parçayı İlk Seferde Bulun
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-surface/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur-sm">
<span className="size-1.5 rounded-full bg-brand" />
Sase.tr
</span>
<h2 className="font-[family-name:var(--font-display)] text-4xl font-bold tracking-tight">
Doğru parçayı
<br />
<span className="text-foreground/60">ilk seferde bulun.</span>
</h2>
<p className="text-base leading-relaxed text-neutral-400">
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
bulun.
<p className="text-base leading-relaxed text-muted-foreground">
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM kodları. Şase
numarasını girin, doğru parçayı saniyeler içinde bulun.
</p>
</div>
{/* Stats */}
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Zap className="size-3" />
1.2sn Sorgu
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<svg
role="img"
aria-label="icon"
className="size-3"
viewBox="0 0 24 24"
fill="none"
@@ -62,47 +84,44 @@ function AuthLayout() {
</svg>
27 Marka
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<Database className="size-3" />
243K+ OEM Parça
<span className="tabular">243K+ OEM Parça</span>
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/60 px-3 py-1.5 text-xs text-foreground/80 backdrop-blur-sm">
<ShieldCheck className="size-3" />
%99.9 Uptime
<span className="tabular">%99.9 Uptime</span>
</span>
</div>
{/* Brand row */}
<p className="text-sm text-neutral-500">
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
Hyundai · Ford · Opel · Skoda
<p className="text-sm text-muted-foreground">
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda · Hyundai · Ford ·
Opel · Skoda
</p>
{/* Testimonial */}
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<p className="text-sm leading-relaxed text-neutral-300">
&ldquo;Sase.tr&apos;ye geçtiğimizden beri yanlış parça
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
zaman tasarrufu sağlıyoruz.&rdquo;
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
<p className="text-sm leading-relaxed text-foreground/85">
&ldquo;Sase.tr&apos;ye geçtiğimizden beri yanlış parça siparişlerimiz neredeyse sıfıra
indi. Aylık 40 saatin üzerinde zaman tasarrufu sağlıyoruz.&rdquo;
</p>
<div className="mt-4 flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium">
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
MK
</div>
<div>
<p className="text-sm font-medium">Mehmet K.</p>
<p className="text-xs text-neutral-400">
Yedek Parça İşletme Sahibi
</p>
<p className="text-xs text-muted-foreground">Yedek Parça İşletme Sahibi</p>
</div>
</div>
</div>
</div>
{/* Bottom trial badge */}
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
<ShieldCheck className="size-4" />
7 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
<div className="relative flex items-center gap-2 pt-6 text-sm text-muted-foreground">
<ShieldCheck className="size-4 text-brand" />
30 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { useState } from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { toast } from "@/lib/toast";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "@/lib/toast";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/_auth/forgot-password")({
component: ForgotPasswordPage,
@@ -44,12 +44,9 @@ function ForgotPasswordPage() {
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
E-posta Gönderildi
</h1>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">E-posta Gönderildi</h1>
<p className="mt-2 text-sm text-muted-foreground">
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen
e-postanızı kontrol edin.
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen e-postanızı kontrol edin.
</p>
</div>
<Link to="/login">
@@ -65,9 +62,7 @@ function ForgotPasswordPage() {
<div className="space-y-8">
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Şifremi Unuttum
</h1>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Şifremi Unuttum</h1>
<p className="mt-2 text-sm text-muted-foreground">
E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz
</p>
@@ -92,10 +87,7 @@ function ForgotPasswordPage() {
</form>
<p className="text-center text-sm">
<Link
to="/login"
className="text-muted-foreground hover:underline"
>
<Link to="/login" className="text-muted-foreground hover:underline">
Giriş Sayfasına Dön
</Link>
</p>

View File

@@ -1,13 +1,13 @@
import { useState } from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { api } from "@/lib/api-client";
import { signIn } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/_auth/login")({
component: LoginPage,
@@ -50,9 +50,7 @@ function LoginPage() {
<div className="space-y-8">
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Hesabınıza Giriş Yapın
</h1>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Hesabınıza Giriş Yapın</h1>
<p className="mt-2 text-sm text-muted-foreground">
Şase çözme ve parça kataloğuna erişmek için giriş yapın
</p>
@@ -69,10 +67,22 @@ function LoginPage() {
}}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google ile Giriş Yap
</Button>
@@ -83,9 +93,7 @@ function LoginPage() {
<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>
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
@@ -116,16 +124,10 @@ function LoginPage() {
{/* Remember me + Forgot password */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 rounded border-input accent-primary"
/>
<input type="checkbox" className="size-4 rounded border-input accent-primary" />
Beni hatırla
</label>
<Link
to="/forgot-password"
className="text-sm text-muted-foreground hover:underline"
>
<Link to="/forgot-password" className="text-sm text-muted-foreground hover:underline">
Şifremi Unuttum
</Link>
</div>

View File

@@ -1,14 +1,14 @@
import { useState } from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { api } from "@/lib/api-client";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { api } from "@/lib/api-client";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/register")({
component: RegisterPage,
@@ -59,17 +59,13 @@ function RegisterPage() {
<div className="space-y-8">
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Kayıt Ol
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Yeni bir Sase.tr hesabı oluşturun
</p>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Kayıt Ol</h1>
<p className="mt-2 text-sm text-muted-foreground">Yeni bir Sase.tr hesabı oluşturun</p>
{/* 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 Full Paket ücretsiz deneyin kredi kartı gerekmez
<div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
<ShieldCheck className="size-4 shrink-0 text-brand" />
30 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
</div>
</div>
@@ -84,10 +80,22 @@ function RegisterPage() {
}}
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google ile Kayıt Ol
</Button>
@@ -98,9 +106,7 @@ function RegisterPage() {
<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>
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { toast } from "@/lib/toast";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "@/lib/toast";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
export const Route = createFileRoute("/_auth/reset-password")({
validateSearch: (search: Record<string, unknown>) => ({
@@ -31,9 +31,7 @@ function ResetPasswordPage() {
toast.success("Şifreniz başarıyla güncellendi.");
navigate({ to: "/login" });
} catch {
toast.error(
"Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.",
);
toast.error("Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.");
} finally {
setLoading(false);
}
@@ -43,12 +41,8 @@ function ResetPasswordPage() {
<div className="space-y-8">
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Şifre Sıfırla
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Yeni şifrenizi belirleyin
</p>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Şifre Sıfırla</h1>
<p className="mt-2 text-sm text-muted-foreground">Yeni şifrenizi belirleyin</p>
</div>
{/* Form */}

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: AboutPage,
@@ -37,47 +37,44 @@ function AboutPage() {
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
<p>
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen
dijital bir platformdur. Şase numarası (VIN) ile araç tanımlama,
orijinal parça kataloğuna erişim ve interaktif şema görüntüleme
hizmetlerini tek bir çatı altında sunar.
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen dijital bir platformdur.
Şase numarası (VIN) ile araç tanımlama, orijinal parça kataloğuna erişim ve interaktif
şema görüntüleme hizmetlerini tek bir çatı altında sunar.
</p>
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
<p>
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk
seferde ulaşmayı sağlamak ve sektördeki bilgi asimetrisini ortadan
kaldırmak. Oto yedek parçacılar, servisler ve bireysel kullanıcılar
için güvenilir bir referans noktası olmayı hedefliyoruz.
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk seferde ulaşmayı sağlamak ve
sektördeki bilgi asimetrisini ortadan kaldırmak. Oto yedek parçacılar, servisler ve
bireysel kullanıcılar için güvenilir bir referans noktası olmayı hedefliyoruz.
</p>
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka,
model, yıl, motor tipi ve donanım bilgilerine anında erişim.
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka, model, yıl, motor tipi ve
donanım bilgilerine anında erişim.
</li>
<li>
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça
numaraları, ıklamalar ve çapraz referanslar.
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça numaraları, ıklamalar ve
çapraz referanslar.
</li>
<li>
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden
rsel parça seçimi ve detay rüntüleme.
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden görsel parça seçimi ve detay
görüntüleme.
</li>
<li>
<strong>Çoklu Marka Desteği:</strong> Volkswagen, Audi, BMW,
Mercedes-Benz, Ford ve daha fazlası.
<strong>Çoklu Marka Desteği:</strong> Volkswagen, Audi, BMW, Mercedes-Benz, Ford ve
daha fazlası.
</li>
</ul>
<h2 className="text-2xl font-semibold text-foreground">Neden Sase.tr?</h2>
<p>
Geleneksel yöntemlerle saatler süren parça arama işlemini
dakikalara indiriyoruz. Güncel ve doğrulanmış verilerle yanlış
parça siparişinin önüne geçiyoruz. Kullanıcı dostu arayüzümüz
sayesinde teknik bilgi seviyesinden bağımsız olarak herkes
kolayca kullanabilir.
Geleneksel yöntemlerle saatler süren parça arama işlemini dakikalara indiriyoruz. Güncel
ve doğrulanmış verilerle yanlış parça siparişinin önüne geçiyoruz. Kullanıcı dostu
arayüzümüz sayesinde teknik bilgi seviyesinden bağımsız olarak herkes kolayca
kullanabilir.
</p>
<h2 className="text-2xl font-semibold text-foreground">İletişim</h2>

View File

@@ -1,7 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog")({
component: BlogPage,
@@ -78,9 +78,7 @@ function BlogPage() {
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{post.description}
</p>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>

View File

@@ -1,7 +1,7 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { ChevronRight } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
@@ -23,33 +23,40 @@ const POSTS: Record<string, BlogPost> = {
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta üretim
aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası" olarak da
bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip etmeye yarar.
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta
üretim aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası"
olarak da bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip
etmeye yarar.
</p>
<h2 className="text-xl font-semibold text-foreground">VIN Nereden Okunur?</h2>
<p>
VIN numarasına birçok yerden ulaşabilirsiniz: ön camın sol alt köşesindeki plaka, sürücü
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların
başında gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların başında
gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
</p>
<h2 className="text-xl font-semibold text-foreground">17 Karakterin Anlamı</h2>
<p>VIN üç ana bölüme ayrılır:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">WMI (1-3. karakterler) — Dünya Üretici Kodu:</strong>{" "}
<strong className="text-foreground">
WMI (1-3. karakterler) — Dünya Üretici Kodu:
</strong>{" "}
Aracın hangi ülkede ve hangi fabrikada üretildiğini gösterir. Örneğin "WVW" Volkswagen
Almanya, "ZFA" Fiat İtalya demektir.
</li>
<li>
<strong className="text-foreground">VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:</strong>{" "}
<strong className="text-foreground">
VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:
</strong>{" "}
Model, kasa tipi, motor hacmi, yakıt türü ve güvenlik donanımları hakkında bilgi içerir.
9. karakter her zaman kontrol karakteridir ve matematiksel bir doğrulama amacı taşır.
</li>
<li>
<strong className="text-foreground">VIS (10-17. karakterler) — Araç Tanımlama Bölümü:</strong>{" "}
<strong className="text-foreground">
VIS (10-17. karakterler) — Araç Tanımlama Bölümü:
</strong>{" "}
Model yılı (10. karakter), üretim fabrikası (11. karakter) ve üretim sıra numarası
(12-17. karakterler) bilgilerini içerir.
</li>
@@ -57,9 +64,9 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">Örnek: WVWZZZ1JZ3W597935</h2>
<p>
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong> Volkswagen
Almanya, <strong className="font-mono text-foreground">ZZZ</strong> pazar tanımlayıcı,{" "}
<strong className="font-mono text-foreground">1J</strong> Golf modeli,{" "}
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong>
Volkswagen Almanya, <strong className="font-mono text-foreground">ZZZ</strong> pazar
tanımlayıcı, <strong className="font-mono text-foreground">1J</strong> Golf modeli,{" "}
<strong className="font-mono text-foreground">Z</strong> motor tipi,{" "}
<strong className="font-mono text-foreground">3</strong> model yılı 2003,{" "}
<strong className="font-mono text-foreground">W</strong> Wolfsburg fabrikası,{" "}
@@ -109,10 +116,10 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">OEM Parça Nedir?</h2>
<p>
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç üreticisinin
onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan parçalarla aynı
spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir. Üzerinde araç markasının
logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç
üreticisinin onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan
parçalarla aynı spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir.
Üzerinde araç markasının logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
</p>
<h2 className="text-xl font-semibold text-foreground">Aftermarket (Muadil) Parça Nedir?</h2>
@@ -154,7 +161,9 @@ const POSTS: Record<string, BlogPost> = {
<li>Güvenlik sistemlerini olumsuz etkileyebilir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Hangi Durumlarda Orijinal, Hangisinde Muadil?</h2>
<h2 className="text-xl font-semibold text-foreground">
Hangi Durumlarda Orijinal, Hangisinde Muadil?
</h2>
<p>
Fren sistemi, hava yastığı, direksiyon ve motor parçaları gibi güvenlik kritik
bileşenlerde kesinlikle OEM tercih edilmelidir. Kaporta, döşeme veya aksesuar
@@ -190,12 +199,12 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">Geleneksel Yöntemlerin Sorunları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden fazla
katalog taramak ortalama 15-20 dakika sürebilir.
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden
fazla katalog taramak ortalama 15-20 dakika sürebilir.
</li>
<li>
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve karşılaştırma
işlemlerinde yanlış parça sipariş riski yüksektir.
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve
karşılaştırma işlemlerinde yanlış parça sipariş riski yüksektir.
</li>
<li>
<strong className="text-foreground">Güncellik sorunu:</strong> Basılı kataloglar yeni
@@ -208,9 +217,7 @@ const POSTS: Record<string, BlogPost> = {
</ul>
<h2 className="text-xl font-semibold text-foreground">Dijital Dönüşümün Faydaları</h2>
<p>
Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:
</p>
<p>Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:</p>
<ul className="list-disc space-y-2 pl-6">
<li>VIN bazlı anlık araç tanımlama saniyeler içinde doğru araç tespiti</li>
<li>Çoklu katalog çapraz sorgulama tek arayüzde birden fazla kaynak</li>
@@ -220,9 +227,7 @@ const POSTS: Record<string, BlogPost> = {
</ul>
<h2 className="text-xl font-semibold text-foreground">Sektörde Sayısal Dönüşüm</h2>
<p>
Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:
</p>
<p>Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:</p>
<ul className="list-disc space-y-2 pl-6">
<li>Parça arama süresi ortalama %85 kısalmaktadır</li>
<li>Yanlış parça iade oranları %60-80 düşmektedir</li>
@@ -230,20 +235,22 @@ const POSTS: Record<string, BlogPost> = {
<li>Personel kapasitesi daha katma değerli işlere yönlendirilebilmektedir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar</h2>
<h2 className="text-xl font-semibold text-foreground">
Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar
</h2>
<p>
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit maliyetli
abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal araçlara
erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit
maliyetli abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal
araçlara erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
</p>
<h2 className="text-xl font-semibold text-foreground">Sase.tr'nin Rolü</h2>
<p>
Sase.tr, Türkiye'nin yedek parça sektörüne özgü geliştirilen bu dijital dönüşümün
öncüsüdür. VIN/şase numarası sorgulama, çoklu katalog entegrasyonu ve interaktif şema
görüntüleme özellikleriyle geleneksel yapış biçimlerini dönüştürmektedir.
Platform, 27+ marka ve 243.000'den fazla OEM parça numarasıyla sektörün en kapsamlı
dijital kataloğunu sunmaktadır.
görüntüleme özellikleriyle geleneksel yapış biçimlerini dönüştürmektedir. Platform, 27+
marka ve 243.000'den fazla OEM parça numarasıyla sektörün en kapsamlı dijital kataloğunu
sunmaktadır.
</p>
</div>
),
@@ -258,8 +265,8 @@ const POSTS: Record<string, BlogPost> = {
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini
de olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini de
olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
platformu nasıl en verimli şekilde kullanacağınızı adım adım anlatıyoruz.
</p>
@@ -276,32 +283,34 @@ const POSTS: Record<string, BlogPost> = {
<h2 className="text-xl font-semibold text-foreground">Adım 2: Kategori Seçin</h2>
<p>
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor,
Şasi & Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden
ihtiyacınız olan bölümü seçin.
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor, Şasi &
Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden ihtiyacınız olan
bölümü seçin.
</p>
<p>
Her ana kategorinin altında detaylı alt kategoriler bulunur. Örneğin "Motor" altında
Silindir Kapağı, Krank Mili, Piston, Yağ Pompası gibi bölümler yer alır.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 3: İnteraktif Şemayı Kullanın</h2>
<h2 className="text-xl font-semibold text-foreground">
Adım 3: İnteraktif Şemayı Kullanın
</h2>
<p>
Kategori seçildikten sonra o bölgеnin teknik çizimi interaktif şema ekranda ılır.
Şema üzerindeki parçalara tıklayarak OEM kodunu, ıklamasını ve gerekli miktarını
görebilirsiniz.
</p>
<p>
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir.
Karmaşık motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek
iade oranlarını ciddi ölçüde düşürmektedir.
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir. Karmaşık
motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek iade oranlarını
ciddi ölçüde düşürmektedir.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 4: OEM Kodunu Kopyalayın</h2>
<p>
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize,
servis faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini
tamamen ortadan kaldırın.
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize, servis
faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini tamamen ortadan
kaldırın.
</p>
<h2 className="text-xl font-semibold text-foreground">İpuçları</h2>
@@ -315,19 +324,17 @@ const POSTS: Record<string, BlogPost> = {
karşılaştırma özelliğini kullanın.
</li>
<li>
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş
bir arama yapabilirsiniz.
</li>
<li>
27+ markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş bir arama
yapabilirsiniz.
</li>
<li>27+ markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
<p>
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin
kredi kartı gerektirmez.
ücretini karşılar. 30 günlük ücretsiz deneme süresiyle platformu bugün deneyin kredi
kartı gerektirmez.
</p>
</div>
),

View File

@@ -1,7 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/contact")({
component: ContactPage,
@@ -35,8 +35,7 @@ function ContactPage() {
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize
ulaşın.
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 sm:grid-cols-2">
@@ -45,10 +44,7 @@ function ContactPage() {
<CardTitle className="text-lg">E-posta</CardTitle>
</CardHeader>
<CardContent>
<a
href="mailto:info@sase.tr"
className="text-primary underline"
>
<a href="mailto:info@sase.tr" className="text-primary underline">
info@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
@@ -62,10 +58,7 @@ function ContactPage() {
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a
href="mailto:destek@sase.tr"
className="text-primary underline"
>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
@@ -79,9 +72,7 @@ function ContactPage() {
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
İstanbul, Türkiye
</p>
<p className="text-muted-foreground">İstanbul, Türkiye</p>
<p className="mt-2 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>

View File

@@ -1,37 +1,38 @@
import { createFileRoute, Outlet, Link, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/hooks/use-auth";
import { useTranslation } from "@/lib/i18n";
import { KEYS_5 } from "@/lib/keys";
import { capture, resetUser } from "@/lib/posthog";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Separator, Skeleton } from "@sase/ui";
import { Link, Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
Search,
History,
CreditCard,
Receipt,
Settings,
Shield,
Users,
ArrowRight,
BarChart3,
Bell,
BookOpen,
Copy,
CreditCard,
DollarSign,
Share2,
Menu,
X,
FlaskConical,
History,
LayoutDashboard,
Library,
LogOut,
Mail,
Menu,
Moon,
PanelLeftClose,
PanelLeftOpen,
LayoutDashboard,
Bell,
ArrowRight,
Mail,
BookOpen,
Receipt,
Search,
Settings,
Share2,
Shield,
Sun,
Moon,
Copy,
Library,
FlaskConical,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { capture, resetUser } from "@/lib/posthog";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
@@ -47,7 +48,12 @@ const mainMenuItems = [
] as const;
const accountItems = [
{ to: "/dashboard/subscription", label: "nav.subscription", translatable: true, icon: CreditCard },
{
to: "/dashboard/subscription",
label: "nav.subscription",
translatable: true,
icon: CreditCard,
},
{ to: "/dashboard/billing", label: "nav.billing", translatable: true, icon: Receipt },
{ to: "/dashboard/settings", label: "nav.settings", translatable: true, icon: Settings },
] as const;
@@ -105,12 +111,17 @@ function NavLink({
<Link
to={to}
title={collapsed ? label : undefined}
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }}
activeOptions={exact ? { exact: true } : undefined}
onClick={onClick}
>
<Icon className="size-4 shrink-0" />
{/* Left accent bar — only visible when active */}
<span
className={`absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100 ${collapsed ? "hidden" : ""}`}
aria-hidden="true"
/>
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
{!collapsed && <span>{label}</span>}
</Link>
);
@@ -123,9 +134,7 @@ function DashboardLayout() {
const { user, isLoading, signOut, isAdmin } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [collapsed, setCollapsed] = useState(
() => getUserSettings().sidebarCollapsed ?? false,
);
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
const [isDark, setIsDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
if (theme === "system") {
@@ -158,8 +167,8 @@ function DashboardLayout() {
<div className="flex min-h-screen">
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
<Skeleton className="mb-8 h-8 w-32" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={`nav-skel-${i}`} className="mb-3 h-10 w-full" />
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="mb-3 h-10 w-full" />
))}
</div>
<div className="flex-1 p-6">
@@ -281,9 +290,7 @@ function DashboardLayout() {
</div>
{/* Navigation */}
<nav
className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}
>
<nav className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}>
<SidebarNav />
</nav>
@@ -292,7 +299,7 @@ function DashboardLayout() {
<button
type="button"
onClick={handleSignOut}
title={collapsed ? user.name ?? ıkış" : undefined}
title={collapsed ? (user.name ?? ıkış") : undefined}
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5"}`}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
@@ -302,9 +309,7 @@ function DashboardLayout() {
<>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="size-4 shrink-0 text-muted-foreground" />
</>
@@ -334,9 +339,7 @@ function DashboardLayout() {
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold">{user.name}</p>
<p className="text-xs text-muted-foreground">
Sase.tr'ye hoş geldiniz 👋
</p>
<p className="text-xs text-muted-foreground">Sase.tr'ye hoş geldiniz 👋</p>
</div>
</div>
</div>
@@ -378,15 +381,14 @@ function DashboardLayout() {
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<main id="main-content" className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
<Outlet />
</main>
{/* Footer */}
<div className="border-t border-border px-6 py-3">
<p className="text-center text-xs text-muted-foreground/60">
&copy; {new Date().getFullYear()} Sase.tr | Gizlilik Politikası,
Kullanım Koşulları
&copy; {new Date().getFullYear()} Sase.tr | Gizlilik Politikası, Kullanım Koşulları
</p>
</div>
</div>
@@ -404,11 +406,7 @@ function DashboardLayout() {
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<span className="text-xl font-bold">Sase.tr</span>
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(false)}
>
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
<X className="size-4" />
</Button>
</div>
@@ -430,9 +428,7 @@ function DashboardLayout() {
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">
{user.email}
</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="size-4 shrink-0 text-muted-foreground" />
</button>

View File

@@ -1,24 +1,17 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Search,
X,
CheckCircle,
XCircle,
Activity,
} from "lucide-react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Activity, CheckCircle, ChevronLeft, ChevronRight, Search, X, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/analytics")({
component: AdminAnalyticsPage,
});
@@ -75,9 +68,7 @@ function AdminAnalyticsPage() {
params.set("page", String(page));
params.set("limit", String(limit));
if (debouncedUserId) params.set("userId", debouncedUserId);
return api.get<QueryLogResponse>(
`/admin/query-logs?${params.toString()}`,
);
return api.get<QueryLogResponse>(`/admin/query-logs?${params.toString()}`);
},
enabled: user?.role === "admin",
});
@@ -136,8 +127,8 @@ function AdminAnalyticsPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`log-skeleton-${i}`} className="h-12 w-full" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
@@ -176,19 +167,13 @@ function AdminAnalyticsPage() {
>
<div className="truncate">
<p className="truncate font-medium">{log.userName}</p>
<p className="truncate text-xs text-muted-foreground">
{log.userEmail}
</p>
<p className="truncate text-xs text-muted-foreground">{log.userEmail}</p>
</div>
<div>
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{log.vin}
</code>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{log.vin}</code>
</div>
<div className="truncate">
<span className="text-sm">
{log.brandName || "-"}
</span>
<span className="text-sm">{log.brandName || "-"}</span>
</div>
<div className="text-center">
{log.success ? (
@@ -214,9 +199,7 @@ function AdminAnalyticsPage() {
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(log.createdAt)}
</div>
<div className="text-xs text-muted-foreground">{formatDate(log.createdAt)}</div>
<div>
{log.errorMessage && (
<span

View File

@@ -1,6 +1,5 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
@@ -8,16 +7,11 @@ import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Tabs, TabsList, TabsTrigger } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Search,
X,
Copy,
TrendingUp,
} from "lucide-react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronLeft, ChevronRight, Copy, Search, TrendingUp, X } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/copy-logs")({
component: AdminCopyLogsPage,
});
@@ -78,9 +72,7 @@ function AdminCopyLogsPage() {
params.set("page", String(page));
params.set("limit", String(limit));
if (debouncedUserId) params.set("userId", debouncedUserId);
return api.get<CopyLogResponse>(
`/admin/copy-logs?${params.toString()}`,
);
return api.get<CopyLogResponse>(`/admin/copy-logs?${params.toString()}`);
},
enabled: user?.role === "admin" && tab === "logs",
});
@@ -161,8 +153,8 @@ function AdminCopyLogsPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`copy-skel-${i}`} className="h-12 w-full" />
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
@@ -195,9 +187,7 @@ function AdminCopyLogsPage() {
>
<div className="truncate">
<p className="truncate font-medium">{log.userName}</p>
<p className="truncate text-xs text-muted-foreground">
{log.userEmail}
</p>
<p className="truncate text-xs text-muted-foreground">{log.userEmail}</p>
</div>
<div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
@@ -258,61 +248,52 @@ function AdminCopyLogsPage() {
</>
)}
{tab === "top" && (
<>
{topLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`top-skel-${i}`} className="h-12 w-full" />
))}
</div>
) : !topCodes || topCodes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<TrendingUp className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Veri bulunamadi</p>
<p className="text-sm text-muted-foreground">
Son 30 gunde kopyalanan OEM kodu yok
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="overflow-x-auto p-0">
<div className="min-w-[500px]">
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>#</span>
<span>OEM Kodu</span>
<span className="text-center">Kopyalanma</span>
<span className="text-center">Benzersiz Kullanici</span>
</div>
<div className="divide-y">
{topCodes.map((item, idx) => (
<div
key={item.oemCode}
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
>
<span className="text-muted-foreground">{idx + 1}</span>
<div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
{item.oemCode}
</code>
</div>
<div className="text-center font-medium">
{item.copyCount}
</div>
<div className="text-center text-muted-foreground">
{item.uniqueUsers}
</div>
</div>
))}
</div>
{tab === "top" &&
(topLoading ? (
<div className="space-y-3">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-12 w-full" />
))}
</div>
) : !topCodes || topCodes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<TrendingUp className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Veri bulunamadi</p>
<p className="text-sm text-muted-foreground">Son 30 gunde kopyalanan OEM kodu yok</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="overflow-x-auto p-0">
<div className="min-w-[500px]">
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>#</span>
<span>OEM Kodu</span>
<span className="text-center">Kopyalanma</span>
<span className="text-center">Benzersiz Kullanici</span>
</div>
</CardContent>
</Card>
)}
</>
)}
<div className="divide-y">
{topCodes.map((item, idx) => (
<div
key={item.oemCode}
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
>
<span className="text-muted-foreground">{idx + 1}</span>
<div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
{item.oemCode}
</code>
</div>
<div className="text-center font-medium">{item.copyCount}</div>
<div className="text-center text-muted-foreground">{item.uniqueUsers}</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,24 +1,25 @@
import { lazy, Suspense } from "react";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { KEYS_6 } from "@/lib/keys";
import { Card, CardContent } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Badge } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
Users,
CreditCard,
TrendingUp,
Search,
UserPlus,
Clock,
UserCog,
Receipt,
Activity,
Clock,
Copy,
CreditCard,
Receipt,
Search,
TrendingUp,
UserCog,
UserPlus,
Users,
} from "lucide-react";
import { Suspense, lazy } from "react";
import { useEffect } from "react";
const DailyChart = lazy(() =>
@@ -75,8 +76,8 @@ function AdminDashboardPage() {
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`skeleton-${i}`} className="h-32" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>
</div>
@@ -188,8 +189,8 @@ function AdminDashboardPage() {
{/* Stat Cards */}
{statsLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`stat-skeleton-${i}`} className="h-32" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-32" />
))}
</div>
) : (
@@ -205,9 +206,7 @@ function AdminDashboardPage() {
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
<div>
<p className="text-sm text-muted-foreground">
{card.label}
</p>
<p className="text-sm text-muted-foreground">{card.label}</p>
<p className="text-2xl font-bold">{card.value}</p>
</div>
</CardContent>

View File

@@ -1,21 +1,16 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle,
XCircle,
ExternalLink,
Receipt,
AlertTriangle,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertTriangle, CheckCircle, ExternalLink, Receipt, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { KEYS_5 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/admin/payments")({
component: AdminPaymentsPage,
});
@@ -57,8 +52,7 @@ function AdminPaymentsPage() {
});
const approveMutation = useMutation({
mutationFn: (paymentId: string) =>
api.patch(`/payments/eft/${paymentId}/approve`, {}),
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/approve`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
@@ -67,8 +61,7 @@ function AdminPaymentsPage() {
});
const rejectMutation = useMutation({
mutationFn: (paymentId: string) =>
api.patch(`/payments/eft/${paymentId}/reject`, {}),
mutationFn: (paymentId: string) => api.patch(`/payments/eft/${paymentId}/reject`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
@@ -109,27 +102,21 @@ function AdminPaymentsPage() {
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">EFT Odeme Onaylari</h2>
<Badge variant="secondary">
{payments?.length ?? 0} bekleyen
</Badge>
<Badge variant="secondary">{payments?.length ?? 0} bekleyen</Badge>
</div>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={`payment-skeleton-${i}`} className="h-32 w-full" />
{KEYS_5.map((__k) => (
<Skeleton key={__k} className="h-32 w-full" />
))}
</div>
) : !payments || payments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<CheckCircle className="h-12 w-12 text-green-500" />
<p className="text-lg font-medium">
Bekleyen odeme bulunmuyor
</p>
<p className="text-sm text-muted-foreground">
Tum EFT odemeleri islenmis durumda
</p>
<p className="text-lg font-medium">Bekleyen odeme bulunmuyor</p>
<p className="text-sm text-muted-foreground">Tum EFT odemeleri islenmis durumda</p>
</CardContent>
</Card>
) : (
@@ -141,9 +128,7 @@ function AdminPaymentsPage() {
{/* User Info */}
<div className="space-y-1">
<p className="font-medium">{payment.userName}</p>
<p className="text-sm text-muted-foreground">
{payment.userEmail}
</p>
<p className="text-sm text-muted-foreground">{payment.userEmail}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDate(payment.createdAt)}</span>
<span>|</span>
@@ -153,9 +138,7 @@ function AdminPaymentsPage() {
{/* Amount & Receipt */}
<div className="flex flex-col items-end gap-2">
<p className="text-xl font-bold">
{formatCurrency(payment.amount)}
</p>
<p className="text-xl font-bold">{formatCurrency(payment.amount)}</p>
{payment.eftReceiptUrl ? (
<a
href={payment.eftReceiptUrl}
@@ -194,15 +177,8 @@ function AdminPaymentsPage() {
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
variant={
confirmAction.type === "approve"
? "default"
: "destructive"
}
disabled={
approveMutation.isPending ||
rejectMutation.isPending
}
variant={confirmAction.type === "approve" ? "default" : "destructive"}
disabled={approveMutation.isPending || rejectMutation.isPending}
onClick={() => {
if (confirmAction.type === "approve") {
approveMutation.mutate(payment.id);
@@ -211,8 +187,7 @@ function AdminPaymentsPage() {
}
}}
>
{approveMutation.isPending ||
rejectMutation.isPending
{approveMutation.isPending || rejectMutation.isPending
? "Isleniyor..."
: "Evet, onayla"}
</Button>

View File

@@ -1,19 +1,20 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { KEYS_6 } from "@/lib/keys";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
Search,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Gift,
Search,
Users,
X,
} from "lucide-react";
@@ -180,8 +181,8 @@ function AdminReferralsPage() {
{/* Referrers List */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`ref-skeleton-${i}`} className="h-20 w-full" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-20 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
@@ -207,9 +208,7 @@ function AdminReferralsPage() {
<button
type="button"
className="flex w-full items-center justify-between p-6 text-left transition-colors hover:bg-muted/50"
onClick={() =>
setExpandedReferrer(isExpanded ? null : referrer.referrerId)
}
onClick={() => setExpandedReferrer(isExpanded ? null : referrer.referrerId)}
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
@@ -217,9 +216,7 @@ function AdminReferralsPage() {
</div>
<div>
<p className="font-medium">{referrer.referrerName}</p>
<p className="text-sm text-muted-foreground">
{referrer.referrerEmail}
</p>
<p className="text-sm text-muted-foreground">{referrer.referrerEmail}</p>
</div>
</div>
@@ -252,9 +249,7 @@ function AdminReferralsPage() {
>
<div>
<p className="text-sm font-medium">{ref.referredName}</p>
<p className="text-xs text-muted-foreground">
{ref.referredEmail}
</p>
<p className="text-xs text-muted-foreground">{ref.referredEmail}</p>
</div>
<div className="flex items-center gap-2">
{ref.rewardApplied && (

View File

@@ -1,7 +1,8 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { KEYS_8 } from "@/lib/keys";
import { toast } from "@/lib/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
@@ -16,17 +17,9 @@ import {
DialogHeader,
DialogTitle,
} from "@sase/ui";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "@/lib/toast";
import {
Search,
ChevronLeft,
ChevronRight,
Eye,
ArrowLeft,
X,
UserPlus,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, ChevronLeft, ChevronRight, Eye, Search, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/dashboard/admin/users")({
@@ -185,11 +178,7 @@ function AdminUsersPage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedUserId(null)}
>
<Button variant="ghost" size="sm" onClick={() => setSelectedUserId(null)}>
<ArrowLeft className="mr-1 h-4 w-4" />
Geri
</Button>
@@ -245,15 +234,11 @@ function AdminUsersPage() {
{/* Subscriptions */}
<Card>
<CardHeader>
<CardTitle>
Abonelikler ({userDetail.subscriptions.length})
</CardTitle>
<CardTitle>Abonelikler ({userDetail.subscriptions.length})</CardTitle>
</CardHeader>
<CardContent>
{userDetail.subscriptions.length === 0 ? (
<p className="py-4 text-center text-muted-foreground">
Abonelik bulunmuyor
</p>
<p className="py-4 text-center text-muted-foreground">Abonelik bulunmuyor</p>
) : (
<div className="space-y-3">
{userDetail.subscriptions.map((sub) => (
@@ -279,13 +264,11 @@ function AdminUsersPage() {
</span>
</div>
<p className="text-xs text-muted-foreground">
{sub.startDate ? formatDate(sub.startDate) : "-"}{" "}
- {sub.endDate ? formatDate(sub.endDate) : "-"}
{sub.startDate ? formatDate(sub.startDate) : "-"} -{" "}
{sub.endDate ? formatDate(sub.endDate) : "-"}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatDate(sub.createdAt)}
</p>
<p className="text-xs text-muted-foreground">{formatDate(sub.createdAt)}</p>
</div>
))}
</div>
@@ -296,15 +279,11 @@ function AdminUsersPage() {
{/* Payments */}
<Card>
<CardHeader>
<CardTitle>
Odemeler ({userDetail.payments.length})
</CardTitle>
<CardTitle>Odemeler ({userDetail.payments.length})</CardTitle>
</CardHeader>
<CardContent>
{userDetail.payments.length === 0 ? (
<p className="py-4 text-center text-muted-foreground">
Odeme bulunmuyor
</p>
<p className="py-4 text-center text-muted-foreground">Odeme bulunmuyor</p>
) : (
<div className="space-y-3">
{userDetail.payments.map((payment) => (
@@ -324,14 +303,10 @@ function AdminUsersPage() {
>
{payment.status}
</Badge>
<span className="text-sm">
{payment.method.toUpperCase()}
</span>
<span className="text-sm">{payment.method.toUpperCase()}</span>
</div>
<div className="text-right">
<p className="font-medium">
{formatCurrency(payment.amount)}
</p>
<p className="font-medium">{formatCurrency(payment.amount)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(payment.createdAt)}
</p>
@@ -369,9 +344,7 @@ function AdminUsersPage() {
<DialogContent>
<DialogHeader>
<DialogTitle>Yeni Kullanici Olustur</DialogTitle>
<DialogDescription>
Sisteme yeni bir kullanici ekleyin.
</DialogDescription>
<DialogDescription>Sisteme yeni bir kullanici ekleyin.</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
@@ -426,10 +399,7 @@ function AdminUsersPage() {
</select>
</div>
<DialogFooter>
<Button
type="submit"
disabled={createUserMutation.isPending}
>
<Button type="submit" disabled={createUserMutation.isPending}>
{createUserMutation.isPending ? "Olusturuluyor..." : "Olustur"}
</Button>
</DialogFooter>
@@ -462,8 +432,8 @@ function AdminUsersPage() {
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`row-skeleton-${i}`} className="h-14 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-14 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
@@ -491,7 +461,16 @@ function AdminUsersPage() {
<div
key={u.id}
className="grid cursor-pointer items-center gap-4 px-6 py-4 transition-colors hover:bg-muted/50 lg:grid-cols-7"
// biome-ignore lint/a11y/useSemanticElements: needs grid layout that <button> cannot host
role="button"
tabIndex={0}
onClick={() => setSelectedUserId(u.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedUserId(u.id);
}
}}
>
<div>
<p className="font-medium">{u.name}</p>
@@ -500,9 +479,7 @@ function AdminUsersPage() {
<p className="text-sm text-muted-foreground">{u.email}</p>
</div>
<div>
<Badge variant={roleVariants[u.role] || "secondary"}>
{u.role}
</Badge>
<Badge variant={roleVariants[u.role] || "secondary"}>{u.role}</Badge>
</div>
<div>
<Badge variant={subStatusVariants[u.subscriptionStatus] || "outline"}>
@@ -510,9 +487,7 @@ function AdminUsersPage() {
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatDate(u.createdAt)}
</p>
<p className="text-sm text-muted-foreground">{formatDate(u.createdAt)}</p>
</div>
<div className="text-right">
<Button

View File

@@ -1,4 +1,3 @@
import { createFileRoute } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
@@ -7,6 +6,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Separator } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Download, Filter } from "lucide-react";
import { useState } from "react";

View File

@@ -1,11 +1,15 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
import { Clock, Library, Lock } from "lucide-react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Skeleton, cn } from "@sase/ui";
import { Button } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChevronRight, Columns2, LayoutGrid, Library, List, Lock } from "lucide-react";
import { useState } from "react";
import { KEYS_10 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog/")({
component: CatalogBrandsPage,
});
@@ -18,156 +22,124 @@ interface CatalogBrand {
hasAccess: boolean;
}
interface EmexBrand {
id: string;
catalogId: string;
code: string | null;
brandName: string;
description: string | null;
}
interface PcatCatalog {
id: string;
name: string;
brand: string | null;
imgUrl: string | null;
modelsCount: number;
carsCount: number;
}
function CatalogBrandsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState("pl24");
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().brandViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("brandViewMode", mode);
};
const { data: brands, isLoading } = useQuery({
queryKey: ["catalog-brands"],
queryFn: () => api.get<CatalogBrand[]>("/catalog/brands"),
});
const { data: pcatCatalogs, isLoading: pcatLoading } = useQuery({
queryKey: ["pcat-catalogs"],
queryFn: () => api.get<PcatCatalog[]>("/catalog/pcat/catalogs"),
enabled: tab === "pcat",
});
const { data: emexBrands, isLoading: emexLoading } = useQuery({
queryKey: ["emex-brands"],
queryFn: () => api.get<EmexBrand[]>("/catalog/emex/brands"),
enabled: tab === "emex",
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("catalog.title")}</h1>
<p className="text-sm text-muted-foreground">{t("catalog.brands")}</p>
</div>
<div
role="tablist"
aria-label="Görünüm modu"
className="inline-flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5"
>
{[
{ mode: "grid" as const, Icon: LayoutGrid, label: "Izgara" },
{ mode: "tree" as const, Icon: List, label: "Liste" },
{ mode: "columns" as const, Icon: Columns2, label: "Sütun" },
].map(({ mode, Icon, label }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => changeViewMode(mode)}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md transition-all duration-200",
viewMode === mode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
title={label}
>
<Icon className="size-3.5" />
</button>
))}
</div>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="sasetr">{t("catalog.tabSasetr")}</TabsTrigger>
<TabsTrigger value="pl24">{t("catalog.tabPl24")}</TabsTrigger>
<TabsTrigger value="pcat">{t("catalog.tabPcat")}</TabsTrigger>
<TabsTrigger value="emex">{t("catalog.tabEmex")}</TabsTrigger>
<TabsTrigger value="tecdoc">{t("catalog.tabTecdoc")}</TabsTrigger>
</TabsList>
<TabsContent value="sasetr" className="mt-4">
<ComingSoonPlaceholder />
</TabsContent>
<TabsContent value="pl24" className="mt-4">
{isLoading ? (
<BrandGridSkeleton />
) : !brands || brands.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<PL24BrandCard key={brand.brandName} brand={brand} />
))}
</div>
)}
</TabsContent>
<TabsContent value="pcat" className="mt-4">
{pcatLoading ? (
<BrandGridSkeleton />
) : !pcatCatalogs || pcatCatalogs.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{pcatCatalogs.map((cat) => (
<PcatCatalogCard key={cat.id} catalog={cat} />
))}
</div>
)}
</TabsContent>
<TabsContent value="emex" className="mt-4">
{emexLoading ? (
<BrandGridSkeleton />
) : !emexBrands || emexBrands.length === 0 ? (
<EmptyBrands message={t("catalog.noBrands")} />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{emexBrands.map((brand) => (
<EmexBrandCard key={brand.catalogId} brand={brand} />
))}
</div>
)}
</TabsContent>
<TabsContent value="tecdoc" className="mt-4">
<ComingSoonPlaceholder />
</TabsContent>
</Tabs>
{isLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{KEYS_10.map((__k) => (
<Skeleton key={__k} className="h-28 w-full rounded-xl" />
))}
</div>
) : !brands || brands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.noBrands")}</p>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{brands.map((brand) => (
<BrandCard key={brand.brandName} brand={brand} />
))}
</div>
) : viewMode === "tree" ? (
<BrandListTree brands={brands} />
) : (
<BrandListColumns brands={brands} />
)}
</div>
);
}
function ComingSoonPlaceholder() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Clock className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{t("catalog.comingSoon")}</p>
</div>
);
}
/* ── Grid card (existing) ── */
function BrandGridSkeleton() {
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-28 w-full rounded-xl" />
))}
</div>
);
}
function EmptyBrands({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Library className="mb-4 size-12 text-muted-foreground/40" />
<p className="text-muted-foreground">{message}</p>
</div>
);
}
function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
function BrandCard({ brand }: { brand: CatalogBrand }) {
const { t } = useTranslation();
if (!brand.hasAccess) {
return (
<div className="relative flex flex-col items-center justify-center rounded-xl border border-border/50 bg-muted/30 p-4 text-center opacity-60 select-none">
<Lock className="mb-2 size-5 text-muted-foreground" />
<p className="text-sm font-semibold text-foreground">{brand.brandName}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("catalog.locked")}</p>
<div className="group relative flex flex-col items-center justify-center overflow-hidden rounded-xl border border-border/50 bg-muted/30 p-4 text-center select-none">
{/* Diagonal stripe overlay for locked feel */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.06]"
style={{
backgroundImage:
"repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 8px)",
}}
aria-hidden="true"
/>
<div className="relative mb-2">
<CarBrandLogo
brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="grayscale opacity-70"
/>
<div className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-foreground">
<Lock className="size-2.5 text-background" />
</div>
</div>
<p className="relative text-sm font-semibold text-foreground/70">{brand.brandName}</p>
<p className="relative mt-1 text-[11px] uppercase tracking-wider text-muted-foreground/70">
{t("catalog.locked")}
</p>
<Link
to="/dashboard/subscription"
className="mt-2 text-xs font-medium text-primary hover:underline"
className="relative mt-2 inline-flex items-center gap-1 rounded-full text-xs font-medium text-brand transition-colors hover:text-brand/80"
>
{t("catalog.upgradeCta")}
<ChevronRight className="size-3" />
</Link>
</div>
);
@@ -178,61 +150,122 @@ function PL24BrandCard({ brand }: { brand: CatalogBrand }) {
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
className="group flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-all duration-200 hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-[var(--shadow-md)]"
>
{brand.logoUrl ? (
<img
src={brand.logoUrl}
alt={brand.brandName}
className="mb-2 h-10 w-auto object-contain"
/>
) : (
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-primary/10">
<Library className="size-5 text-primary" />
</div>
)}
<CarBrandLogo
brandName={brand.brandName}
logoUrl={brand.logoUrl}
size={40}
className="mb-2 transition-transform duration-200 group-hover:scale-105"
/>
<p className="text-sm font-semibold">{brand.brandName}</p>
</Link>
);
}
function PcatCatalogCard({ catalog }: { catalog: PcatCatalog }) {
const imgSrc = catalog.imgUrl?.startsWith("//") ? `https:${catalog.imgUrl}` : catalog.imgUrl;
/* ── Tree (flat list) ── */
function BrandListTree({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
return (
<Link
to="/dashboard/catalog/pcat/$catalogId"
params={{ catalogId: catalog.id }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
{imgSrc ? (
<img src={imgSrc} alt={catalog.name} className="mb-2 h-10 w-auto object-contain" />
) : (
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-emerald-500/10">
<Library className="size-5 text-emerald-500" />
</div>
)}
<p className="text-sm font-semibold">{catalog.name}</p>
<p className="mt-1 text-xs text-muted-foreground">
{catalog.modelsCount} model
</p>
</Link>
<div className="divide-y rounded-lg border">
{brands.map((brand) => {
if (!brand.hasAccess) {
return (
<div key={brand.brandName} className="flex items-center gap-3 px-4 py-3 opacity-50">
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
</div>
);
}
return (
<Link
key={brand.brandName}
to="/dashboard/catalog/$brandName"
params={{ brandName: encodeURIComponent(brand.brandName) }}
search={{ catalog: undefined }}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent"
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={24} />
<span className="flex-1 truncate text-sm font-medium">{brand.brandName}</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
);
})}
</div>
);
}
function EmexBrandCard({ brand }: { brand: EmexBrand }) {
/* ── Columns (left: brand list, right: detail + CTA) ── */
function BrandListColumns({ brands }: { brands: CatalogBrand[] }) {
const { t } = useTranslation();
const [selectedName, setSelectedName] = useState<string | null>(null);
const navigate = useNavigate();
const selected = brands.find((b) => b.brandName === selectedName) ?? null;
return (
<Link
to="/dashboard/catalog/emex/$catalogCode"
params={{ catalogCode: brand.catalogId }}
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card p-4 text-center transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-orange-500/10">
<Library className="size-5 text-orange-500" />
<div className="flex border rounded-lg overflow-hidden" style={{ minHeight: 320 }}>
{/* Left panel */}
<div className="w-[240px] shrink-0 border-r overflow-y-auto" style={{ maxHeight: 480 }}>
{brands.map((brand) => (
<button
key={brand.brandName}
type="button"
onClick={() => setSelectedName(brand.brandName)}
disabled={!brand.hasAccess}
className={cn(
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors",
brand.hasAccess ? "hover:bg-accent" : "opacity-50 cursor-not-allowed",
selectedName === brand.brandName && "bg-accent font-medium",
)}
>
<CarBrandLogo brandName={brand.brandName} logoUrl={brand.logoUrl} size={20} />
<span className="flex-1 truncate">{brand.brandName}</span>
{brand.hasAccess ? (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<Lock className="size-3 shrink-0 text-muted-foreground" />
)}
</button>
))}
</div>
<p className="text-sm font-semibold">{brand.brandName}</p>
{brand.description && brand.description !== brand.brandName && (
<p className="mt-1 text-xs text-muted-foreground">{brand.description}</p>
)}
</Link>
{/* Right panel */}
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
{selected ? (
<div className="space-y-4">
<CarBrandLogo brandName={selected.brandName} logoUrl={selected.logoUrl} size={56} />
<p className="text-lg font-semibold">{selected.brandName}</p>
{selected.hasAccess ? (
<Button
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName: encodeURIComponent(selected.brandName) },
search: { catalog: undefined },
})
}
>
Modellere Git
</Button>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t("catalog.locked")}</p>
<Button variant="outline" asChild>
<Link to="/dashboard/subscription">{t("catalog.upgradeCta")}</Link>
</Button>
</div>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Soldan bir marka seçin</p>
)}
</div>
</div>
);
}

View File

@@ -1,9 +1,23 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { ModelListColumns } from "@/components/catalog/model-list-columns";
import { ModelListTree } from "@/components/catalog/model-list-tree";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, BookOpen, Car, ChevronRight, Loader2 } from "lucide-react";
import { KEYS_4, KEYS_9 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
ArrowLeft,
BookOpen,
Car,
ChevronRight,
Columns2,
LayoutGrid,
List,
Loader2,
} from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/$brandName/")({
validateSearch: (search: Record<string, unknown>) => ({
@@ -27,6 +41,7 @@ interface CatalogVehicle {
bodyType: string | null;
transmission: string | null;
architecture: string | null;
catalogPath: string | null;
}
function CatalogModelsPage() {
@@ -37,6 +52,15 @@ function CatalogModelsPage() {
const decodedBrandName = decodeURIComponent(brandName);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().modelViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("modelViewMode", mode);
};
// Always fetch catalogs to know whether this brand has multiple sub-catalogs
const { data: catalogs, isLoading: catalogsLoading } = useQuery({
queryKey: ["catalog-catalogs", decodedBrandName],
@@ -88,11 +112,7 @@ function CatalogModelsPage() {
{" / "}
{isMultiCatalog && activeCatalog ? (
<>
<Link
to="."
search={{ catalog: undefined }}
className="hover:underline"
>
<Link to="." search={{ catalog: undefined }} className="hover:underline">
{decodedBrandName}
</Link>
{" / "}
@@ -110,17 +130,13 @@ function CatalogModelsPage() {
{catalogsLoading ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-24 w-full rounded-xl" />
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-xl" />
))}
</div>
) : isMultiCatalog && !activeCatalog ? (
) : isMultiCatalog && !activeCatalog && catalogs ? (
// Sub-catalog selector
<CatalogSelector
catalogs={catalogs!}
brandName={brandName}
brandLabel={decodedBrandName}
/>
<CatalogSelector catalogs={catalogs} brandName={brandName} brandLabel={decodedBrandName} />
) : modelsLoading ? (
<div>
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
@@ -128,8 +144,8 @@ function CatalogModelsPage() {
{t("catalog.loadingModels")}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={`model-skel-${i}`} className="h-24 w-full rounded-lg" />
{KEYS_9.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
</div>
@@ -139,10 +155,57 @@ function CatalogModelsPage() {
<p className="text-muted-foreground">{t("catalog.noModels")}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
<div className="space-y-3">
{/* View toggle */}
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn(
"rounded p-1.5",
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn(
"rounded p-1.5",
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Liste"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"rounded p-1.5",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
{viewMode === "grid" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard key={model.id} model={model} brandName={brandName} />
))}
</div>
) : viewMode === "tree" ? (
<ModelListTree models={models} brandName={brandName} />
) : (
<ModelListColumns models={models} brandName={brandName} />
)}
</div>
)}
</div>
@@ -196,7 +259,7 @@ function ModelCard({ model, brandName }: { model: CatalogVehicle; brandName: str
<Link
to="/dashboard/catalog/$brandName/$modelId"
params={{ brandName, modelId: model.id }}
search={{ body: undefined, engine: undefined, gearbox: undefined }}
search={{ body: undefined, engine: undefined, gearbox: undefined, mgp: undefined }}
className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<p className="font-semibold">{model.model}</p>

View File

@@ -1,12 +1,16 @@
import { lazy, Suspense } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
import { CategoryGrid } from "@/components/categories/category-grid";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
@@ -21,8 +25,8 @@ function SchemaViewerFallback() {
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full" />
))}
</div>
</div>
@@ -36,6 +40,7 @@ export const Route = createFileRoute(
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}),
component: CatalogCategoryPage,
});
@@ -59,7 +64,18 @@ function CatalogCategoryPage() {
const engine = search.engine;
const gearbox = search.gearbox;
const variantSearch = body || engine || gearbox ? { body, engine, gearbox } : undefined;
const mgp = search.mgp;
const variantSearch =
body || engine || gearbox || mgp ? { body, engine, gearbox, mgp } : undefined;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const { data, isLoading, error } = useQuery({
queryKey: ["catalog-category", modelId, categoryId, body, engine, gearbox],
@@ -77,13 +93,23 @@ function CatalogCategoryPage() {
navigate({
to: "/dashboard/catalog/$brandName/$modelId/categories/$categoryId",
params: { brandName, modelId, categoryId: data.parentId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
search: variantSearch ?? {
body: undefined,
engine: undefined,
gearbox: undefined,
mgp: undefined,
},
});
} else {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: variantSearch ?? { body: undefined, engine: undefined, gearbox: undefined },
search: variantSearch ?? {
body: undefined,
engine: undefined,
gearbox: undefined,
mgp: undefined,
},
});
}
};
@@ -111,30 +137,89 @@ function CatalogCategoryPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title={t("common.back")}>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
</div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div>
<h1 className="text-xl font-bold">{data?.name || t("catalog.categories")}</h1>
</div>
{hasChildren && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn(
"rounded p-1.5",
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn(
"rounded p-1.5",
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Agac"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"rounded p-1.5",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
)}
</div>
{/* Content */}
{hasChildren ? (
<CategoryGrid
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
parentId={categoryId}
variantSearch={variantSearch}
/>
viewMode === "grid" ? (
<CategoryGrid
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
parentId={categoryId}
variantSearch={variantSearch}
/>
) : viewMode === "tree" ? (
<CategoryTree
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryColumns
categories={data.children}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)
) : (
<Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer

View File

@@ -1,21 +1,25 @@
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton, Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { FordVariantSelector } from "@/components/catalog/ford-variant-selector";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/catalog_/$brandName_/$modelId/")({
validateSearch: (search) => ({
body: typeof search.body === "string" ? search.body : undefined,
engine: typeof search.engine === "string" ? search.engine : undefined,
gearbox: typeof search.gearbox === "string" ? search.gearbox : undefined,
mgp: typeof search.mgp === "string" ? search.mgp : undefined,
}),
component: CatalogVehiclePage,
});
@@ -38,15 +42,16 @@ function CatalogVehiclePage() {
const body = search.body;
const engine = search.engine;
const gearbox = search.gearbox;
const hasVariant = !!(body || engine || gearbox);
const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp);
const [viewMode, setViewMode] = useState<"grid" | "tree">(
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const decodedBrandName = decodeURIComponent(brandName);
const changeViewMode = (mode: "grid" | "tree") => {
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
@@ -58,24 +63,46 @@ function CatalogVehiclePage() {
});
const isPsa = vehicle?.architecture === "LEGACY_PSA";
const isP4Legacy = [
"LEGACY_FORD",
"LEGACY_VOLVO",
].includes(vehicle?.architecture);
const isP4Legacy = ["LEGACY_FORD", "LEGACY_VOLVO"].includes(vehicle?.architecture);
const isP5WithRestrictions =
vehicle?.architecture === "P5_MODERN" &&
!!vehicle?.catalogPath &&
!vehicle.catalogPath.includes("/mainGroup");
const showPsaVariantSelector = isPsa && !hasVariant;
const showFordVariantSelector = isP4Legacy && !hasVariant;
const showVariantSelector = showPsaVariantSelector || showFordVariantSelector;
const showP5RestrictionSelector = isP5WithRestrictions && !hasVariant;
const showVariantSelector =
showPsaVariantSelector || showFordVariantSelector || showP5RestrictionSelector;
const variantSearch = hasVariant ? { body, engine, gearbox } : undefined;
const variantSearch = hasVariant ? { body, engine, gearbox, mgp } : undefined;
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox],
queryFn: () =>
api.get<any[]>(`/catalog/vehicles/${modelId}/categories${buildVariantQuery(body, engine, gearbox)}`),
queryKey: ["catalog-category-tree", modelId, body, engine, gearbox, mgp],
queryFn: () => {
const params = new URLSearchParams();
if (body) params.set("body", body);
if (engine) params.set("engine", engine);
if (gearbox) params.set("gearbox", gearbox);
if (mgp) params.set("mgp", mgp);
const qs = params.toString();
return api.get<any[]>(`/catalog/vehicles/${modelId}/categories${qs ? `?${qs}` : ""}`);
},
enabled: !!modelId && !vehicleLoading && !showVariantSelector,
});
const handleVariantSelect = (selectedBody: string, selectedEngine: string, selectedGearbox: string) => {
const handleP5RestrictionComplete = (mainGroupsPath: string) => {
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName, modelId },
search: { mgp: mainGroupsPath, body: undefined, engine: undefined, gearbox: undefined },
});
};
const handleVariantSelect = (
selectedBody: string,
selectedEngine: string,
selectedGearbox: string,
) => {
const norm = (v: string) => (v && v !== "_all_" && v !== "_nor_" ? v : undefined);
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
@@ -87,6 +114,7 @@ function CatalogVehiclePage() {
body: norm(selectedBody) ?? (selectedBody === "_nor_" ? "_nor_" : undefined),
engine: norm(selectedEngine),
gearbox: norm(selectedGearbox),
mgp: undefined,
},
});
};
@@ -103,56 +131,96 @@ function CatalogVehiclePage() {
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Header / Breadcrumb */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName },
search: { catalog: undefined },
})
}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
{" / "}
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() =>
navigate({
to: "/dashboard/catalog/$brandName",
params: { brandName },
search: { catalog: undefined },
})
}
>
<ArrowLeft className="size-4" />
</Button>
<div>
<div className="text-xs text-muted-foreground">
<Link to="/dashboard/catalog" className="hover:underline">
{t("catalog.title")}
</Link>
{" / "}
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName }}
search={{ catalog: undefined }}
className="hover:underline"
>
{decodedBrandName}
</Link>
{" / "}
<span className="font-medium text-foreground">{vehicle?.model}</span>
{hasVariant && (
<>
{body && body !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{body}</span>
</>
)}
{engine && engine !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{engine}</span>
</>
)}
{gearbox && gearbox !== "_all_" && (
<>
<span className="mx-1">/</span>
<span className="font-medium text-foreground">{gearbox}</span>
</>
)}
</>
)}
</div>
<h1 className="text-xl font-bold">
{vehicle?.model}
</span>
{hasVariant && (
<>
{body && body !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{body}</span></>
)}
{engine && engine !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{engine}</span></>
)}
{gearbox && gearbox !== "_all_" && (
<><span className="mx-1">/</span><span className="font-medium text-foreground">{gearbox}</span></>
)}
</>
)}
{vehicle?.year && (
<span className="ml-2 text-base font-normal text-muted-foreground">
({vehicle.year})
</span>
)}
</h1>
</div>
<h1 className="text-xl font-bold">
{vehicle?.model}
{vehicle?.year && <span className="ml-2 text-base font-normal text-muted-foreground">({vehicle.year})</span>}
</h1>
</div>
{/* View toggle — always visible */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Izgara"
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Agac"
>
<List className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={`rounded p-1.5 ${viewMode === "columns" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
title="Sutun"
>
<Columns2 className="size-4" />
</button>
</div>
</div>
@@ -198,32 +266,20 @@ function CatalogVehiclePage() {
<PsaVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showFordVariantSelector ? (
<FordVariantSelector vehicleId={modelId} onSelect={handleVariantSelect} />
) : showP5RestrictionSelector ? (
<P5RestrictionSelector vehicleId={modelId} onComplete={handleP5RestrictionComplete} />
) : (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader>
<CardTitle className="text-base">{t("catalog.categories")}</CardTitle>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={`rounded p-1.5 ${viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<LayoutGrid className="size-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={`rounded p-1.5 ${viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground"}`}
>
<List className="size-4" />
</button>
</div>
</CardHeader>
<CardContent>
<CardContent
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (
@@ -234,7 +290,7 @@ function CatalogVehiclePage() {
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
) : viewMode === "tree" ? (
<CategoryTree
categories={categoryTree || []}
vehicleId={modelId}
@@ -242,6 +298,14 @@ function CatalogVehiclePage() {
brandName={brandName}
variantSearch={variantSearch}
/>
) : (
<CategoryColumns
categories={categoryTree || []}
vehicleId={modelId}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
)}
</CardContent>
</Card>

View File

@@ -1,22 +1,14 @@
import { useState, useMemo, useCallback } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import {
ArrowLeft,
Car,
ChevronRight,
RotateCcw,
AlertCircle,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft, Car, ChevronRight, RotateCcw } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")(
{
component: EmexVehicleListPage,
},
);
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode/")({
component: EmexVehicleListPage,
});
// ── Types ────────────────────────────────────────────
@@ -70,18 +62,12 @@ function EmexVehicleListPage() {
});
// Parse wizard state
const determined = useMemo(
() => wizardRows?.filter((r) => r.determined) ?? [],
[wizardRows],
);
const determined = useMemo(() => wizardRows?.filter((r) => r.determined) ?? [], [wizardRows]);
const undetermined = useMemo(
() =>
wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
() => wizardRows?.filter((r) => !r.determined && r.options?.length > 0) ?? [],
[wizardRows],
);
const allDetermined = wizardRows
? wizardRows.length > 0 && undetermined.length === 0
: false;
const allDetermined = wizardRows ? wizardRows.length > 0 && undetermined.length === 0 : false;
// Get the "Sales Designation" and "Model" from determined params
const wizardMatch = useMemo(() => {
@@ -89,12 +75,7 @@ function EmexVehicleListPage() {
let salesDesignation: string | null = null;
let model: string | null = null;
for (const key of [
"Sales Designation",
"Name",
"Modification",
"Model name",
]) {
for (const key of ["Sales Designation", "Name", "Modification", "Model name"]) {
const row = determined.find((r) => r.name === key);
if (row?.value && row.value !== "None") {
salesDesignation = row.value;
@@ -113,15 +94,10 @@ function EmexVehicleListPage() {
// When all wizard params are determined, search DB for matching vehicles
const { data: matchedVehicles, isLoading: matchLoading } = useQuery({
queryKey: [
"emex-wizard-vehicles",
catalogCode,
wizardMatch?.name,
wizardMatch?.model,
],
queryKey: ["emex-wizard-vehicles", catalogCode, wizardMatch?.name, wizardMatch?.model],
queryFn: () => {
const params = new URLSearchParams({ name: wizardMatch!.name });
if (wizardMatch!.model) params.set("model", wizardMatch!.model);
const params = new URLSearchParams({ name: wizardMatch?.name ?? "" });
if (wizardMatch?.model) params.set("model", wizardMatch?.model);
return api.get<EmexVehicle[]>(
`/catalog/emex/brands/${catalogCode}/wizard-vehicles?${params}`,
);
@@ -149,9 +125,7 @@ function EmexVehicleListPage() {
{t("catalog.backToBrands")}
</Button>
</Link>
<h1 className="text-xl font-bold">
{decodeURIComponent(catalogCode)}
</h1>
<h1 className="text-xl font-bold">{decodeURIComponent(catalogCode)}</h1>
{determined.length > 0 && (
<Button variant="ghost" size="sm" onClick={handleReset}>
<RotateCcw className="mr-1 size-3.5" />
@@ -168,8 +142,7 @@ function EmexVehicleListPage() {
key={r.name}
className="rounded-md border border-border bg-muted/50 px-2 py-1 text-xs"
>
<span className="text-muted-foreground">{r.name}:</span>{" "}
{r.value}
<span className="text-muted-foreground">{r.name}:</span> {r.value}
</span>
))}
</div>
@@ -208,15 +181,9 @@ function EmexVehicleListPage() {
</div>
) : matchedVehicles && matchedVehicles.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{matchedVehicles.length} varyant
</p>
<p className="text-xs text-muted-foreground">{matchedVehicles.length} varyant</p>
{matchedVehicles.map((v) => (
<VehicleRow
key={v.id}
vehicle={v}
catalogCode={catalogCode}
/>
<VehicleRow key={v.id} vehicle={v} catalogCode={catalogCode} />
))}
</div>
) : (
@@ -234,14 +201,9 @@ function EmexVehicleListPage() {
)}
{/* Empty state: no wizard rows at all */}
{!wizardLoading &&
!wizardError &&
wizardRows &&
wizardRows.length === 0 && (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noModels")}
</p>
)}
{!wizardLoading && !wizardError && wizardRows && wizardRows.length === 0 && (
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
)}
</div>
);
}
@@ -267,9 +229,7 @@ function WizardStep({
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{row.name}</h2>
<span className="text-xs text-muted-foreground">
{row.options.length} seçenek
</span>
<span className="text-xs text-muted-foreground">{row.options.length} seçenek</span>
</div>
{row.options.length > 10 && (
@@ -295,9 +255,7 @@ function WizardStep({
</button>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Sonuç bulunamadı
</p>
<p className="py-4 text-center text-sm text-muted-foreground">Sonuç bulunamadı</p>
)}
</div>
</div>
@@ -319,14 +277,10 @@ function VehicleRow({
parts.push(vehicle.optionsRaw);
}
const raw = vehicle.optionsRaw?.toLowerCase() || "";
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
parts.push(vehicle.engine);
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase())) parts.push(vehicle.engine);
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
parts.push(vehicle.bodyType);
if (
vehicle.transmission &&
!raw.includes(vehicle.transmission.toLowerCase())
)
if (vehicle.transmission && !raw.includes(vehicle.transmission.toLowerCase()))
parts.push(vehicle.transmission);
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
parts.push(vehicle.driveType);

View File

@@ -1,15 +1,13 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { EMEX_GROUP_HIERARCHY } from "@/lib/emex-group-hierarchy";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { ArrowLeft, FolderOpen, ChevronRight, ChevronDown } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute(
"/dashboard/catalog_/emex/$catalogCode_/$vehicleId/",
)({
export const Route = createFileRoute("/dashboard/catalog_/emex/$catalogCode_/$vehicleId/")({
component: EmexGroupListPage,
});
@@ -74,19 +72,14 @@ function EmexGroupListPage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link
to="/dashboard/catalog/emex/$catalogCode"
params={{ catalogCode }}
>
<Link to="/dashboard/catalog/emex/$catalogCode" params={{ catalogCode }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
</Button>
</Link>
<h1 className="text-xl font-bold">{t("catalog.categories")}</h1>
{groups && (
<span className="text-sm text-muted-foreground">({groups.length})</span>
)}
{groups && <span className="text-sm text-muted-foreground">({groups.length})</span>}
</div>
{isLoading ? (
@@ -96,9 +89,7 @@ function EmexGroupListPage() {
))}
</div>
) : !groups || groups.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noCategories")}
</p>
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
) : isFlat ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
@@ -195,8 +186,5 @@ function GroupLink({
}
function countGroups(node: TreeNode): number {
return (
node.groups.length +
node.children.reduce((sum, c) => sum + countGroups(c), 0)
);
return node.groups.length + node.children.reduce((sum, c) => sum + countGroups(c), 0);
}

View File

@@ -1,12 +1,12 @@
import { lazy, Suspense, useState, useEffect } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import type { Part, SchemaPic } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import type { Part, SchemaPic } from "@/hooks/use-parts";
import { Suspense, lazy, useEffect, useState } from "react";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
@@ -38,8 +38,7 @@ function EmexGroupPartsPage() {
const { data, isLoading } = useQuery({
queryKey: ["emex-group-parts", vehicleId, groupId],
queryFn: () =>
api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
queryFn: () => api.get<EmexGroupParts>(`/catalog/emex/vehicles/${vehicleId}/groups/${groupId}`),
});
// Reset schema store and image index on group change

View File

@@ -1,13 +1,11 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Library } from "lucide-react";
export const Route = createFileRoute(
"/dashboard/catalog_/pcat/$catalogId/",
)({
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId/")({
component: PcatModelsPage,
});
@@ -27,8 +25,7 @@ function PcatModelsPage() {
const { data: models, isLoading } = useQuery({
queryKey: ["pcat-models", catalogId],
queryFn: () =>
api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
queryFn: () => api.get<PcatModel[]>(`/catalog/pcat/catalogs/${catalogId}/models`),
});
return (
@@ -50,9 +47,7 @@ function PcatModelsPage() {
))}
</div>
) : !models || models.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noModels")}
</p>
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{models.map((model) => (
@@ -64,11 +59,7 @@ function PcatModelsPage() {
>
{model.imgUrl ? (
<img
src={
model.imgUrl.startsWith("//")
? `https:${model.imgUrl}`
: model.imgUrl
}
src={model.imgUrl.startsWith("//") ? `https:${model.imgUrl}` : model.imgUrl}
alt={model.name}
className="mb-2 h-16 w-auto object-contain"
/>
@@ -84,9 +75,7 @@ function PcatModelsPage() {
</p>
)}
{model.carsCount > 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">
{model.carsCount} araç
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{model.carsCount} araç</p>
)}
</Link>
))}

View File

@@ -1,14 +1,12 @@
import { useState, useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Car, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
export const Route = createFileRoute(
"/dashboard/catalog_/pcat/$catalogId_/$modelId/",
)({
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId/")({
component: PcatCarsPage,
});
@@ -35,10 +33,7 @@ function PcatCarsPage() {
const { data: cars, isLoading } = useQuery({
queryKey: ["pcat-cars", catalogId, modelId],
queryFn: () =>
api.get<PcatCar[]>(
`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`,
),
queryFn: () => api.get<PcatCar[]>(`/catalog/pcat/catalogs/${catalogId}/models/${modelId}/cars`),
});
const filtered = useMemo(() => {
@@ -56,10 +51,7 @@ function PcatCarsPage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Link
to="/dashboard/catalog/pcat/$catalogId"
params={{ catalogId }}
>
<Link to="/dashboard/catalog/pcat/$catalogId" params={{ catalogId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
@@ -75,9 +67,7 @@ function PcatCarsPage() {
))}
</div>
) : !cars || cars.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noModels")}
</p>
<p className="py-8 text-center text-muted-foreground">{t("catalog.noModels")}</p>
) : (
<>
{cars.length > 10 && (
@@ -90,9 +80,7 @@ function PcatCarsPage() {
/>
)}
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{filtered.length} araç
</p>
<p className="text-xs text-muted-foreground">{filtered.length} araç</p>
{filtered.map((car) => (
<Link
key={car.id}

View File

@@ -1,14 +1,12 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronRight, FolderOpen } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute(
"/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/",
)({
export const Route = createFileRoute("/dashboard/catalog_/pcat/$catalogId_/$modelId_/$carId/")({
component: PcatCarGroupsPage,
});
@@ -26,23 +24,15 @@ function PcatCarGroupsPage() {
const { t } = useTranslation();
const { catalogId, modelId, carId } = Route.useParams();
const [parentStack, setParentStack] = useState<
{ id: string; name: string }[]
>([]);
const [parentStack, setParentStack] = useState<{ id: string; name: string }[]>([]);
const currentParentId =
parentStack.length > 0
? parentStack[parentStack.length - 1].id
: undefined;
parentStack.length > 0 ? parentStack[parentStack.length - 1].id : undefined;
const { data: groups, isLoading } = useQuery({
queryKey: ["pcat-car-groups", carId, currentParentId || "root"],
queryFn: () => {
const params = currentParentId
? `?parentId=${encodeURIComponent(currentParentId)}`
: "";
return api.get<PcatGroup[]>(
`/catalog/pcat/cars/${carId}/groups${params}`,
);
const params = currentParentId ? `?parentId=${encodeURIComponent(currentParentId)}` : "";
return api.get<PcatGroup[]>(`/catalog/pcat/cars/${carId}/groups${params}`);
},
});
@@ -65,10 +55,7 @@ function PcatCarGroupsPage() {
{t("catalog.backToCategories")}
</Button>
) : (
<Link
to="/dashboard/catalog/pcat/$catalogId/$modelId"
params={{ catalogId, modelId }}
>
<Link to="/dashboard/catalog/pcat/$catalogId/$modelId" params={{ catalogId, modelId }}>
<Button variant="ghost" size="sm">
<ArrowLeft className="mr-1 size-4" />
{t("catalog.backToModels")}
@@ -93,9 +80,7 @@ function PcatCarGroupsPage() {
))}
</div>
) : !groups || groups.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">
{t("catalog.noCategories")}
</p>
<p className="py-8 text-center text-muted-foreground">{t("catalog.noCategories")}</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{groups.map((group) => {

View File

@@ -1,12 +1,12 @@
import { lazy, Suspense, useState, useEffect } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import type { Hotspot, Part, SchemaPic } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react";
import type { Part, SchemaPic, Hotspot } from "@/hooks/use-parts";
import { Suspense, lazy, useEffect, useState } from "react";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
@@ -43,9 +43,7 @@ function PcatSchemaPage() {
const { data: schemaImages, isLoading: imagesLoading } = useQuery({
queryKey: ["pcat-schemas", carId, groupId],
queryFn: () =>
api.get<PcatSchemaImage[]>(
`/catalog/pcat/cars/${carId}/groups/${groupId}/schemas`,
),
api.get<PcatSchemaImage[]>(`/catalog/pcat/cars/${carId}/groups/${groupId}/schemas`),
});
const activeSchema = schemaImages?.[activeImageIndex];
@@ -54,10 +52,7 @@ function PcatSchemaPage() {
// Fetch detail for active schema image
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ["pcat-schema-detail", activeSchema?.id],
queryFn: () =>
api.get<PcatSchemaDetail>(
`/catalog/pcat/schemas/${activeSchema!.id}`,
),
queryFn: () => api.get<PcatSchemaDetail>(`/catalog/pcat/schemas/${activeSchema?.id}`),
enabled: !!activeSchema?.id,
});
@@ -84,9 +79,7 @@ function PcatSchemaPage() {
{t("catalog.backToCategories")}
</Button>
</Link>
<h1 className="text-xl font-bold">
{activeSchema?.name || t("catalog.parts")}
</h1>
<h1 className="text-xl font-bold">{activeSchema?.name || t("catalog.parts")}</h1>
</div>
<Suspense

View File

@@ -1,9 +1,11 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
const HISTORY_SKEL_KEYS = ["h0", "h1", "h2"];
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/history")({
component: HistoryPage,
@@ -24,8 +26,8 @@ function HistoryPage() {
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
{HISTORY_SKEL_KEYS.map((k) => (
<Skeleton key={k} className="h-24 w-full" />
))}
</div>
) : !data || data.length === 0 ? (
@@ -41,11 +43,7 @@ function HistoryPage() {
) : (
<div className="grid gap-4 md:grid-cols-2">
{data.map((vehicle: any) => (
<Link
key={vehicle.id}
to="/dashboard/vehicles/$id"
params={{ id: vehicle.id }}
>
<Link key={vehicle.id} to="/dashboard/vehicles/$id" params={{ id: vehicle.id }}>
<Card className="transition-shadow hover:shadow-md cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">

View File

@@ -1,17 +1,19 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { Button, Badge, Skeleton, Separator } from "@sase/ui";
import { KEYS_4 } from "@/lib/keys";
import { Badge, Button, Separator, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
Search,
Car,
Database,
User,
ArrowRight,
Crown,
Calendar,
Car,
CheckCircle2,
Crown,
Database,
Search,
User,
} from "lucide-react";
export const Route = createFileRoute("/dashboard/")({
@@ -73,9 +75,7 @@ function StatCard({
<p className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
{value}
</p>
<p className="mt-0.5 text-sm font-medium text-muted-foreground">
{label}
</p>
<p className="mt-0.5 text-sm font-medium text-muted-foreground">{label}</p>
</div>
{detail && (
<div className="flex items-center justify-between text-sm">
@@ -178,7 +178,9 @@ function DashboardHome() {
queryKey: ["subscription", "me"],
queryFn: async () => {
try {
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me");
return await api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
);
} catch {
return null;
}
@@ -230,8 +232,8 @@ function DashboardHome() {
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
{isLoadingCards ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`card-skel-${i}`} className="h-64 w-full rounded-2xl" />
{KEYS_4.map((__k) => (
<Skeleton key={__k} className="h-64 w-full rounded-2xl" />
))}
</div>
) : (
@@ -265,19 +267,13 @@ function DashboardHome() {
label="Aktif Marka"
detail="Erişilebilir Marka"
detailValue={maxBrands > 0 ? String(maxBrands) : "—"}
progress={
maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined
}
progress={maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined}
buttonLabel={maxBrands === 0 ? "Plan Seçin" : undefined}
buttonTo={maxBrands === 0 ? "/dashboard/subscription" : undefined}
/>
{/* 4. Profil */}
<ProfileCard
name={user?.name ?? "—"}
email={user?.email ?? "—"}
initials={initials}
/>
<ProfileCard name={user?.name ?? "—"} email={user?.email ?? "—"} initials={initials} />
</div>
)}
@@ -294,7 +290,9 @@ function DashboardHome() {
{subLoading ? (
<Skeleton className="h-48 w-full rounded-2xl" />
) : subscription && (subscription.status === "active" || (subscription.status === "trial" && !subData?.eligibleForTrial)) ? (
) : 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 */}
@@ -308,15 +306,15 @@ function DashboardHome() {
<h3 className="text-lg font-bold">
{subscription.plan?.name ?? "Aktif Plan"}
</h3>
<Badge variant="default" className="bg-emerald-600 text-xs">
<Badge
variant="default"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
{subscription.status === "trial" ? "Deneme" : "Aktif"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{subscription.billingPeriod === "yearly"
? "Yıllık"
: "Aylık"}{" "}
abonelik
{subscription.billingPeriod === "yearly" ? "Yıllık" : "Aylık"} abonelik
</p>
</div>
</div>
@@ -325,7 +323,12 @@ function DashboardHome() {
{subscription.brands && subscription.brands.length > 0 && (
<div className="flex flex-wrap gap-2">
{subscription.brands.map((b) => (
<Badge key={b.brandId} variant="outline">
<Badge
key={b.brandId}
variant="outline"
className="flex items-center gap-1.5"
>
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
{b.brandName}
</Badge>
))}
@@ -339,9 +342,7 @@ function DashboardHome() {
<Calendar className="size-3.5" />
Başlangıç:{" "}
<span className="font-medium text-foreground">
{new Date(subscription.startDate).toLocaleDateString(
"tr-TR",
)}
{new Date(subscription.startDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
@@ -350,9 +351,7 @@ function DashboardHome() {
<Calendar className="size-3.5" />
Bitiş:{" "}
<span className="font-medium text-foreground">
{new Date(subscription.endDate).toLocaleDateString(
"tr-TR",
)}
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
@@ -360,16 +359,12 @@ function DashboardHome() {
{/* Features */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{[
"Sınırsız şase arama",
"Parça kataloğu",
"İnteraktif şema",
].map((f) => (
{["Sınırsız şase arama", "Parça kataloğu", "İnteraktif şema"].map((f) => (
<span
key={f}
className="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<CheckCircle2 className="size-3.5 text-emerald-500" />
<CheckCircle2 className="size-3.5 text-brand" />
{f}
</span>
))}
@@ -432,11 +427,7 @@ function DashboardHome() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{history.slice(0, 3).map((v: any) => (
<Link
key={v.id}
to="/dashboard/vehicles/$id"
params={{ id: v.id }}
>
<Link key={v.id} to="/dashboard/vehicles/$id" params={{ id: v.id }}>
<div className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 transition-colors hover:bg-accent">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Car className="size-4 text-muted-foreground" />
@@ -445,9 +436,7 @@ function DashboardHome() {
<p className="truncate text-sm font-medium">
{v.brandName} {v.model}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{v.vin}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
</div>
<Badge variant="secondary" className="shrink-0">
{v.year}

View File

@@ -1,20 +1,14 @@
import { useState, useEffect, useRef } from "react";
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Button, Input, Badge, Separator } from "@sase/ui";
import {
Search,
Car,
Loader2,
Clock,
AlertCircle,
Send,
} from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { KEYS_17 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { Badge, Button, Input, Separator } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -30,9 +24,16 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
const corrections: string[] = [];
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
const upper = ch.toUpperCase();
if (upper === "I") { corrections.push("I→1"); return "1"; }
if (upper === "O") { corrections.push("O→0"); return "0"; }
corrections.push("Q→9"); return "9";
if (upper === "I") {
corrections.push("I→1");
return "1";
}
if (upper === "O") {
corrections.push("O→0");
return "0";
}
corrections.push("Q→9");
return "9";
});
return { cleaned, corrections };
}
@@ -140,9 +141,7 @@ function SearchPage() {
startAction("vin-decode", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin });
if (!isValidVin(cleanVin)) {
setError(
"Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
);
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
@@ -170,7 +169,8 @@ function SearchPage() {
params: { id: data.id },
});
} catch (err) {
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
capture("vin_decode_error", { vin: cleanVin, error: message });
if (err instanceof ApiError) {
setError(err.message);
@@ -217,7 +217,7 @@ function SearchPage() {
try {
const payload: Record<string, unknown> = { vin: candidateVin };
if (candidateSource === "emex") {
payload.emexCarIndex = parseInt(carId, 10);
payload.emexCarIndex = Number.parseInt(carId, 10);
} else {
payload.pcatCarId = carId;
}
@@ -236,9 +236,7 @@ function SearchPage() {
});
} catch (err) {
const message =
err instanceof ApiError
? err.message
: "Bir hata oluştu. Lütfen tekrar deneyin.";
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
setCandidates(null);
setCandidateSource(null);
@@ -300,11 +298,11 @@ function SearchPage() {
{/* 17-segment progress bar */}
<div className="flex gap-0.5">
{Array.from({ length: 17 }).map((_, i) => (
{KEYS_17.map((k, i) => (
<div
key={i}
key={k}
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-muted"
i < vin.length ? "bg-brand" : "bg-muted"
}`}
/>
))}
@@ -312,9 +310,7 @@ function SearchPage() {
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">
{vin.length}/17 karakter
</span>
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
<button
type="button"
onClick={fillExampleVin}
@@ -391,17 +387,15 @@ function SearchPage() {
{previewLoading && (
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Araç bilgileri alınıyor...
</span>
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
</div>
)}
{preview && !previewLoading && (
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
<div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
<Car className="size-5 text-emerald-500" />
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
<Car className="size-5 text-brand" />
</div>
<div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
@@ -414,7 +408,7 @@ function SearchPage() {
<div className="mt-3 flex flex-wrap gap-2">
<Badge
variant="default"
className="bg-emerald-600 text-xs text-white"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
Araç tanımlandı
</Badge>
@@ -428,7 +422,6 @@ function SearchPage() {
</div>
)}
{/* ─── SECTION 4: Son Aramalar ────────────────────────────────────── */}
{history && history.length > 0 && (
<div className="space-y-4">
@@ -466,9 +459,7 @@ function SearchPage() {
<p className="truncate text-sm font-medium">
{v.brandName} {v.model}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{v.vin}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">{v.vin}</p>
</div>
<Badge variant="secondary" className="shrink-0">
{v.year}

View File

@@ -1,18 +1,10 @@
import { useState, useEffect, useRef } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button, Input, Badge, Separator } from "@sase/ui";
import {
FlaskConical,
Search,
Loader2,
AlertCircle,
Copy,
Check,
Car,
} from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Separator } from "@sase/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, Check, Copy, FlaskConical, Loader2, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -27,9 +19,16 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
const corrections: string[] = [];
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
const upper = ch.toUpperCase();
if (upper === "I") { corrections.push("I→1"); return "1"; }
if (upper === "O") { corrections.push("O→0"); return "0"; }
corrections.push("Q→9"); return "9";
if (upper === "I") {
corrections.push("I→1");
return "1";
}
if (upper === "O") {
corrections.push("O→0");
return "0";
}
corrections.push("Q→9");
return "9";
});
return { cleaned, corrections };
}
@@ -135,7 +134,8 @@ function ServiceTestPage() {
setResult(data);
} catch (err) {
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
setError(message);
toast.error("Servis testi başarısız");
} finally {
@@ -160,7 +160,7 @@ function ServiceTestPage() {
try {
const data = await api.post<any>("/vehicles/decode", {
vin: candidateVin,
emexCarIndex: parseInt(carId, 10),
emexCarIndex: Number.parseInt(carId, 10),
});
setCandidates(null);
if (data.id) {
@@ -262,9 +262,7 @@ function ServiceTestPage() {
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">
{vin.length}/17 karakter
</span>
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
<button
type="button"
onClick={fillExampleVin}
@@ -311,7 +309,8 @@ function ServiceTestPage() {
</p>
<p className="mt-0.5 text-sm text-muted-foreground">
{result.result.vehicle.year || "—"}
{result.result.vehicle.engineCode && ` — Motor: ${result.result.vehicle.engineCode}`}
{result.result.vehicle.engineCode &&
` — Motor: ${result.result.vehicle.engineCode}`}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="default" className="bg-emerald-600 text-xs text-white">
@@ -338,60 +337,55 @@ function ServiceTestPage() {
)}
{/* ─── SECTION 3: Result Card (JSON for failures or non-vehicle results) ── */}
{result && (!result.success || result.result?.type !== "vehicle") && result.result?.type !== "candidates" && (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
{/* Meta badges */}
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="default"
className="bg-blue-600 text-xs text-white"
>
{result.service}
</Badge>
<Badge variant="secondary" className="text-xs">
{result.responseTimeMs.toLocaleString("tr-TR")}ms
</Badge>
<Badge
variant={result.success ? "default" : "destructive"}
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
>
{result.success ? "Başarılı" : "Başarısız"}
</Badge>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={handleCopyJson}
className="h-8 gap-1.5 rounded-lg text-xs"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "Kopyalandı" : "JSON Kopyala"}
</Button>
</div>
{/* Error message */}
{result.error && (
<div className="mt-4 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">{result.error}</p>
{result &&
(!result.success || result.result?.type !== "vehicle") &&
result.result?.type !== "candidates" && (
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
{/* Meta badges */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default" className="bg-blue-600 text-xs text-white">
{result.service}
</Badge>
<Badge variant="secondary" className="text-xs">
{result.responseTimeMs.toLocaleString("tr-TR")}ms
</Badge>
<Badge
variant={result.success ? "default" : "destructive"}
className={`text-xs ${result.success ? "bg-emerald-600 text-white" : ""}`}
>
{result.success ? "Başarılı" : "Başarısız"}
</Badge>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={handleCopyJson}
className="h-8 gap-1.5 rounded-lg text-xs"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? "Kopyalandı" : "JSON Kopyala"}
</Button>
</div>
)}
{/* JSON output */}
{result.result !== null && (
<>
<Separator className="my-4 bg-border" />
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(result.result, null, 2)}
</pre>
</>
)}
</div>
)}
{/* Error message */}
{result.error && (
<div className="mt-4 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">{result.error}</p>
</div>
)}
{/* JSON output */}
{result.result !== null && (
<>
<Separator className="my-4 bg-border" />
<pre className="max-h-[600px] overflow-auto rounded-xl border border-border bg-muted/50 p-4 font-mono text-xs leading-relaxed">
{JSON.stringify(result.result, null, 2)}
</pre>
</>
)}
</div>
)}
{/* ─── Vehicle Selection Modal (EMEX multi-result) ──────────────── */}
{candidates && (

View File

@@ -1,6 +1,6 @@
import { lazy, Suspense } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Skeleton } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, lazy } from "react";
const SettingsContent = lazy(() =>
import("@/components/settings/settings-content").then((mod) => ({

View File

@@ -1,9 +1,11 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { capture, setPeopleProperties } from "@/lib/posthog";
import { useTranslation } from "@/lib/i18n";
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
import { capture, setPeopleProperties } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { getUserSettings } from "@/lib/user-settings";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Button } from "@sase/ui";
@@ -19,10 +21,18 @@ import {
DialogTrigger,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Crown, Sparkles, ShieldCheck, Loader2, CheckCircle2, ArrowRight } from "lucide-react";
import { toast } from "@/lib/toast";
import { getUserSettings } from "@/lib/user-settings";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import confetti from "canvas-confetti";
import {
ArrowRight,
Check,
CheckCircle2,
Crown,
Loader2,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
const BrandSelector = lazy(() =>
import("@/components/subscription/brand-selector").then((mod) => ({
@@ -30,9 +40,7 @@ const BrandSelector = lazy(() =>
})),
);
const LazyPlayer = lazy(() =>
import("@remotion/player").then((mod) => ({ default: mod.Player })),
);
const LazyPlayer = lazy(() => import("@remotion/player").then((mod) => ({ default: mod.Player })));
const LazyOnboardingProgress = lazy(() =>
import("@/remotion/OnboardingProgress").then((mod) => ({
@@ -43,8 +51,8 @@ const LazyOnboardingProgress = lazy(() =>
function BrandSelectorFallback() {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-24 w-full rounded-lg" />
{BRAND_SKELETON_KEYS.map((k) => (
<Skeleton key={k} className="h-24 w-full rounded-lg" />
))}
</div>
);
@@ -148,7 +156,10 @@ function SubscriptionPage() {
const { data: subData, isLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>("/subscriptions/me"),
queryFn: () =>
api.get<{ subscription: Subscription | null; eligibleForTrial: boolean }>(
"/subscriptions/me",
),
});
const subscription = subData?.subscription;
@@ -163,7 +174,7 @@ function SubscriptionPage() {
billing_period: subscription.billingPeriod,
});
}
}, [subscription?.status, subscription?.plan?.key]);
}, [subscription]);
// Apply referral code from Google OAuth callback
useEffect(() => {
@@ -217,7 +228,7 @@ function SubscriptionPage() {
setOnboardingPhase("provisioning");
trialMutation.mutate();
window.history.replaceState({}, "", window.location.pathname);
}, [welcome, subData, eligibleForTrial]);
}, [welcome, subData, eligibleForTrial, trialMutation.mutate]);
// Transition from provisioning → completed when both animation and mutation are done
useEffect(() => {
@@ -231,7 +242,7 @@ function SubscriptionPage() {
spread: 80,
origin: { y: 0.6 },
});
}, [onboardingPhase, animationEnded, trialMutation.isSuccess]);
}, [onboardingPhase, animationEnded, trialMutation.isSuccess, queryClient.invalidateQueries]);
// Animation timer: 210 frames / 30fps = 7s + small buffer
useEffect(() => {
@@ -295,12 +306,12 @@ function SubscriptionPage() {
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" />
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<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">
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
<h2 className="text-xl font-bold text-foreground">
{t("subscription.onboarding.provisioning")}
</h2>
</div>
@@ -308,7 +319,7 @@ function SubscriptionPage() {
<Suspense
fallback={
<div className="flex h-[200px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
<Loader2 className="h-8 w-8 animate-spin text-brand" />
</div>
}
>
@@ -325,7 +336,7 @@ function SubscriptionPage() {
{/* 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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}...
</div>
@@ -337,10 +348,7 @@ function SubscriptionPage() {
<p className="text-sm text-red-600 dark:text-red-400">
{t("subscription.onboarding.error")}
</p>
<Button
variant="outline"
onClick={() => trialMutation.mutate()}
>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
{t("subscription.onboarding.retry")}
</Button>
</div>
@@ -356,24 +364,30 @@ function SubscriptionPage() {
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" />
<Card className="relative w-full overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardContent className="relative flex flex-col items-center gap-6 py-10">
<CheckCircle2 className="h-16 w-16 text-emerald-500" />
<CheckCircle2 className="h-16 w-16 text-brand" />
<h2 className="text-center text-2xl font-bold text-emerald-900 dark:text-emerald-100">
<h2 className="text-center text-2xl font-bold text-foreground">
{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="w-full max-w-md space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5 backdrop-blur-sm dark:bg-background/30">
<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>
<span className="text-sm text-muted-foreground">
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">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>
<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">
@@ -386,8 +400,8 @@ function SubscriptionPage() {
<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" />
<li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
@@ -396,7 +410,7 @@ function SubscriptionPage() {
<Button
size="lg"
className="bg-emerald-600 hover:bg-emerald-700 text-white"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => navigate({ to: "/dashboard/search" })}
>
{t("subscription.onboarding.startSearching")}
@@ -441,7 +455,8 @@ function SubscriptionPage() {
<p className="mb-2 text-sm font-medium">{t("subscription.accessibleBrands")}:</p>
<div className="flex flex-wrap gap-2">
{subscription.brands.map((b) => (
<Badge key={b.brandId} variant="outline">
<Badge key={b.brandId} variant="outline" className="flex items-center gap-1.5">
<CarBrandLogo brandName={b.brandName} size={16} className="shrink-0" />
{b.brandName}
</Badge>
))}
@@ -469,10 +484,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">
<div className="flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-2 text-sm text-brand">
<Sparkles className="h-4 w-4" />
{(() => {
const days = Math.max(0, Math.ceil((new Date(subscription.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)));
const days = Math.max(
0,
Math.ceil(
(new Date(subscription.endDate).getTime() - Date.now()) /
(1000 * 60 * 60 * 24),
),
);
return `${days} gün kaldı`;
})()}
</div>
@@ -513,7 +534,13 @@ function SubscriptionPage() {
</Dialog>
)}
{subscription.status === "cancelled" && (
<Button onClick={() => { capture("subscription_resumed"); resumeMutation.mutate(); }} disabled={resumeMutation.isPending}>
<Button
onClick={() => {
capture("subscription_resumed");
resumeMutation.mutate();
}}
disabled={resumeMutation.isPending}
>
{resumeMutation.isPending
? t("subscription.resuming")
: t("subscription.resumeSubscription")}
@@ -525,44 +552,43 @@ function SubscriptionPage() {
)}
{/* 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={() => {
startAction("trial-start");
capture("trial_started");
trialMutation.mutate();
}}
disabled={trialMutation.isPending}
>
<ShieldCheck className="mr-2 h-4 w-4" />
{trialMutation.isPending ? t("common.loading") : t("subscription.startTrial")}
</Button>
</CardContent>
</Card>
)}
{eligibleForTrial &&
(!subscription || subscription.status === "expired" || subscription.status === "trial") && (
<Card className="relative overflow-hidden border-brand/25 bg-brand/5">
<div className="pointer-events-none absolute -right-24 top-0 h-[300px] w-[300px] rounded-full bg-brand/15 blur-[100px]" />
<CardHeader className="relative">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-brand" />
<CardTitle className="text-foreground">{t("subscription.trialTitle")}</CardTitle>
</div>
<CardDescription className="text-muted-foreground">
{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-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
<Button
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={() => {
startAction("trial-start");
capture("trial_started");
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 */}
{!eligibleForTrial && !subscription && (

View File

@@ -1,6 +1,6 @@
import { lazy, Suspense } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Skeleton } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense, lazy } from "react";
const PaymentContent = lazy(() =>
import("@/components/payment/payment-content").then((mod) => ({

View File

@@ -1,11 +1,14 @@
import { lazy, Suspense } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCategoryParts } from "@/hooks/use-parts";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
import { CategoryTree } from "@/components/categories/category-tree";
import { useCategoryParts } from "@/hooks/use-parts";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Skeleton, cn } from "@sase/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { Suspense, lazy, useState } from "react";
import { KEYS_6, KEYS_8 } from "@/lib/keys";
const SchemaViewer = lazy(() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
@@ -20,8 +23,8 @@ function SchemaViewerFallback() {
</div>
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full" />
))}
</div>
</div>
@@ -31,8 +34,8 @@ function SchemaViewerFallback() {
function CategoryGridFallback() {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`cat-grid-skel-${i}`} className="h-24 w-full rounded-lg" />
{KEYS_6.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
);
@@ -49,6 +52,15 @@ function VehicleCategoryPage() {
const hasChildren = data?.children && data.children.length > 0;
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
const handleBack = () => {
if (data?.parentId) {
navigate({
@@ -66,25 +78,57 @@ function VehicleCategoryPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri don"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">
{data?.name || "Kategori Detayi"}
</h1>
{data?.description && (
<p className="text-sm text-muted-foreground">
{data.description}
</p>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack} title="Geri don">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">{data?.name || "Kategori Detayi"}</h1>
{data?.description && (
<p className="text-sm text-muted-foreground">{data.description}</p>
)}
</div>
</div>
{hasChildren && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => changeViewMode("grid")}
className={cn(
"rounded p-1.5",
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Izgara"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("tree")}
className={cn(
"rounded p-1.5",
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Agac"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"rounded p-1.5",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<Columns2 className="h-4 w-4" />
</button>
</div>
)}
</div>
{/* Error state */}
@@ -95,17 +139,18 @@ function VehicleCategoryPage() {
)}
{/* Loading state */}
{isLoading && !data && (
<CategoryGridFallback />
)}
{isLoading && !data && <CategoryGridFallback />}
{/* Parent category — show children grid */}
{hasChildren && (
<CategoryGrid
categories={data.children!}
vehicleId={id}
/>
)}
{/* Parent category — show children */}
{hasChildren &&
data?.children &&
(viewMode === "grid" ? (
<CategoryGrid categories={data.children} vehicleId={id} />
) : viewMode === "tree" ? (
<CategoryTree categories={data.children} vehicleId={id} />
) : (
<CategoryColumns categories={data.children} vehicleId={id} />
))}
{/* Leaf category — show schema viewer */}
{data && !hasChildren && (

View File

@@ -1,26 +1,29 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Card, CardContent, CardHeader, CardTitle, cn } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft, LayoutGrid, List } from "lucide-react";
import { Button } from "@sase/ui";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Columns2, LayoutGrid, List } from "lucide-react";
import { useState } from "react";
import { KEYS_8 } from "@/lib/keys";
export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
component: VehicleDetailPage,
});
function VehicleDetailPage() {
const { id } = Route.useParams();
const [viewMode, setViewMode] = useState<"grid" | "tree">(
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
const changeViewMode = (mode: "grid" | "tree") => {
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
};
@@ -51,19 +54,19 @@ function VehicleDetailPage() {
<div className="mx-auto max-w-4xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => window.history.back()}
title="Geri don"
>
<Button variant="ghost" size="icon" onClick={() => window.history.back()} title="Geri don">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h2 className="text-2xl font-bold">
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
</h2>
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
<div className="flex items-center gap-3">
{vehicle?.brandName && (
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
)}
<div>
<h2 className="text-2xl font-bold">
{vehicle?.brandName} {vehicle?.model} {vehicle?.year && `(${vehicle.year})`}
</h2>
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
</div>
</div>
</div>
@@ -87,10 +90,9 @@ function VehicleDetailPage() {
onClick={() => changeViewMode("grid")}
className={cn(
"p-1.5 rounded",
viewMode === "grid"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
viewMode === "grid" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Izgara"
>
<LayoutGrid className="h-4 w-4" />
</button>
@@ -99,32 +101,42 @@ function VehicleDetailPage() {
onClick={() => changeViewMode("tree")}
className={cn(
"p-1.5 rounded",
viewMode === "tree"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
viewMode === "tree" ? "bg-accent" : "text-muted-foreground hover:text-foreground",
)}
title="Agac"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => changeViewMode("columns")}
className={cn(
"p-1.5 rounded",
viewMode === "columns"
? "bg-accent"
: "text-muted-foreground hover:text-foreground",
)}
title="Sutun"
>
<Columns2 className="h-4 w-4" />
</button>
</div>
</CardHeader>
<CardContent>
<CardContent
className={viewMode === "columns" ? "p-0 overflow-hidden rounded-b-lg" : undefined}
>
{categoriesLoading ? (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`cat-skel-${i}`} className="h-8 w-full" />
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
) : viewMode === "grid" ? (
<CategoryGrid
categories={categoryTree || []}
vehicleId={id}
/>
<CategoryGrid categories={categoryTree || []} vehicleId={id} />
) : viewMode === "tree" ? (
<CategoryTree categories={categoryTree || []} vehicleId={id} />
) : (
<CategoryTree
categories={categoryTree || []}
vehicleId={id}
/>
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
)}
</CardContent>
</Card>

View File

@@ -1,19 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button, Input } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import {
Search,
Car,
ArrowRight,
Lock,
Loader2,
FolderTree,
MousePointerClick,
Sun,
Moon,
} from "lucide-react";
import { useState, useEffect } from "react";
import { KEYS_16, KEYS_17 } from "@/lib/keys";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Input } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import {
ArrowRight,
Car,
FolderTree,
Loader2,
Lock,
Moon,
MousePointerClick,
Search,
Sun,
} from "lucide-react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/demo")({
component: DemoPage,
@@ -118,17 +119,22 @@ function DemoPage() {
</Link>
<div className="flex items-center gap-3">
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<span className="rounded-full bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-600">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
<span className="size-1.5 rounded-full bg-brand" />
Demo
</span>
<Link to="/register">
<Button size="sm" className="rounded-full bg-foreground text-background hover:bg-foreground/90">
<Button
size="sm"
className="rounded-full bg-foreground text-background hover:bg-foreground/90"
>
Tam Erişim
<ArrowRight className="ml-1.5 size-3.5" />
</Button>
@@ -137,17 +143,22 @@ function DemoPage() {
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
{/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<button
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
type="button"
onClick={() => {
setStep("vin");
setSelectedCategory(null);
}}
className={`rounded-full px-3 py-1 transition ${step === "vin" ? "bg-foreground text-background" : "bg-muted"}`}
>
1. VIN Girin
</button>
<div className="h-px w-6 bg-border" />
<button
type="button"
onClick={() => vinPreview && setStep("categories")}
className={`rounded-full px-3 py-1 transition ${step === "categories" ? "bg-foreground text-background" : "bg-muted"} ${!vinPreview ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={!vinPreview}
@@ -156,6 +167,7 @@ function DemoPage() {
</button>
<div className="h-px w-6 bg-border" />
<button
type="button"
onClick={() => selectedCategory && setStep("schema")}
className={`rounded-full px-3 py-1 transition ${step === "schema" ? "bg-foreground text-background" : "bg-muted"} ${!selectedCategory ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={!selectedCategory}
@@ -189,11 +201,11 @@ function DemoPage() {
{/* Progress bar */}
<div className="flex gap-0.5">
{Array.from({ length: 17 }).map((_, i) => (
{KEYS_17.map((k, i) => (
<div
key={i}
key={k}
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
i < vin.length ? "bg-emerald-500" : "bg-border"
i < vin.length ? "bg-brand" : "bg-border"
}`}
/>
))}
@@ -207,9 +219,9 @@ function DemoPage() {
)}
{vinPreview && !vinLoading && (
<div className="animate-fade-in-up rounded-2xl border border-emerald-500/30 bg-surface p-6">
<div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
<div className="flex items-center gap-3">
<Car className="size-6 text-emerald-500" />
<Car className="size-6 text-brand" />
<div>
<p className="font-semibold text-foreground">
{vinPreview.make} {vinPreview.model}
@@ -221,7 +233,8 @@ function DemoPage() {
</div>
<Button
onClick={() => setStep("categories")}
className="mt-4 w-full rounded-full bg-emerald-600 text-white hover:bg-emerald-700"
variant="brand"
className="mt-4 w-full rounded-full"
>
Parça Kataloğuna Devam Et
<ArrowRight className="ml-2 size-4" />
@@ -237,6 +250,7 @@ function DemoPage() {
{!vin && (
<button
type="button"
onClick={() => setVin("WVWZZZ1JZ3W597935")}
className="mx-auto block text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
@@ -261,7 +275,11 @@ function DemoPage() {
)}
</div>
<button
onClick={() => { setStep("vin"); setSelectedCategory(null); }}
type="button"
onClick={() => {
setStep("vin");
setSelectedCategory(null);
}}
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Farklı VIN dene
@@ -271,6 +289,7 @@ function DemoPage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{EXAMPLE_CATEGORIES.map((cat) => (
<button
type="button"
key={cat.name}
onClick={() => {
setSelectedCategory(cat.name);
@@ -309,6 +328,7 @@ function DemoPage() {
)}
</div>
<button
type="button"
onClick={() => setStep("categories")}
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
@@ -328,14 +348,18 @@ function DemoPage() {
<div className="relative aspect-square bg-muted/50 p-6">
{/* Simplified schema grid */}
<div className="grid h-full grid-cols-4 grid-rows-4 gap-2">
{Array.from({ length: 16 }).map((_, i) => (
{KEYS_16.map((k, i) => (
<div
key={i}
key={k}
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
[2, 5, 9, 13].includes(i) ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-500" : "bg-muted/50"
[2, 5, 9, 13].includes(i)
? "border-brand/50 bg-brand/10 text-brand"
: "bg-muted/50"
}`}
>
{[2, 5, 9, 13].includes(i) ? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position : ""}
{[2, 5, 9, 13].includes(i)
? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position
: ""}
</div>
))}
</div>
@@ -364,7 +388,7 @@ function DemoPage() {
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
</div>
{idx < 2 ? (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-500">
<span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
Görünür
</span>
) : (
@@ -380,7 +404,7 @@ function DemoPage() {
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
<p className="mt-2 text-sm text-muted-foreground">
7 gün ücretsiz deneyin kredi kartı gerekmez
30 gün ücretsiz deneyin kredi kartı gerekmez
</p>
<Link to="/register">
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/kvkk")({
component: KvkkPage,
@@ -25,49 +25,37 @@ function KvkkPage() {
</header>
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">
KVKK Aydınlatma Metni
</h1>
<h1 className="text-4xl font-bold">KVKK Aydınlatma Metni</h1>
<p className="mt-2 text-sm text-muted-foreground">
6698 Sayılı Kişisel Verilerin Korunması Kanunu Kapsamında
Aydınlatma Metni
6698 Sayılı Kişisel Verilerin Korunması Kanunu Kapsamında Aydınlatma Metni
</p>
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground">
1. Veri Sorumlusu
</h2>
<h2 className="text-xl font-semibold text-foreground">1. Veri Sorumlusu</h2>
<p className="mt-3">
6698 sayılı Kişisel Verilerin Korunması Kanunu ("KVKK")
uyarınca, kişisel verileriniz veri sorumlusu sıfatıyla
Sase.tr tarafından aşağıda ıklanan kapsamda işlenmektedir.
6698 sayılı Kişisel Verilerin Korunması Kanunu ("KVKK") uyarınca, kişisel verileriniz
veri sorumlusu sıfatıyla Sase.tr tarafından aşağıda ıklanan kapsamda işlenmektedir.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
2. İşlenen Kişisel Veriler
</h2>
<h2 className="text-xl font-semibold text-foreground">2. İşlenen Kişisel Veriler</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>
<strong>Kimlik Bilgileri:</strong> Ad, soyad
</li>
<li>
<strong>İletişim Bilgileri:</strong> E-posta adresi,
telefon numarası (isteğe bağlı)
<strong>İletişim Bilgileri:</strong> E-posta adresi, telefon numarası (isteğe bağlı)
</li>
<li>
<strong>İşlem Güvenliği:</strong> IP adresi, oturum
bilgileri, log kayıtları
<strong>İşlem Güvenliği:</strong> IP adresi, oturum bilgileri, log kayıtları
</li>
<li>
<strong>Kullanım Verileri:</strong> Arama geçmişi,
platform kullanım istatistikleri
<strong>Kullanım Verileri:</strong> Arama geçmişi, platform kullanım istatistikleri
</li>
<li>
<strong>Finansal Bilgiler:</strong> Fatura bilgileri,
abonelik durumu
<strong>Finansal Bilgiler:</strong> Fatura bilgileri, abonelik durumu
</li>
</ul>
</section>
@@ -77,35 +65,23 @@ function KvkkPage() {
3. Kişisel Verilerin İşlenme Amaçları
</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>Üyelik işlemlerinin gerçekleştirilmesi ve hesap yönetimi</li>
<li>Platform hizmetlerinin sunulması ve iyileştirilmesi</li>
<li>Ödeme ve faturalama işlemlerinin yürütülmesi</li>
<li>Müşteri destek taleplerinin karşılanması</li>
<li>
Üyelik işlemlerinin gerçekleştirilmesi ve hesap yönetimi
</li>
<li>
Platform hizmetlerinin sunulması ve iyileştirilmesi
</li>
<li>
Ödeme ve faturalama işlemlerinin yürütülmesi
</li>
<li>
Müşteri destek taleplerinin karşılanması
</li>
<li>
Yasal yükümlülüklerin yerine getirilmesi (5651 sayılı
Kanun kapsamında log tutma yükümlülüğü dahil)
</li>
<li>
İstatistiksel analiz ve hizmet geliştirme
Yasal yükümlülüklerin yerine getirilmesi (5651 sayılı Kanun kapsamında log tutma
yükümlülüğü dahil)
</li>
<li>İstatistiksel analiz ve hizmet geliştirme</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
4. Hukuki Sebepler
</h2>
<h2 className="text-xl font-semibold text-foreground">4. Hukuki Sebepler</h2>
<p className="mt-3">
Kişisel verileriniz, KVKK'nın 5. maddesinde belirtilen
aşağıdaki hukuki sebeplere dayanılarak işlenmektedir:
Kişisel verileriniz, KVKK'nın 5. maddesinde belirtilen aşağıdaki hukuki sebeplere
dayanılarak işlenmektedir:
</p>
<ul className="mt-2 list-disc space-y-2 pl-6">
<li>Sözleşmenin kurulması ve ifası</li>
@@ -116,84 +92,56 @@ function KvkkPage() {
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
5. Verilerin Aktarımı
</h2>
<h2 className="text-xl font-semibold text-foreground">5. Verilerin Aktarımı</h2>
<p className="mt-3">
Kişisel verileriniz, hizmetin sunulması amacıyla yurt
içindeki iş ortaklarımız (hosting, ödeme altyapısı) ve
yasal zorunluluk halinde yetkili kamu kurum ve kuruluşlarıyla
paylaşılabilir. Yurt dışına veri aktarımı, KVKK'nın 9.
maddesi kapsamındaki güvencelere uygun olarak
gerçekleştirilir.
Kişisel verileriniz, hizmetin sunulması amacıyla yurt içindeki iş ortaklarımız
(hosting, ödeme altyapısı) ve yasal zorunluluk halinde yetkili kamu kurum ve
kuruluşlarıyla paylaşılabilir. Yurt dışına veri aktarımı, KVKK'nın 9. maddesi
kapsamındaki güvencelere uygun olarak gerçekleştirilir.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
6. Veri Saklama Süresi
</h2>
<h2 className="text-xl font-semibold text-foreground">6. Veri Saklama Süresi</h2>
<p className="mt-3">
Kişisel verileriniz, işlenme amaçlarının gerektirdiği süre
boyunca ve yasal saklama yükümlülükleri kapsamında muhafaza
edilir. Süre sona erdiğinde veriler silinir, yok edilir
veya anonim hale getirilir.
Kişisel verileriniz, işlenme amaçlarının gerektirdiği süre boyunca ve yasal saklama
yükümlülükleri kapsamında muhafaza edilir. Süre sona erdiğinde veriler silinir, yok
edilir veya anonim hale getirilir.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
7. Haklarınız
</h2>
<p className="mt-3">
KVKK'nın 11. maddesi uyarınca aşağıdaki haklara
sahipsiniz:
</p>
<h2 className="text-xl font-semibold text-foreground">7. Haklarınız</h2>
<p className="mt-3">KVKK'nın 11. maddesi uyarınca aşağıdaki haklara sahipsiniz:</p>
<ul className="mt-2 list-disc space-y-2 pl-6">
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
<li>İşlenme amacını ve amacına uygun kullanılıp kullanılmadığını öğrenme</li>
<li>Yurt içinde veya yurt dışında aktarıldığı üçüncü kişileri bilme</li>
<li>Eksik veya yanlış işlenmişse düzeltilmesini isteme</li>
<li>
İşlenme amacını ve amacına uygun kullanılıp
kullanılmadığını öğrenme
KVKK'nın 7. maddesindeki şartlar çerçevesinde silinmesini veya yok edilmesini isteme
</li>
<li>
Yurt içinde veya yurt dışında aktarıldığı üçüncü
kişileri bilme
İşlenen verilerin münhasıran otomatik sistemler vasıtasıyla analiz edilmesi
suretiyle aleyhinize bir sonucun ortaya çıkmasına itiraz etme
</li>
<li>
Eksik veya yanlış işlenmişse düzeltilmesini isteme
</li>
<li>
KVKK'nın 7. maddesindeki şartlar çerçevesinde silinmesini
veya yok edilmesini isteme
</li>
<li>
İşlenen verilerin münhasıran otomatik sistemler
vasıtasıyla analiz edilmesi suretiyle aleyhinize bir
sonucun ortaya çıkmasına itiraz etme
</li>
<li>
Kanuna aykırı işlenmesi sebebiyle zarara uğramanız
halinde zararın giderilmesini talep etme
Kanuna aykırı işlenmesi sebebiyle zarara uğramanız halinde zararın giderilmesini
talep etme
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
8. Başvuru Yöntemi
</h2>
<h2 className="text-xl font-semibold text-foreground">8. Başvuru Yöntemi</h2>
<p className="mt-3">
Yukarıda belirtilen haklarınızı kullanmak için{" "}
<a
href="mailto:info@sase.tr"
className="text-primary underline"
>
<a href="mailto:info@sase.tr" className="text-primary underline">
info@sase.tr
</a>{" "}
adresine kimliğinizi tespit edici belgelerle birlikte
yazılı olarak başvurabilirsiniz. Başvurular en geç 30 gün
içinde sonuçlandırılır.
adresine kimliğinizi tespit edici belgelerle birlikte yazılı olarak başvurabilirsiniz.
Başvurular en geç 30 gün içinde sonuçlandırılır.
</p>
</section>
</div>

View File

@@ -1,8 +1,8 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
import { Badge } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/pricing")({
component: PricingPage,
@@ -63,7 +63,7 @@ function PricingPage() {
usePageMeta({
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
description:
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.",
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 30 gün ücretsiz deneyin.",
canonical: "https://sase.tr/pricing",
});
@@ -85,12 +85,10 @@ function PricingPage() {
</div>
</header>
<main className="container mx-auto px-4 py-24">
<main id="main-content" className="container mx-auto px-4 py-24">
<div className="text-center">
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
<p className="mt-4 text-lg text-muted-foreground">
İhtiyacınıza uygun planı seçin.
</p>
<p className="mt-4 text-lg text-muted-foreground">İhtiyacınıza uygun planı seçin.</p>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/privacy")({
component: PrivacyPage,
@@ -26,51 +26,42 @@ function PrivacyPage() {
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Gizlilik Politikası</h1>
<p className="mt-2 text-sm text-muted-foreground">
Son güncelleme: 15 Şubat 2026
</p>
<p className="mt-2 text-sm text-muted-foreground">Son güncelleme: 15 Şubat 2026</p>
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground">
1. Genel Bakış
</h2>
<h2 className="text-xl font-semibold text-foreground">1. Genel Bakış</h2>
<p className="mt-3">
Sase.tr ("Platform") olarak kullanıcılarımızın gizliliğine
önem veriyoruz. Bu politika, kişisel verilerinizin nasıl
toplandığını, işlendiğini ve korunduğunu ıklar.
Sase.tr ("Platform") olarak kullanıcılarımızın gizliliğine önem veriyoruz. Bu
politika, kişisel verilerinizin nasıl toplandığını, işlendiğini ve korunduğunu
ıklar.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
2. Toplanan Veriler
</h2>
<h2 className="text-xl font-semibold text-foreground">2. Toplanan Veriler</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>
<strong>Hesap Bilgileri:</strong> Ad, e-posta adresi,
telefon numarası (isteğe bağlı).
<strong>Hesap Bilgileri:</strong> Ad, e-posta adresi, telefon numarası (isteğe
bağlı).
</li>
<li>
<strong>Kullanım Verileri:</strong> Arama geçmişi, VIN
sorguları, sayfa görüntüleme istatistikleri.
<strong>Kullanım Verileri:</strong> Arama geçmişi, VIN sorguları, sayfa görüntüleme
istatistikleri.
</li>
<li>
<strong>Teknik Veriler:</strong> IP adresi, tarayıcı türü,
cihaz bilgisi, çerez verileri.
<strong>Teknik Veriler:</strong> IP adresi, tarayıcı türü, cihaz bilgisi, çerez
verileri.
</li>
<li>
<strong>Ödeme Bilgileri:</strong> Ödeme işlemleri üçüncü
parti ödeme sağlayıcıları aracılığıyla gerçekleştirilir.
Kredi kartı bilgileri tarafımızca saklanmaz.
<strong>Ödeme Bilgileri:</strong> Ödeme işlemleri üçüncü parti ödeme sağlayıcıları
aracılığıyla gerçekleştirilir. Kredi kartı bilgileri tarafımızca saklanmaz.
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
3. Verilerin Kullanım Amacı
</h2>
<h2 className="text-xl font-semibold text-foreground">3. Verilerin Kullanım Amacı</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>Hizmetin sunulması ve iyileştirilmesi</li>
<li>Kullanıcı hesaplarının yönetimi</li>
@@ -81,52 +72,37 @@ function PrivacyPage() {
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
4. Veri Paylaşımı
</h2>
<h2 className="text-xl font-semibold text-foreground">4. Veri Paylaşımı</h2>
<p className="mt-3">
Kişisel verileriniz, yasal zorunluluklar dışında üçüncü
taraflarla paylaşılmaz. Hizmet sağlayıcılarımız (hosting,
ödeme altyapısı) yalnızca hizmetin işletilmesi için gerekli
Kişisel verileriniz, yasal zorunluluklar dışında üçüncü taraflarla paylaşılmaz. Hizmet
sağlayıcılarımız (hosting, ödeme altyapısı) yalnızca hizmetin işletilmesi için gerekli
olan verilere erişir ve gizlilik sözleşmeleri ile bağlıdır.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
5. Çerezler
</h2>
<h2 className="text-xl font-semibold text-foreground">5. Çerezler</h2>
<p className="mt-3">
Platform, oturum yönetimi ve kullanıcı deneyimini
iyileştirmek amacıyla çerezler kullanır. Zorunlu çerezler
hizmetin çalışması için gereklidir. Analitik çerezler
Platform, oturum yönetimi ve kullanıcı deneyimini iyileştirmek amacıyla çerezler
kullanır. Zorunlu çerezler hizmetin çalışması için gereklidir. Analitik çerezler
kullanıcı tercihine bağlıdır.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
6. Veri Güvenliği
</h2>
<h2 className="text-xl font-semibold text-foreground">6. Veri Güvenliği</h2>
<p className="mt-3">
Verileriniz SSL/TLS şifrelemesi ile korunur. Sunucularımız
güvenli veri merkezlerinde barındırılır ve düzenli güvenlik
denetimleri yapılır.
Verileriniz SSL/TLS şifrelemesi ile korunur. Sunucularımız güvenli veri merkezlerinde
barındırılır ve düzenli güvenlik denetimleri yapılır.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
7. Haklarınız
</h2>
<h2 className="text-xl font-semibold text-foreground">7. Haklarınız</h2>
<p className="mt-3">
KVKK kapsamında kişisel verilerinize erişim, düzeltme,
silme ve işlemeye itiraz etme haklarına sahipsiniz.
Talepleriniz için{" "}
<a
href="mailto:info@sase.tr"
className="text-primary underline"
>
KVKK kapsamında kişisel verilerinize erişim, düzeltme, silme ve işlemeye itiraz etme
haklarına sahipsiniz. Talepleriniz için{" "}
<a href="mailto:info@sase.tr" className="text-primary underline">
info@sase.tr
</a>{" "}
adresine başvurabilirsiniz.
@@ -134,13 +110,10 @@ function PrivacyPage() {
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
8. Değişiklikler
</h2>
<h2 className="text-xl font-semibold text-foreground">8. Değişiklikler</h2>
<p className="mt-3">
Bu politika zaman zaman güncellenebilir. Önemli
değişiklikler e-posta veya platform içi bildirim yoluyla
duyurulur.
Bu politika zaman zaman güncellenebilir. Önemli değişiklikler e-posta veya platform
içi bildirim yoluyla duyurulur.
</p>
</section>
</div>

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/terms")({
component: TermsPage,
@@ -26,37 +26,28 @@ function TermsPage() {
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Kullanım Koşulları</h1>
<p className="mt-2 text-sm text-muted-foreground">
Son güncelleme: 15 Şubat 2026
</p>
<p className="mt-2 text-sm text-muted-foreground">Son güncelleme: 15 Şubat 2026</p>
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
<section>
<h2 className="text-xl font-semibold text-foreground">
1. Kabul ve Onay
</h2>
<h2 className="text-xl font-semibold text-foreground">1. Kabul ve Onay</h2>
<p className="mt-3">
Sase.tr platformunu ("Platform") kullanarak bu kullanım
koşullarını kabul etmiş sayılırsınız. Koşulları kabul
etmiyorsanız platformu kullanmayınız.
Sase.tr platformunu ("Platform") kullanarak bu kullanım koşullarını kabul etmiş
sayılırsınız. Koşulları kabul etmiyorsanız platformu kullanmayınız.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
2. Hizmet Tanımı
</h2>
<h2 className="text-xl font-semibold text-foreground">2. Hizmet Tanımı</h2>
<p className="mt-3">
Platform, şase numarası (VIN) ile araç tanımlama, orijinal
yedek parça kataloğuna erişim ve interaktif şema görüntüleme
hizmetleri sunar. Hizmetler abonelik modeli ile sunulur.
Platform, şase numarası (VIN) ile araç tanımlama, orijinal yedek parça kataloğuna
erişim ve interaktif şema görüntüleme hizmetleri sunar. Hizmetler abonelik modeli ile
sunulur.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
3. Hesap Oluşturma
</h2>
<h2 className="text-xl font-semibold text-foreground">3. Hesap Oluşturma</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>Kayıt sırasında doğru ve güncel bilgi vermekle yükümlüsünüz.</li>
<li>Hesap bilgilerinizin güvenliğinden siz sorumlusunuz.</li>
@@ -66,30 +57,19 @@ function TermsPage() {
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
4. Abonelik ve Ödeme
</h2>
<h2 className="text-xl font-semibold text-foreground">4. Abonelik ve Ödeme</h2>
<ul className="mt-3 list-disc space-y-2 pl-6">
<li>Abonelikler aylık veya yıllık olarak faturalandırılır.</li>
<li>Yıllık aboneliklerde indirimli fiyat uygulanır.</li>
<li>Abonelik, dönem sonunda otomatik olarak yenilenir.</li>
<li>
Abonelikler aylık veya yıllık olarak faturalandırılır.
</li>
<li>
Yıllık aboneliklerde indirimli fiyat uygulanır.
</li>
<li>
Abonelik, dönem sonunda otomatik olarak yenilenir.
</li>
<li>
İptal işlemi mevcut dönemin sonunda geçerli olur;
kalan süre için iade yapılmaz.
İptal işlemi mevcut dönemin sonunda geçerli olur; kalan süre için iade yapılmaz.
</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
5. Kullanım Kuralları
</h2>
<h2 className="text-xl font-semibold text-foreground">5. Kullanım Kuralları</h2>
<p className="mt-3">Aşağıdaki eylemler yasaktır:</p>
<ul className="mt-2 list-disc space-y-2 pl-6">
<li>Platformdaki verilerin toplu olarak çekilmesi (scraping)</li>
@@ -100,47 +80,36 @@ function TermsPage() {
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
6. Fikri Mülkiyet
</h2>
<h2 className="text-xl font-semibold text-foreground">6. Fikri Mülkiyet</h2>
<p className="mt-3">
Platform üzerindeki tüm içerik, tasarım, yazılım ve
veritabanı Sase.tr'ye aittir. Kullanıcılar yalnızca
kişisel kullanım amacıyla erişim hakkına sahiptir.
Platform üzerindeki tüm içerik, tasarım, yazılım ve veritabanı Sase.tr'ye aittir.
Kullanıcılar yalnızca kişisel kullanım amacıyla erişim hakkına sahiptir.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
7. Sorumluluk Sınırı
</h2>
<h2 className="text-xl font-semibold text-foreground">7. Sorumluluk Sınırı</h2>
<p className="mt-3">
Platform, sunulan bilgilerin doğruluğu için azami özeni
gösterir ancak verilerin eksiksiz veya hatasız olduğunu
garanti etmez. Yedek parça alım kararlarında son sorumluluk
kullanıcıya aittir.
Platform, sunulan bilgilerin doğruluğu için azami özeni gösterir ancak verilerin
eksiksiz veya hatasız olduğunu garanti etmez. Yedek parça alım kararlarında son
sorumluluk kullanıcıya aittir.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
8. Hizmet Değişiklikleri
</h2>
<h2 className="text-xl font-semibold text-foreground">8. Hizmet Değişiklikleri</h2>
<p className="mt-3">
Sase.tr, hizmet içeriğini, fiyatlandırmayı ve bu koşulları
önceden bildirimde bulunarak değiştirme hakkını saklı tutar.
Önemli değişiklikler en az 30 gün önce duyurulur.
Sase.tr, hizmet içeriğini, fiyatlandırmayı ve bu koşulları önceden bildirimde
bulunarak değiştirme hakkını saklı tutar. Önemli değişiklikler en az 30 gün önce
duyurulur.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-foreground">
9. Uyuşmazlık Çözümü
</h2>
<h2 className="text-xl font-semibold text-foreground">9. Uyuşmazlık Çözümü</h2>
<p className="mt-3">
Bu koşullar Türkiye Cumhuriyeti kanunlarına tabidir.
Uyuşmazlıklarda İstanbul mahkemeleri ve icra daireleri
yetkilidir.
Bu koşullar Türkiye Cumhuriyeti kanunlarına tabidir. Uyuşmazlıklarda İstanbul
mahkemeleri ve icra daireleri yetkilidir.
</p>
</section>
</div>