feat(FN-094): add comment line for deployment verification
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
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:
@@ -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" />
|
||||
</>
|
||||
|
||||
115
apps/web/src/routes/__tests__/index.test.tsx
Normal file
115
apps/web/src/routes/__tests__/index.test.tsx
Normal 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 logged‑in 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 side‑effects
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
“Sase.tr'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.”
|
||||
<div className="rounded-2xl border border-border bg-surface/40 p-6 backdrop-blur-sm">
|
||||
<p className="text-sm leading-relaxed text-foreground/85">
|
||||
“Sase.tr'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.”
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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ı, açıklamalar ve çapraz referanslar.
|
||||
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça numaraları, açıklamalar ve
|
||||
çapraz referanslar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden
|
||||
görsel parça seçimi ve detay gö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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, iç 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 iş 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 iş 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 açılır.
|
||||
Şema üzerindeki parçalara tıklayarak OEM kodunu, açı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>
|
||||
),
|
||||
|
||||
@@ -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 iş birliği talepleriniz için bize
|
||||
ulaşın.
|
||||
Sorularınız, önerileriniz veya iş 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>
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası,
|
||||
Kullanım Koşulları
|
||||
© {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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 açı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 açı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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 açı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
|
||||
açı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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user