Initial commit: Sase.tr VIN Sorgulama Platformu

Features:
- Next.js 16.1.3 frontend with Turbopack
- NestJS API with Prisma ORM
- EMEX VIN scraper integration
- Turkish translations for automotive parts
- JWT authentication with refresh tokens
- PM2 production deployment

Tech Stack:
- Frontend: Next.js 16.1, React 19, TailwindCSS
- Backend: NestJS, Prisma, MySQL
- Scraping: Puppeteer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-17 03:04:30 +01:00
commit b9532a61ee
195 changed files with 31777 additions and 0 deletions

20
apps/web/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# ===========================================
# Web Environment Configuration
# ===========================================
# ----- App -----
NEXT_PUBLIC_APP_NAME=Sase.tr
NEXT_PUBLIC_APP_URL=https://sase.tr
NEXT_PUBLIC_API_URL=https://sase.tr/api
# ----- NextAuth (if using) -----
NEXTAUTH_URL=https://sase.tr
NEXTAUTH_SECRET=your-nextauth-secret-min-32-chars
# ----- Social Login (Optional) -----
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=

View File

@@ -0,0 +1,125 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, ArrowLeft, Mail, CheckCircle2, Sparkles, Send } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
const forgotPasswordSchema = z.object({
email: z.string().email('Gecersiz email adresi'),
});
type ForgotPasswordForm = z.infer<typeof forgotPasswordSchema>;
export default function ForgotPasswordPage() {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSent, setIsSent] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<ForgotPasswordForm>({
resolver: zodResolver(forgotPasswordSchema),
});
const onSubmit = async (data: ForgotPasswordForm) => {
setIsLoading(true);
try {
await apiClient.post('/auth/forgot-password', data);
setIsSent(true);
toast({ title: 'Basarili', description: 'Sifre sifirlama linki gonderildi' });
} catch (error: any) {
toast({
title: 'Hata',
description: error.response?.data?.error?.message || 'Islem basarisiz',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
if (isSent) {
return (
<Card className="border-0 shadow-xl overflow-hidden">
<div className="h-1 bg-green-500" />
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-4">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h2 className="text-xl font-semibold mb-2">Email Gonderildi!</h2>
<p className="text-muted-foreground text-center mb-6 max-w-xs">
Sifre sifirlama linki email adresinize gonderildi. Lutfen gelen kutunuzu kontrol edin.
</p>
<Link href="/login" className="w-full">
<Button variant="outline" className="w-full border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20">
<ArrowLeft className="mr-2 h-4 w-4" />
Girise don
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<Card className="border-0 shadow-xl overflow-hidden">
<div className="h-1 gradient-bg" />
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl gradient-bg flex items-center justify-center lg:hidden">
<Sparkles className="h-6 w-6 text-white" />
</div>
<CardTitle className="text-2xl">Sifremi Unuttum</CardTitle>
<CardDescription>
Email adresinizi girin, size sifre sifirlama linki gonderelim
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4 pt-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email
</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
className="h-12 border-2 focus:border-purple-500"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<>
<Send className="mr-2 h-5 w-5" />
Sifirlama Linki Gonder
</>
)}
</Button>
<Link href="/login" className="inline-flex items-center text-sm text-purple-600 hover:text-purple-700 hover:underline">
<ArrowLeft className="mr-1 h-4 w-4" />
Girise don
</Link>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
import Link from 'next/link';
import { Car } from 'lucide-react';
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex">
{/* Left side - Branding */}
<div className="hidden lg:flex lg:w-1/2 gradient-bg relative overflow-hidden">
{/* Grid pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff08_1px,transparent_1px),linear-gradient(to_bottom,#ffffff08_1px,transparent_1px)] bg-[size:3rem_3rem]" />
{/* Floating elements */}
<div className="absolute top-20 left-20 h-32 w-32 rounded-full bg-white/10 blur-2xl animate-pulse-slow" />
<div className="absolute bottom-40 right-20 h-40 w-40 rounded-full bg-white/10 blur-2xl animate-pulse-slow" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/3 h-24 w-24 rounded-full bg-white/10 blur-2xl animate-pulse-slow" style={{ animationDelay: '4s' }} />
<div className="relative z-10 flex flex-col justify-center px-12 text-white">
<Link href="/" className="flex items-center gap-3 mb-12">
<div className="h-12 w-12 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
<Car className="h-7 w-7 text-white" />
</div>
<span className="text-3xl font-bold">Sase.tr</span>
</Link>
<h1 className="text-4xl font-bold mb-4 leading-tight">
Arac Bilgilerine<br />
Saniyeler Icinde Ulasin
</h1>
<p className="text-lg text-white/80 max-w-md mb-8">
VIN numarasi ile aracinizin tum teknik detaylarina, yedek parca kodlarina ve guncel fiyatlarina erisim saglayin.
</p>
<div className="flex items-center gap-8">
<div>
<p className="text-3xl font-bold">50+</p>
<p className="text-sm text-white/70">Marka</p>
</div>
<div className="h-10 w-px bg-white/20" />
<div>
<p className="text-3xl font-bold">1M+</p>
<p className="text-sm text-white/70">Parca</p>
</div>
<div className="h-10 w-px bg-white/20" />
<div>
<p className="text-3xl font-bold">10K+</p>
<p className="text-sm text-white/70">Kullanici</p>
</div>
</div>
</div>
</div>
{/* Right side - Form */}
<div className="flex-1 flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-6">
<div className="w-full max-w-md">
{/* Mobile logo */}
<div className="lg:hidden flex justify-center mb-8">
<Link href="/" className="flex items-center gap-2">
<div className="h-10 w-10 rounded-xl gradient-bg flex items-center justify-center">
<Car className="h-6 w-6 text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Sase.tr</span>
</Link>
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState, FormEvent } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader2, Mail, Lock, ArrowRight, CheckCircle2, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import { apiClient } from '@/lib/api-client';
export default function LoginPage() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loginSuccess, setLoginSuccess] = useState(false);
const handleLogin = async (e: FormEvent) => {
e.preventDefault();
e.stopPropagation();
if (!email || !password) {
setError('Email ve sifre gerekli');
return;
}
setIsLoading(true);
setError('');
try {
const response = await apiClient.post('/auth/login', { email, password });
const data = response.data;
localStorage.setItem('accessToken', data.data.accessToken);
localStorage.setItem('refreshToken', data.data.refreshToken);
setLoginSuccess(true);
router.push('/dashboard');
setTimeout(() => {
window.location.href = '/dashboard';
}, 500);
} catch (err: any) {
setError(err.message || 'Giris yapilamadi');
toast({
title: 'Hata',
description: err.message || 'Giris yapilamadi',
variant: 'destructive',
});
setIsLoading(false);
}
};
if (loginSuccess) {
return (
<Card className="border-0 shadow-xl">
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-4">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h2 className="text-xl font-semibold mb-2">Giris Basarili!</h2>
<p className="text-muted-foreground mb-6">Yonlendiriliyorsunuz...</p>
<Link href="/dashboard">
<Button className="gradient-bg hover:opacity-90">
Dashboard&apos;a Git
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<Card className="border-0 shadow-xl overflow-hidden">
<div className="h-1 gradient-bg" />
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl gradient-bg flex items-center justify-center lg:hidden">
<Sparkles className="h-6 w-6 text-white" />
</div>
<CardTitle className="text-2xl">Tekrar Hosgeldiniz</CardTitle>
<CardDescription>Hesabiniza giris yapin</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4 pt-4">
{error && (
<div className="flex items-center gap-2 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email
</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
className="h-12 border-2 focus:border-purple-500"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
Sifre
</Label>
<Input
id="password"
type="password"
placeholder="********"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
className="h-12 border-2 focus:border-purple-500"
/>
</div>
<div className="flex justify-end">
<Link href="/forgot-password" className="text-sm text-purple-600 hover:text-purple-700 hover:underline">
Sifremi unuttum
</Link>
</div>
</CardContent>
<CardFooter className="flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<>
Giris Yap
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
<p className="text-sm text-muted-foreground">
Hesabiniz yok mu?{' '}
<Link href="/register" className="text-purple-600 hover:text-purple-700 font-medium hover:underline">
Kayit ol
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, Mail, Lock, User, ArrowRight, Sparkles, CheckCircle2 } from 'lucide-react';
import { useAuth } from '@/providers/auth-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
const registerSchema = z.object({
name: z.string().min(2, 'Ad en az 2 karakter olmalidir').optional().or(z.literal('')),
email: z.string().email('Gecersiz email adresi'),
password: z.string()
.min(8, 'Sifre en az 8 karakter olmalidir')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, 'Sifre en az bir harf ve bir rakam icermelidir'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Sifreler eslesmiyor',
path: ['confirmPassword'],
});
type RegisterForm = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const { register: registerUser } = useAuth();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterForm) => {
setIsLoading(true);
try {
await registerUser(data.email, data.password, data.name || undefined);
toast({ title: 'Basarili', description: 'Hesabiniz olusturuldu' });
window.location.href = '/dashboard';
} catch (error: any) {
toast({
title: 'Hata',
description: error.response?.data?.error?.message || 'Kayit olusturulamadi',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const features = [
'Sinirsiz VIN sorgulama',
'Detayli parca bilgileri',
'Guncel fiyat verileri',
];
return (
<Card className="border-0 shadow-xl overflow-hidden">
<div className="h-1 gradient-bg" />
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 h-12 w-12 rounded-xl gradient-bg flex items-center justify-center lg:hidden">
<Sparkles className="h-6 w-6 text-white" />
</div>
<CardTitle className="text-2xl">Hesap Olustur</CardTitle>
<CardDescription>Ucretsiz kayit olun ve hemen baslayin</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4 pt-4">
{/* Features mini list */}
<div className="flex flex-wrap gap-2 justify-center mb-2">
{features.map((feature) => (
<span key={feature} className="inline-flex items-center gap-1 text-xs bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 px-2 py-1 rounded-full">
<CheckCircle2 className="h-3 w-3" />
{feature}
</span>
))}
</div>
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
Ad Soyad <span className="text-xs text-muted-foreground">(Opsiyonel)</span>
</Label>
<Input
id="name"
placeholder="Ali Yilmaz"
className="h-12 border-2 focus:border-purple-500"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email
</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
className="h-12 border-2 focus:border-purple-500"
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
Sifre
</Label>
<Input
id="password"
type="password"
placeholder="********"
className="h-12 border-2 focus:border-purple-500"
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
Sifre Tekrar
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="********"
className="h-12 border-2 focus:border-purple-500"
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<>
Kayit Ol
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
<p className="text-sm text-muted-foreground">
Zaten hesabiniz var mi?{' '}
<Link href="/login" className="text-purple-600 hover:text-purple-700 font-medium hover:underline">
Giris yap
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,254 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Car, Search, Shield, Zap, CheckCircle2, ArrowRight, Sparkles } from 'lucide-react';
export default function HomePage() {
return (
<div className="flex min-h-screen flex-col overflow-hidden">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass">
<div className="container flex h-16 items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
<Car className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold gradient-text">Sase.tr</span>
</Link>
<nav className="flex items-center gap-4">
<Link href="/subscription/plans" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
Fiyatlar
</Link>
<Link href="/login">
<Button variant="ghost" className="hover:bg-purple-100 dark:hover:bg-purple-900/20">
Giris Yap
</Button>
</Link>
<Link href="/register">
<Button className="gradient-bg hover:opacity-90 transition-opacity shadow-lg shadow-purple-500/25">
Kayit Ol
</Button>
</Link>
</nav>
</div>
</header>
{/* Hero */}
<section className="relative min-h-screen flex items-center justify-center pt-16">
{/* Background decorations */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-purple-500/20 blur-3xl animate-pulse-slow" />
<div className="absolute top-1/2 -left-40 h-80 w-80 rounded-full bg-indigo-500/20 blur-3xl animate-pulse-slow" style={{ animationDelay: '2s' }} />
<div className="absolute -bottom-40 right-1/3 h-80 w-80 rounded-full bg-violet-500/20 blur-3xl animate-pulse-slow" style={{ animationDelay: '4s' }} />
</div>
{/* Grid pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:4rem_4rem]" />
<div className="container relative z-10 text-center py-20">
<div className="inline-flex items-center gap-2 rounded-full border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 px-4 py-2 mb-8 animate-float">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Turkiye&apos;nin en kapsamli VIN sorgulama sistemi</span>
</div>
<h1 className="mb-6 text-5xl md:text-7xl font-bold tracking-tight">
Arac Sase Sorgulama
<br />
<span className="gradient-text">Platformu</span>
</h1>
<p className="mx-auto mb-10 max-w-2xl text-xl text-muted-foreground leading-relaxed">
VIN numarasi ile aracinizin tum bilgilerine, yedek parca kodlarina
ve guncel fiyatlarina <span className="text-foreground font-medium">saniyeler icinde</span> ulasin.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4 mb-16">
<Link href="/register">
<Button size="lg" className="gradient-bg hover:opacity-90 transition-all shadow-xl shadow-purple-500/25 text-lg px-8 py-6 group">
Ucretsiz Baslat
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
<Link href="/subscription/plans">
<Button size="lg" variant="outline" className="text-lg px-8 py-6 border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20">
Fiyatlari Gor
</Button>
</Link>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 max-w-3xl mx-auto">
{[
{ value: '50+', label: 'Marka' },
{ value: '1M+', label: 'Parca' },
{ value: '10K+', label: 'Kullanici' },
{ value: '99.9%', label: 'Uptime' },
].map((stat) => (
<div key={stat.label} className="text-center">
<div className="text-3xl md:text-4xl font-bold gradient-text">{stat.value}</div>
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Features */}
<section className="container py-24 relative">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Neden <span className="gradient-text">Sase.tr</span>?
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Modern altyapi, hizli sonuclar ve kapsamli veritabani ile aracinizi taniyin.
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-purple-50/50 dark:from-gray-900 dark:to-purple-900/10">
<CardHeader className="space-y-4">
<div className="h-14 w-14 rounded-2xl gradient-bg flex items-center justify-center shadow-lg shadow-purple-500/25">
<Zap className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-xl">Simsek Hizinda Sorgulama</CardTitle>
<CardDescription className="text-base leading-relaxed">
VIN numarasi girin, milisaniyeler icinde arac bilgilerine ulasin. API tabanli modern altyapi.
</CardDescription>
</CardHeader>
</Card>
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-indigo-50/50 dark:from-gray-900 dark:to-indigo-900/10">
<CardHeader className="space-y-4">
<div className="h-14 w-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center shadow-lg shadow-indigo-500/25">
<Search className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-xl">Detayli Parca Bilgisi</CardTitle>
<CardDescription className="text-base leading-relaxed">
OEM kodlari, alternatif markalar, stok durumu ve guncel fiyatlar tek platformda.
</CardDescription>
</CardHeader>
</Card>
<Card className="card-hover border-0 shadow-lg bg-gradient-to-br from-white to-violet-50/50 dark:from-gray-900 dark:to-violet-900/10">
<CardHeader className="space-y-4">
<div className="h-14 w-14 rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-lg shadow-violet-500/25">
<Shield className="h-7 w-7 text-white" />
</div>
<CardTitle className="text-xl">Guvenli Altyapi</CardTitle>
<CardDescription className="text-base leading-relaxed">
SSL sertifikasi, sifreli veri iletimi ve KVKK uyumlu guvenli depolama.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
{/* Pricing */}
<section className="py-24 bg-gradient-to-b from-transparent via-purple-50/50 to-transparent dark:via-purple-900/10">
<div className="container">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Basit ve Seffaf <span className="gradient-text">Fiyatlandirma</span>
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Isletmenize uygun plani secin, hemen baslayin.
</p>
</div>
<div className="grid gap-6 md:grid-cols-4 max-w-6xl mx-auto">
{[
{ name: 'Baslangic', price: 299, brands: 1, features: ['1 marka erisimi', 'Sinirsiz sorgu', 'Email destek'] },
{ name: 'Pro', price: 599, brands: 3, popular: true, features: ['3 marka erisimi', 'Sinirsiz sorgu', 'Oncelikli destek', 'API erisimi'] },
{ name: 'Isletme', price: 999, brands: 10, features: ['10 marka erisimi', 'Sinirsiz sorgu', '7/24 destek', 'API erisimi', 'Ozel raporlar'] },
{ name: 'Full', price: 1999, brands: 'Tum', features: ['Tum markalara erisim', 'Sinirsiz sorgu', 'VIP destek', 'API erisimi', 'Ozel raporlar', 'Beyaz etiket'] },
].map((plan) => (
<Card
key={plan.name}
className={`card-hover relative overflow-hidden ${
plan.popular
? 'border-2 border-purple-500 shadow-xl shadow-purple-500/20 scale-105'
: 'border-0 shadow-lg'
}`}
>
{plan.popular && (
<div className="absolute top-0 right-0 gradient-bg px-4 py-1 text-xs font-semibold text-white rounded-bl-xl">
Populer
</div>
)}
<CardHeader className="pb-4">
<CardTitle className="text-lg">{plan.name}</CardTitle>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-muted-foreground">TL/ay</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-purple-500 flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
<Link href="/register" className="block">
<Button
className={`w-full ${plan.popular ? 'gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25' : ''}`}
variant={plan.popular ? 'default' : 'outline'}
>
Hemen Basla
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="container py-24">
<div className="relative overflow-hidden rounded-3xl gradient-bg p-12 md:p-20 text-center animate-gradient">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff0a_1px,transparent_1px),linear-gradient(to_bottom,#ffffff0a_1px,transparent_1px)] bg-[size:2rem_2rem]" />
<div className="relative z-10">
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
Hemen Ucretsiz Deneyin
</h2>
<p className="text-white/80 text-lg max-w-2xl mx-auto mb-8">
Kredi karti gerekmez. Hemen kayit olun ve arac sorgulama deneyiminizi kesfetmeye baslayin.
</p>
<Link href="/register">
<Button size="lg" variant="secondary" className="text-lg px-8 py-6 bg-white text-purple-700 hover:bg-gray-100 shadow-xl group">
Ucretsiz Hesap Olustur
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t bg-gray-50/50 dark:bg-gray-900/50">
<div className="container py-12">
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
<Car className="h-5 w-5 text-white" />
</div>
<span className="text-lg font-bold gradient-text">Sase.tr</span>
</div>
<nav className="flex items-center gap-8 text-sm text-muted-foreground">
<Link href="/subscription/plans" className="hover:text-foreground transition-colors">Fiyatlar</Link>
<Link href="/privacy" className="hover:text-foreground transition-colors">Gizlilik</Link>
<Link href="/terms" className="hover:text-foreground transition-colors">Kullanim Sartlari</Link>
<Link href="/contact" className="hover:text-foreground transition-colors">Iletisim</Link>
</nav>
</div>
<div className="mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
<p>2025 Sase.tr - Tum haklar saklidir.</p>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/providers/auth-provider';
import { Button } from '@/components/ui/button';
import { Search, Car, CreditCard, User, LogOut, Menu, X, Sparkles } from 'lucide-react';
const navItems = [
{ href: '/dashboard/vehicles/search', icon: Search, label: 'VIN Sorgula' },
{ href: '/dashboard/vehicles', icon: Car, label: 'Araclarim' },
{ href: '/dashboard/subscription', icon: CreditCard, label: 'Abonelik' },
{ href: '/dashboard/profile', icon: User, label: 'Profil' },
];
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isLoading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isLoading && !user) {
router.push('/login');
}
}, [user, isLoading, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<div className="relative">
<div className="h-16 w-16 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-4 text-sm text-muted-foreground">Yukleniyor...</p>
</div>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="flex min-h-screen bg-gray-50/50 dark:bg-gray-950">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-72 transform bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex h-full flex-col">
{/* Sidebar Header */}
<div className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-gray-800 px-6">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
<Car className="h-5 w-5 text-white" />
</div>
<span className="text-xl font-bold gradient-text">Sase.tr</span>
</Link>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link key={item.href} href={item.href} onClick={() => setSidebarOpen(false)}>
<div
className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-gradient-to-r from-purple-500/10 to-indigo-500/10 text-purple-700 dark:text-purple-300 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
<item.icon className={`h-5 w-5 ${isActive ? 'text-purple-600' : ''}`} />
{item.label}
{isActive && (
<div className="ml-auto h-2 w-2 rounded-full bg-purple-600" />
)}
</div>
</Link>
);
})}
</nav>
{/* Sidebar Footer - User Info */}
<div className="border-t border-gray-200 dark:border-gray-800 p-4">
<div className="flex items-center gap-3 rounded-xl bg-gray-100 dark:bg-gray-800 p-3">
<div className="h-10 w-10 rounded-full gradient-bg flex items-center justify-center text-white font-semibold">
{(user.name || user.email || 'U').charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name || 'Kullanici'}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={logout}
className="text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl px-6">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<div className="hidden lg:flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="text-sm text-muted-foreground">
VIN sorgulama platformuna hosgeldiniz
</span>
</div>
<div className="flex items-center gap-4">
<Link href="/subscription/plans">
<Button variant="outline" size="sm" className="hidden sm:flex border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/20">
<Sparkles className="mr-2 h-4 w-4" />
Yukselt
</Button>
</Link>
<div className="flex items-center gap-2 lg:hidden">
<span className="text-sm text-muted-foreground truncate max-w-[120px]">
{user.name || user.email}
</span>
<Button variant="ghost" size="icon" onClick={logout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 p-6 lg:p-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/providers/auth-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Search, Car, CreditCard, ArrowRight, Zap, TrendingUp, Clock } from 'lucide-react';
export default function DashboardPage() {
const { user } = useAuth();
const quickActions = [
{
title: 'VIN Sorgula',
description: 'Arac sase numarasi ile sorgulama yapin',
icon: Search,
href: '/dashboard/vehicles/search',
gradient: 'from-purple-500 to-indigo-600',
primary: true,
},
{
title: 'Araclarim',
description: 'Gecmis sorgularinizi goruntuleyin',
icon: Car,
href: '/dashboard/vehicles',
gradient: 'from-indigo-500 to-blue-600',
},
{
title: 'Abonelik',
description: 'Abonelik durumunuzu kontrol edin',
icon: CreditCard,
href: '/dashboard/subscription',
gradient: 'from-violet-500 to-purple-600',
},
];
const stats = [
{ label: 'Bu Ay Sorgu', value: '24', icon: Search, change: '+12%' },
{ label: 'Kayitli Arac', value: '8', icon: Car, change: '+2' },
{ label: 'Kalan Gun', value: '18', icon: Clock, change: '' },
];
return (
<div className="space-y-8">
{/* Welcome Section */}
<div className="relative overflow-hidden rounded-2xl gradient-bg p-8 text-white">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff08_1px,transparent_1px),linear-gradient(to_bottom,#ffffff08_1px,transparent_1px)] bg-[size:2rem_2rem]" />
<div className="relative z-10">
<div className="flex items-center gap-2 mb-2">
<span className="text-purple-200">Hosgeldiniz</span>
<Zap className="h-4 w-4 text-yellow-300" />
</div>
<h1 className="text-3xl md:text-4xl font-bold mb-2">
{user?.name || 'Kullanici'}
</h1>
<p className="text-purple-100 max-w-xl">
VIN sorgulama platformuna hosgeldiniz. Aracinizin tum bilgilerine saniyeler icinde ulasin.
</p>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.label} className="border-0 shadow-md bg-white dark:bg-gray-900">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{stat.label}</p>
<p className="text-3xl font-bold mt-1">{stat.value}</p>
{stat.change && (
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-xs text-green-600">{stat.change}</span>
</div>
)}
</div>
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center">
<stat.icon className="h-6 w-6 text-white" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Quick Actions */}
<div>
<h2 className="text-xl font-semibold mb-4">Hizli Islemler</h2>
<div className="grid gap-6 md:grid-cols-3">
{quickActions.map((action) => (
<Card
key={action.title}
className={`card-hover border-0 shadow-lg overflow-hidden ${
action.primary ? 'ring-2 ring-purple-500/20' : ''
}`}
>
<CardHeader className="pb-4">
<div className={`h-12 w-12 rounded-xl bg-gradient-to-br ${action.gradient} flex items-center justify-center shadow-lg mb-3`}>
<action.icon className="h-6 w-6 text-white" />
</div>
<CardTitle className="text-lg">{action.title}</CardTitle>
<CardDescription>{action.description}</CardDescription>
</CardHeader>
<CardContent>
<Link href={action.href}>
<Button
className={`w-full group ${
action.primary
? 'gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25'
: ''
}`}
variant={action.primary ? 'default' : 'outline'}
>
{action.primary ? 'Sorgula' : 'Goruntule'}
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</div>
{/* Tips Section */}
<Card className="border-0 shadow-md bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-xl bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center flex-shrink-0">
<Zap className="h-5 w-5 text-purple-600" />
</div>
<div>
<h3 className="font-semibold text-purple-900 dark:text-purple-100 mb-1">
Ipucu: VIN Numarasi Nerede?
</h3>
<p className="text-sm text-purple-700 dark:text-purple-300">
VIN numaranizi aracinizin ruhsatinda, sol on kapi pervazinda veya on camin sol alt kosesinde bulabilirsiniz. 17 karakterden olusur ve I, O, Q harflerini icermez.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,161 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, User, Mail, Shield, AlertTriangle, Save } from 'lucide-react';
import { useAuth } from '@/providers/auth-provider';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
const profileSchema = z.object({
name: z.string().min(2, 'Ad en az 2 karakter olmalidir').optional().or(z.literal('')),
});
type ProfileForm = z.infer<typeof profileSchema>;
export default function ProfilePage() {
const { user, refreshUser } = useAuth();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<ProfileForm>({
resolver: zodResolver(profileSchema),
defaultValues: {
name: user?.name || '',
},
});
const onSubmit = async (data: ProfileForm) => {
setIsLoading(true);
try {
await apiClient.patch('/users/profile', data);
await refreshUser();
toast({ title: 'Basarili', description: 'Profil guncellendi' });
} catch (error: any) {
toast({
title: 'Hata',
description: error.response?.data?.error?.message || 'Profil guncellenemedi',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-2xl gradient-bg flex items-center justify-center text-white text-2xl font-bold shadow-lg shadow-purple-500/25">
{(user?.name || user?.email || 'U').charAt(0).toUpperCase()}
</div>
<div>
<h1 className="text-3xl font-bold">{user?.name || 'Profil'}</h1>
<p className="text-muted-foreground">Hesap bilgilerinizi yonetin</p>
</div>
</div>
{/* Profile Form Card */}
<Card className="border-0 shadow-lg overflow-hidden">
<div className="h-1 gradient-bg" />
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
<User className="h-5 w-5 text-white" />
</div>
<div>
<CardTitle>Kisisel Bilgiler</CardTitle>
<CardDescription>Ad ve email bilgileriniz</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
Email
</Label>
<Input
id="email"
type="email"
value={user?.email || ''}
disabled
className="bg-gray-50 dark:bg-gray-800/50 border-2"
/>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Shield className="h-3 w-3" />
Email adresi degistirilemez
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
Ad Soyad
</Label>
<Input
id="name"
placeholder="Ali Yilmaz"
className="border-2 focus:border-purple-500"
{...register('name')}
/>
{errors.name && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{errors.name.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isLoading}
className="gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Degisiklikleri Kaydet
</Button>
</form>
</CardContent>
</Card>
{/* Danger Zone Card */}
<Card className="border-2 border-red-200 dark:border-red-900/50 shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div>
<CardTitle className="text-red-600">Tehlikeli Bolge</CardTitle>
<CardDescription>Hesabinizi kalici olarak silin</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4 mb-4">
<p className="text-sm text-red-800 dark:text-red-200">
Hesabinizi sildiginizde tum verileriniz kalici olarak silinir ve geri alinamaz.
Bu islem aboneliginizi de iptal eder.
</p>
</div>
<Button variant="destructive" disabled className="opacity-70">
<AlertTriangle className="mr-2 h-4 w-4" />
Hesabi Sil
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,238 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { CreditCard, CheckCircle, AlertCircle, ArrowRight, Sparkles, Calendar, Shield } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatCurrency, formatDate } from '@/lib/utils';
interface Subscription {
id: string;
status: string;
currentPeriodStart: string;
currentPeriodEnd: string;
cancelAtPeriodEnd: boolean;
plan: {
name: string;
price: number;
currency: string;
brandLimit: number;
hasFullAccess: boolean;
};
}
interface SelectedBrand {
id: string;
code: string;
name: string;
}
export default function SubscriptionPage() {
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [selectedBrands, setSelectedBrands] = useState<SelectedBrand[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchSubscription() {
try {
const response = await apiClient.get('/subscriptions/current');
if (response.data.data) {
setSubscription(response.data.data.subscription);
setSelectedBrands(response.data.data.selectedBrands || []);
}
} catch (error) {
console.error('Failed to fetch subscription', error);
} finally {
setIsLoading(false);
}
}
fetchSubscription();
}, []);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="relative mx-auto w-fit">
<div className="h-12 w-12 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-4 text-sm text-muted-foreground">Abonelik yukleniyor...</p>
</div>
</div>
);
}
if (!subscription) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Abonelik</h1>
<p className="text-muted-foreground">Abonelik durumunuz</p>
</div>
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-20 w-20 rounded-2xl bg-gradient-to-br from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 flex items-center justify-center mb-6">
<CreditCard className="h-10 w-10 text-purple-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Aktif aboneliginiz yok</h3>
<p className="mb-6 text-muted-foreground text-center max-w-sm">
VIN sorgulama yapmak icin bir paket secin ve hemen baslayin
</p>
<Link href="/subscription/plans">
<Button className="gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 group">
<Sparkles className="mr-2 h-4 w-4" />
Paketleri Gor
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
const isActive = subscription.status === 'ACTIVE';
const isPending = subscription.status === 'PENDING';
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Abonelik</h1>
<p className="text-muted-foreground">Abonelik durumunuz ve detaylari</p>
</div>
<Link href="/subscription/plans">
<Button variant="outline" className="border-2 border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/20 group">
<Sparkles className="mr-2 h-4 w-4" />
Paket Degistir
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Current Plan */}
<Card className="border-0 shadow-lg overflow-hidden">
<div className="h-2 gradient-bg" />
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center">
<CreditCard className="h-6 w-6 text-white" />
</div>
<div>
<CardTitle className="text-xl">{subscription.plan.name} Paketi</CardTitle>
<p className="text-2xl font-bold gradient-text">
{formatCurrency(subscription.plan.price, subscription.plan.currency)}
<span className="text-sm font-normal text-muted-foreground">/ay</span>
</p>
</div>
</div>
<span
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium ${
isActive
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: isPending
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}
>
{isActive ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{isActive ? 'Aktif' : isPending ? 'Beklemede' : 'Pasif'}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Baslangic</span>
</div>
<p className="font-semibold">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Calendar className="h-4 w-4" />
<span className="text-sm">Bitis</span>
</div>
<p className="font-semibold">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
</div>
{subscription.cancelAtPeriodEnd && (
<div className="flex items-center gap-3 rounded-xl bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-4">
<AlertCircle className="h-5 w-5 text-yellow-600 flex-shrink-0" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Aboneliginiz donem sonunda iptal edilecek.
</p>
</div>
)}
</CardContent>
</Card>
{/* Selected Brands */}
<Card className="border-0 shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center">
<Shield className="h-6 w-6 text-white" />
</div>
<div>
<CardTitle>Secili Markalar</CardTitle>
<CardDescription>
{subscription.plan.hasFullAccess
? 'Tum markalara erisiniz var'
: `${subscription.plan.brandLimit} marka hakkiniz var`}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{subscription.plan.hasFullAccess ? (
<div className="text-center py-6">
<div className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 px-4 py-2">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
Tum markalara sinirsiz erisim
</span>
</div>
</div>
) : selectedBrands.length === 0 ? (
<div className="text-center py-6">
<p className="text-muted-foreground mb-4">Henuz marka secmediniz</p>
<Button variant="outline" size="sm">
Marka Sec
</Button>
</div>
) : (
<div className="flex flex-wrap gap-2">
{selectedBrands.map((brand) => (
<span
key={brand.id}
className="inline-flex items-center gap-2 rounded-xl bg-purple-100 dark:bg-purple-900/30 px-4 py-2 text-sm font-medium text-purple-700 dark:text-purple-300"
>
<div className="h-6 w-6 rounded-lg gradient-bg flex items-center justify-center">
<span className="text-xs text-white font-bold">{brand.name.charAt(0)}</span>
</div>
{brand.name}
</span>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,457 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import {
ArrowLeft,
Car,
Search,
Package,
ChevronRight,
Grid3X3,
List,
Copy,
Check
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { formatVin } from '@/lib/utils';
import { useToast } from '@/hooks/use-toast';
interface Part {
id: string;
oemCode: string;
alternativeOems: string[];
nameEn: string;
nameTr: string;
description: string | null;
positionCode: string | null;
imageUrl: string | null;
prices: Array<{
brand: string;
price: number;
currency: string;
inStock: boolean;
}>;
}
interface Category {
id: string;
code: string;
nameTr: string;
nameEn: string;
slug: string;
iconName: string | null;
schemaImageUrl: string | null;
}
interface Vehicle {
id: string;
vin: string;
brand: { name: string; code: string };
model: string;
year: number;
series: string | null;
engineCode: string | null;
}
interface CategoryPartsData {
vehicle: Vehicle;
category: Category;
parts: Part[];
totalParts: number;
}
export default function CategoryPartsPage() {
const params = useParams();
const router = useRouter();
const { toast } = useToast();
const vin = params.vin as string;
const categoryId = params.categoryId as string;
const [data, setData] = useState<CategoryPartsData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [copiedOem, setCopiedOem] = useState<string | null>(null);
useEffect(() => {
async function fetchCategoryParts() {
try {
const response = await apiClient.get(`/vehicles/${vin}/categories/${categoryId}/parts`);
setData(response.data.data);
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Parcalar yuklenemedi');
} finally {
setIsLoading(false);
}
}
if (vin && categoryId) {
fetchCategoryParts();
}
}, [vin, categoryId]);
const copyOemCode = async (oemCode: string) => {
try {
await navigator.clipboard.writeText(oemCode);
setCopiedOem(oemCode);
setTimeout(() => setCopiedOem(null), 2000);
} catch {
toast({
title: 'Kopyalanamadi',
description: 'OEM kodu kopyalanamadi',
variant: 'destructive',
});
}
};
// Parcalari filtrele
const filteredParts = data?.parts.filter((part) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
part.oemCode.toLowerCase().includes(query) ||
part.nameTr?.toLowerCase().includes(query) ||
part.nameEn.toLowerCase().includes(query) ||
part.alternativeOems?.some(oem => oem.toLowerCase().includes(query))
);
}) || [];
if (isLoading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="text-center">
<div className="relative mx-auto w-fit">
<div className="h-16 w-16 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-6 text-muted-foreground">Parcalar yukleniyor...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Button variant="ghost" className="gap-2" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
Geri Don
</Button>
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-20 w-20 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
<Package className="h-10 w-10 text-red-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Parcalar Yuklenemedi</h3>
<p className="text-muted-foreground mb-6">{error}</p>
<Button className="gradient-bg" onClick={() => router.back()}>
Kategorilere Don
</Button>
</CardContent>
</Card>
</div>
);
}
const { vehicle, category, totalParts } = data;
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/dashboard/vehicles/search" className="hover:text-foreground transition-colors">
Arac Ara
</Link>
<ChevronRight className="h-4 w-4" />
<Link href={`/dashboard/vehicles/${vin}/categories`} className="hover:text-foreground transition-colors">
Kategoriler
</Link>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground font-medium">{category.nameTr}</span>
</div>
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
className="rounded-xl border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 flex-shrink-0"
onClick={() => router.push(`/dashboard/vehicles/${vin}/categories`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center flex-shrink-0">
<Package className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold">{category.nameTr}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{vehicle.brand.name} {vehicle.model}</span>
<span className="text-purple-600 font-medium"> {vehicle.year}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3 ml-16 md:ml-0">
<Badge variant="secondary" className="px-3 py-1">
{totalParts} parca
</Badge>
<div className="flex items-center border rounded-lg p-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setViewMode('grid')}
>
<Grid3X3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
placeholder="OEM kodu veya parca adi ara..."
className="pl-12 h-12 text-base border-2 focus:border-purple-500"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Category Schema Image */}
{category.schemaImageUrl && (
<Card className="border-0 shadow-md overflow-hidden">
<CardContent className="p-0">
<div className="relative aspect-video bg-gray-100 dark:bg-gray-800">
<Image
src={category.schemaImageUrl}
alt={category.nameTr}
fill
className="object-contain"
/>
</div>
</CardContent>
</Card>
)}
{/* Parts */}
{filteredParts.length === 0 ? (
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<Package className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">
{searchQuery ? 'Parca bulunamadi' : 'Henuz parca yok'}
</h3>
<p className="text-muted-foreground text-center max-w-md">
{searchQuery
? `"${searchQuery}" ile eslesen parca bulunamadi. Farkli bir arama deneyin.`
: 'Bu kategori icin henuz parca eklenmemis.'}
</p>
</CardContent>
</Card>
) : viewMode === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredParts.map((part) => (
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-lg transition-all">
<CardContent className="p-4">
{/* Part Image */}
{part.imageUrl && (
<div className="relative aspect-square mb-4 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
<Image
src={part.imageUrl}
alt={part.nameTr || part.nameEn}
fill
className="object-contain p-2"
/>
</div>
)}
{/* Part Info */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold line-clamp-2">
{part.nameTr || part.nameEn}
</h3>
{part.positionCode && (
<Badge variant="outline" className="flex-shrink-0">
{part.positionCode}
</Badge>
)}
</div>
{/* OEM Code */}
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded flex-1 truncate">
{part.oemCode}
</code>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0"
onClick={() => copyOemCode(part.oemCode)}
>
{copiedOem === part.oemCode ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* Alternative OEMs */}
{part.alternativeOems && part.alternativeOems.length > 0 && (
<div className="flex flex-wrap gap-1">
{part.alternativeOems.slice(0, 3).map((oem) => (
<Badge key={oem} variant="secondary" className="text-xs">
{oem}
</Badge>
))}
{part.alternativeOems.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{part.alternativeOems.length - 3}
</Badge>
)}
</div>
)}
{/* Prices */}
{part.prices && part.prices.length > 0 && (
<div className="pt-2 border-t">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{part.prices[0].brand}
</span>
<span className="font-semibold text-purple-600">
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
</span>
</div>
{part.prices[0].inStock && (
<Badge className="mt-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Stokta
</Badge>
)}
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="space-y-2">
{filteredParts.map((part) => (
<Card key={part.id} className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Part Image */}
{part.imageUrl && (
<div className="relative h-16 w-16 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden flex-shrink-0">
<Image
src={part.imageUrl}
alt={part.nameTr || part.nameEn}
fill
className="object-contain p-1"
/>
</div>
)}
{/* Part Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate">
{part.nameTr || part.nameEn}
</h3>
{part.positionCode && (
<Badge variant="outline" className="flex-shrink-0">
{part.positionCode}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<code className="text-sm font-mono text-muted-foreground">
{part.oemCode}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => copyOemCode(part.oemCode)}
>
{copiedOem === part.oemCode ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* Price */}
{part.prices && part.prices.length > 0 && (
<div className="text-right flex-shrink-0">
<div className="font-semibold text-purple-600">
{part.prices[0].price.toLocaleString('tr-TR')} {part.prices[0].currency}
</div>
<div className="text-sm text-muted-foreground">
{part.prices[0].brand}
</div>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Vehicle Info Footer */}
<Card className="border-0 shadow-md bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
<Car className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-purple-900 dark:text-purple-100">
{vehicle.brand.name} {vehicle.model} ({vehicle.year})
</p>
<p className="text-sm text-purple-600 dark:text-purple-300 font-mono">
{formatVin(vehicle.vin)}
</p>
</div>
</div>
<Link href={`/dashboard/vehicles/${vehicle.vin}`}>
<Button variant="outline" className="border-purple-300 text-purple-700 hover:bg-purple-100 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/30">
Arac Detaylari
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,297 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
Car,
Wrench,
Search,
ChevronRight,
Package,
Settings,
Cog,
Zap,
Shield,
CircleDot,
Lightbulb,
Wind,
Droplets,
Radio,
Armchair
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { formatVin } from '@/lib/utils';
interface Vehicle {
id: string;
vin: string;
brand: { name: string; code: string };
model: string;
year: number;
series: string | null;
engineCode: string | null;
engineType: string | null;
categories: Array<{
id: string;
categoryId: string;
partCount: number;
category: {
id: string;
code: string;
nameTr: string;
nameEn: string;
slug: string;
iconName: string | null;
schemaImageUrl: string | null;
};
}>;
}
// Kategori ikonları
const categoryIcons: Record<string, any> = {
'motor': Cog,
'engine': Cog,
'fren': Shield,
'brake': Shield,
'suspansiyon': CircleDot,
'suspension': CircleDot,
'elektrik': Zap,
'electrical': Zap,
'aydinlatma': Lightbulb,
'lighting': Lightbulb,
'sogutma': Wind,
'cooling': Wind,
'yakit': Droplets,
'fuel': Droplets,
'ses': Radio,
'audio': Radio,
'ic': Armchair,
'interior': Armchair,
'kaporta': Car,
'body': Car,
'default': Wrench,
};
function getCategoryIcon(category: { nameTr: string; nameEn: string; code: string }) {
const searchTerms = [
category.nameTr.toLowerCase(),
category.nameEn.toLowerCase(),
category.code.toLowerCase()
].join(' ');
for (const [key, icon] of Object.entries(categoryIcons)) {
if (key !== 'default' && searchTerms.includes(key)) {
return icon;
}
}
return categoryIcons.default;
}
export default function VehicleCategoriesPage() {
const params = useParams();
const router = useRouter();
const vin = params.vin as string;
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
async function fetchVehicle() {
try {
const response = await apiClient.post('/vehicles/decode', { vin });
setVehicle(response.data.data.vehicle);
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Arac bulunamadi');
} finally {
setIsLoading(false);
}
}
if (vin) {
fetchVehicle();
}
}, [vin]);
// Kategorileri filtrele
const filteredCategories = vehicle?.categories.filter((vc) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
vc.category.nameTr.toLowerCase().includes(query) ||
vc.category.nameEn.toLowerCase().includes(query) ||
vc.category.code.toLowerCase().includes(query)
);
}) || [];
if (isLoading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="text-center">
<div className="relative mx-auto w-fit">
<div className="h-16 w-16 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-16 w-16 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-6 text-muted-foreground">Parca kategorileri yukleniyor...</p>
</div>
</div>
);
}
if (error || !vehicle) {
return (
<div className="space-y-4">
<Button variant="ghost" className="gap-2" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
Geri Don
</Button>
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-20 w-20 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
<Car className="h-10 w-10 text-red-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Arac Bulunamadi</h3>
<p className="text-muted-foreground mb-6">{error}</p>
<Link href="/dashboard/vehicles/search">
<Button className="gradient-bg">Yeni Sorgulama Yap</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Vehicle Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="icon"
className="rounded-xl border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300 flex-shrink-0"
onClick={() => router.push('/dashboard/vehicles/search')}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl gradient-bg flex items-center justify-center flex-shrink-0">
<Car className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold">
{vehicle.brand.name} {vehicle.model}
</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{formatVin(vehicle.vin)}</span>
<span className="text-purple-600 font-medium"> {vehicle.year}</span>
{vehicle.engineCode && (
<span className="hidden md:inline"> Motor: {vehicle.engineCode}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-16 md:ml-0">
<span className="text-sm text-muted-foreground">
{vehicle.categories.length} kategori
</span>
<span className="h-6 w-px bg-border" />
<span className="text-sm text-muted-foreground">
{vehicle.categories.reduce((sum, c) => sum + c.partCount, 0)} parca
</span>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
placeholder="Kategori ara... (ornek: motor, fren, elektrik)"
className="pl-12 h-12 text-base border-2 focus:border-purple-500"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Categories Grid */}
{filteredCategories.length === 0 ? (
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-16 w-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<Package className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold mb-2">
{searchQuery ? 'Kategori bulunamadi' : 'Henuz kategori yok'}
</h3>
<p className="text-muted-foreground text-center max-w-md">
{searchQuery
? `"${searchQuery}" ile eslesen kategori bulunamadi. Farkli bir arama deneyin.`
: 'Bu arac icin henuz parca kategorisi eklenmemis.'}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredCategories.map((vc) => {
const Icon = getCategoryIcon(vc.category);
return (
<Link
key={vc.id}
href={`/dashboard/vehicles/${vehicle.vin}/categories/${vc.category.id}`}
>
<Card className="border-2 border-transparent hover:border-purple-300 dark:hover:border-purple-700 hover:shadow-lg transition-all cursor-pointer group h-full">
<CardContent className="p-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 flex items-center justify-center flex-shrink-0 group-hover:from-purple-200 group-hover:to-indigo-200 dark:group-hover:from-purple-800/40 dark:group-hover:to-indigo-800/40 transition-colors">
<Icon className="h-6 w-6 text-purple-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate group-hover:text-purple-600 transition-colors">
{vc.category.nameTr}
</h3>
<p className="text-sm text-muted-foreground">
{vc.partCount} parca
</p>
</div>
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-purple-600 group-hover:translate-x-1 transition-all flex-shrink-0" />
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
{/* Quick Actions */}
<Card className="border-0 shadow-md bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-white dark:bg-gray-800 shadow-sm flex items-center justify-center">
<Settings className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-purple-900 dark:text-purple-100">Arac detaylarini gor</p>
<p className="text-sm text-purple-600 dark:text-purple-300">Tum teknik ozellikler</p>
</div>
</div>
<Link href={`/dashboard/vehicles/${vehicle.vin}`}>
<Button variant="outline" className="border-purple-300 text-purple-700 hover:bg-purple-100 dark:border-purple-700 dark:text-purple-300 dark:hover:bg-purple-900/30">
Detaylari Gor
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,231 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Car, Wrench, Calendar, Fuel, Settings, Gauge, ArrowRight } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatVin } from '@/lib/utils';
interface Vehicle {
id: string;
vin: string;
brand: { name: string; code: string };
model: string;
year: number;
series: string | null;
bodyType: string | null;
engineCode: string | null;
engineType: string | null;
engineVolume: string | null;
transmission: string | null;
driveType: string | null;
categories: Array<{
id: string;
categoryId: string;
partCount: number;
category: {
id: string;
nameTr: string;
slug: string;
iconName: string | null;
};
}>;
}
const specIcons: Record<string, any> = {
'Marka': Car,
'Model': Car,
'Yil': Calendar,
'Motor Kodu': Settings,
'Yakit': Fuel,
'Motor Hacmi': Gauge,
'Vites': Settings,
'Cekis': Settings,
};
export default function VehicleDetailPage() {
const params = useParams();
const vin = params.vin as string;
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchVehicle() {
try {
const response = await apiClient.post('/vehicles/decode', { vin });
setVehicle(response.data.data.vehicle);
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Arac bulunamadi');
} finally {
setIsLoading(false);
}
}
if (vin) {
fetchVehicle();
}
}, [vin]);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="relative mx-auto w-fit">
<div className="h-12 w-12 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-4 text-sm text-muted-foreground">Arac bilgileri yukleniyor...</p>
</div>
</div>
);
}
if (error || !vehicle) {
return (
<div className="space-y-4">
<Link href="/dashboard/vehicles">
<Button variant="ghost" className="gap-2">
<ArrowLeft className="h-4 w-4" />
Geri Don
</Button>
</Link>
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-20 w-20 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
<Car className="h-10 w-10 text-red-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Hata</h3>
<p className="text-muted-foreground">{error}</p>
</CardContent>
</Card>
</div>
);
}
const specs = [
{ label: 'Marka', value: vehicle.brand.name },
{ label: 'Model', value: vehicle.model },
{ label: 'Yil', value: vehicle.year },
{ label: 'Seri', value: vehicle.series },
{ label: 'Kasa Tipi', value: vehicle.bodyType },
{ label: 'Motor Kodu', value: vehicle.engineCode },
{ label: 'Yakit', value: vehicle.engineType },
{ label: 'Motor Hacmi', value: vehicle.engineVolume },
{ label: 'Vites', value: vehicle.transmission },
{ label: 'Cekis', value: vehicle.driveType },
].filter(s => s.value);
return (
<div className="space-y-6">
{/* Back Button & Header */}
<div className="flex items-start gap-4">
<Link href="/dashboard/vehicles">
<Button variant="outline" size="icon" className="rounded-xl border-2 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:border-purple-300">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<div className="h-10 w-10 rounded-xl gradient-bg flex items-center justify-center">
<Car className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold">
{vehicle.brand.name} {vehicle.model}
</h1>
<p className="font-mono text-sm text-muted-foreground">{formatVin(vehicle.vin)}</p>
</div>
</div>
</div>
<span className="text-sm font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 px-3 py-1.5 rounded-full">
{vehicle.year}
</span>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Vehicle Info */}
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center">
<Settings className="h-4 w-4 text-white" />
</div>
Arac Bilgileri
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{specs.map((spec) => {
const Icon = specIcons[spec.label] || Settings;
return (
<div key={spec.label} className="flex items-start gap-3 p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50">
<div className="h-8 w-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
<Icon className="h-4 w-4 text-purple-600" />
</div>
<div>
<p className="text-xs text-muted-foreground">{spec.label}</p>
<p className="font-medium">{spec.value}</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Categories */}
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-gradient-to-br from-indigo-500 to-blue-600 flex items-center justify-center">
<Wrench className="h-4 w-4 text-white" />
</div>
Parca Kategorileri
</CardTitle>
<CardDescription>
Kategorilere tiklayarak parcalari gorebilirsiniz
</CardDescription>
</CardHeader>
<CardContent>
{vehicle.categories.length === 0 ? (
<div className="text-center py-8">
<div className="h-12 w-12 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-3">
<Wrench className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-muted-foreground">Kategori bulunamadi</p>
</div>
) : (
<div className="grid gap-2">
{vehicle.categories.map((vc) => (
<Link
key={vc.id}
href={`/parts/${vehicle.id}/${vc.category.id}`}
>
<div className="flex items-center justify-between rounded-xl border-2 border-transparent bg-gray-50 dark:bg-gray-800/50 p-4 hover:border-purple-300 dark:hover:border-purple-700 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center">
<Wrench className="h-5 w-5 text-purple-600" />
</div>
<span className="font-medium">{vc.category.nameTr}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground bg-white dark:bg-gray-700 px-2 py-1 rounded-lg">
{vc.partCount} parca
</span>
<ArrowRight className="h-4 w-4 text-purple-600 opacity-0 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Car, Search, ArrowRight, Calendar, Sparkles } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate, formatVin } from '@/lib/utils';
interface Vehicle {
id: string;
vin: string;
brand: { name: string };
model: string;
year: number;
createdAt: string;
}
export default function VehiclesPage() {
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchVehicles() {
try {
const response = await apiClient.get('/vehicles');
setVehicles(response.data.data.items);
} catch (error) {
console.error('Failed to fetch vehicles', error);
} finally {
setIsLoading(false);
}
}
fetchVehicles();
}, []);
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="relative mx-auto w-fit">
<div className="h-12 w-12 rounded-full border-4 border-purple-200 dark:border-purple-900" />
<div className="absolute inset-0 h-12 w-12 animate-spin rounded-full border-4 border-transparent border-t-purple-600" />
</div>
<p className="mt-4 text-sm text-muted-foreground">Araclar yukleniyor...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Araclarim</h1>
<p className="text-muted-foreground">
Gecmis VIN sorgulamalariniz
</p>
</div>
<Link href="/dashboard/vehicles/search">
<Button className="gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 group">
<Search className="mr-2 h-4 w-4" />
Yeni Sorgulama
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
{vehicles.length === 0 ? (
<Card className="border-0 shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="h-20 w-20 rounded-2xl bg-gradient-to-br from-purple-100 to-indigo-100 dark:from-purple-900/30 dark:to-indigo-900/30 flex items-center justify-center mb-6">
<Car className="h-10 w-10 text-purple-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Henuz sorgulama yapmadiniz</h3>
<p className="mb-6 text-muted-foreground text-center max-w-sm">
Ilk VIN sorgulamanizi yaparak aracinizin detayli bilgilerine ulasin
</p>
<Link href="/dashboard/vehicles/search">
<Button className="gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 group">
<Sparkles className="mr-2 h-4 w-4" />
Ilk Sorgulami Yap
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{vehicles.map((vehicle, index) => (
<Card
key={vehicle.id}
className="card-hover border-0 shadow-md overflow-hidden group"
style={{ animationDelay: `${index * 50}ms` }}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="h-10 w-10 rounded-xl gradient-bg flex items-center justify-center">
<Car className="h-5 w-5 text-white" />
</div>
<span className="text-xs font-medium text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-300 px-2 py-1 rounded-full">
{vehicle.year}
</span>
</div>
<CardTitle className="text-lg mt-3">
{vehicle.brand.name} {vehicle.model}
</CardTitle>
<CardDescription className="font-mono text-xs">
{formatVin(vehicle.vin)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
{formatDate(vehicle.createdAt)}
</div>
<Link href={`/dashboard/vehicles/${vehicle.vin}`}>
<Button variant="ghost" size="sm" className="text-purple-600 hover:text-purple-700 hover:bg-purple-50 dark:hover:bg-purple-900/20 group-hover:translate-x-1 transition-transform">
Detay
<ArrowRight className="ml-1 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2, Search, AlertCircle, Info, Sparkles, ArrowRight } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import { VIN_REGEX } from '@/lib/constants';
const vinSchema = z.object({
vin: z.string()
.length(17, 'VIN 17 karakter olmalidir')
.regex(VIN_REGEX, 'Gecersiz VIN formati (I, O, Q kullanilamaz)')
.transform(v => v.toUpperCase()),
});
type VinForm = z.infer<typeof vinSchema>;
export default function VehicleSearchPage() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { register, handleSubmit, formState: { errors }, watch } = useForm<VinForm>({
resolver: zodResolver(vinSchema),
});
const vinValue = watch('vin', '');
const onSubmit = async (data: VinForm) => {
setIsLoading(true);
setError(null);
try {
const response = await apiClient.post('/vehicles/decode', { vin: data.vin });
const vehicle = response.data.data.vehicle;
// Direkt parça kategorileri sayfasına yönlendir
router.push(`/dashboard/vehicles/${vehicle.vin}/categories`);
} catch (err: any) {
const message = err.response?.data?.error?.message || 'VIN sorgulama basarisiz';
setError(message);
toast({
title: 'Hata',
description: message,
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const progress = Math.min((vinValue.length / 17) * 100, 100);
return (
<div className="mx-auto max-w-2xl space-y-6">
{/* Header */}
<div className="text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 px-4 py-2 mb-4">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Hizli Sorgulama</span>
</div>
<h1 className="text-3xl font-bold mb-2">VIN Sorgulama</h1>
<p className="text-muted-foreground">
Arac sase numarasini girerek detayli bilgilere ulasin
</p>
</div>
{/* Search Card */}
<Card className="border-0 shadow-xl overflow-hidden">
<div className="h-1 bg-gray-100 dark:bg-gray-800">
<div
className="h-full gradient-bg transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg gradient-bg flex items-center justify-center">
<Search className="h-4 w-4 text-white" />
</div>
Sase Numarasi (VIN)
</CardTitle>
<CardDescription>
17 haneli arac kimlik numarasini girin
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="vin" className="text-sm font-medium">VIN Numarasi</Label>
<Input
id="vin"
placeholder="WVWZZZ3CZWE123456"
maxLength={17}
className="font-mono text-lg uppercase tracking-wider h-14 text-center border-2 focus:border-purple-500 focus:ring-purple-500"
{...register('vin')}
/>
<div className="flex justify-between text-sm">
<span className={`transition-colors ${vinValue.length === 17 ? 'text-green-600 font-medium' : 'text-muted-foreground'}`}>
{vinValue.length}/17 karakter
</span>
{errors.vin && (
<span className="text-destructive">{errors.vin.message}</span>
)}
</div>
</div>
{error && (
<div className="flex items-center gap-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4 text-sm text-red-700 dark:text-red-300">
<AlertCircle className="h-5 w-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<Button
type="submit"
className="w-full h-12 gradient-bg hover:opacity-90 shadow-lg shadow-purple-500/25 text-base group"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sorgulanıyor...
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
Sorgula
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
</form>
</CardContent>
</Card>
{/* Info Card */}
<Card className="border-0 shadow-md bg-gradient-to-br from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-purple-900 dark:text-purple-100">
<Info className="h-5 w-5 text-purple-600" />
VIN Nedir?
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-purple-800 dark:text-purple-200 space-y-4">
<p>
VIN (Vehicle Identification Number), her araca ozgu 17 karakterlik bir kimlik numarasidir.
Bu numara aracinizin ruhsatinda, kapi pervazinda veya on camin altinda bulunabilir.
</p>
<div className="grid gap-2">
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">1-3</div>
<div>
<p className="font-medium text-purple-900 dark:text-purple-100">Uretici Kodu (WMI)</p>
<p className="text-xs text-purple-600 dark:text-purple-300">Ulke ve uretici bilgisi</p>
</div>
</div>
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">4-9</div>
<div>
<p className="font-medium text-purple-900 dark:text-purple-100">Arac Ozellikleri (VDS)</p>
<p className="text-xs text-purple-600 dark:text-purple-300">Model, motor ve donanim</p>
</div>
</div>
<div className="flex items-center gap-3 bg-white/50 dark:bg-gray-900/30 rounded-lg p-3">
<div className="h-8 w-8 rounded-full bg-purple-100 dark:bg-purple-900/50 flex items-center justify-center text-xs font-bold text-purple-600">10-17</div>
<div>
<p className="font-medium text-purple-900 dark:text-purple-100">Uretim Bilgileri (VIS)</p>
<p className="text-xs text-purple-600 dark:text-purple-300">Yil ve seri numarasi</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

24
apps/web/app/error.tsx Normal file
View File

@@ -0,0 +1,24 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<h2 className="text-2xl font-bold">Bir hata olustu</h2>
<p className="text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Tekrar dene</Button>
</div>
);
}

25
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/styles/globals.css';
import { Providers } from '@/providers/providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Sase.tr - Arac Sase Sorgulama',
description: 'VIN numarasi ile arac bilgisi ve yedek parca sorgulama platformu',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="tr" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}

9
apps/web/app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Loader2 } from 'lucide-react';
export default function Loading() {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
export default function NotFound() {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4">
<h2 className="text-4xl font-bold">404</h2>
<p className="text-xl text-muted-foreground">Sayfa bulunamadi</p>
<Button asChild>
<Link href="/">Ana Sayfaya Don</Link>
</Button>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,46 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm hover:shadow-md',
outline: 'border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-5 py-2',
sm: 'h-9 rounded-lg px-4',
lg: 'h-12 rounded-xl px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-300',
className
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,20 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,111 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,33 @@
'use client';
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,22 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
];
export default eslintConfig;

154
apps/web/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,154 @@
'use client';
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType['ADD_TOAST']; toast: ToasterToast }
| { type: ActionType['UPDATE_TOAST']; toast: Partial<ToasterToast> }
| { type: ActionType['DISMISS_TOAST']; toastId?: ToasterToast['id'] }
| { type: ActionType['REMOVE_TOAST']; toastId?: ToasterToast['id'] };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: 'REMOVE_TOAST', toastId: toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined ? { ...t, open: false } : t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return { ...state, toasts: [] };
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) => dispatch({ type: 'UPDATE_TOAST', toast: { ...props, id } });
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return { id, dismiss, update };
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

View File

@@ -0,0 +1,59 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error),
);
// Response interceptor
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token');
}
const response = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);

19
apps/web/lib/constants.ts Normal file
View File

@@ -0,0 +1,19 @@
export const APP_NAME = 'Sase.tr';
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export const VIN_LENGTH = 17;
export const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/i;
export const ROUTES = {
HOME: '/',
LOGIN: '/login',
REGISTER: '/register',
FORGOT_PASSWORD: '/forgot-password',
DASHBOARD: '/',
VEHICLES: '/vehicles',
VEHICLE_SEARCH: '/vehicles/search',
SUBSCRIPTION: '/subscription',
SUBSCRIPTION_PLANS: '/subscription/plans',
PROFILE: '/profile',
} as const;

29
apps/web/lib/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number, currency: string = 'TRY'): string {
return new Intl.NumberFormat('tr-TR', {
style: 'currency',
currency,
minimumFractionDigits: 0,
}).format(amount);
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
export function formatVin(vin: string): string {
const normalized = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/gi, '');
if (normalized.length !== 17) return vin;
return `${normalized.slice(0, 3)} ${normalized.slice(3, 9)} ${normalized.slice(9)}`;
}

6
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

23
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ['@sase/shared'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
// Next.js 16.1: Turbopack is now the default bundler
// File system caching is enabled by default for faster dev restarts
};
export default nextConfig;

52
apps/web/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"clean": "rm -rf .next"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@sase/shared": "workspace:*",
"@tanstack/react-query": "^5.62.7",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"next": "16.1.3",
"next-auth": "4.24.13",
"next-themes": "^0.4.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.54.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,97 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api-client';
interface User {
id: string;
email: string;
name: string | null;
avatar: string | null;
}
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const refreshUser = async () => {
try {
const token = localStorage.getItem('accessToken');
if (!token) {
setUser(null);
return;
}
const response = await apiClient.get('/auth/me');
setUser(response.data.data);
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
}
};
useEffect(() => {
refreshUser().finally(() => setIsLoading(false));
}, []);
const login = async (email: string, password: string) => {
const response = await apiClient.post('/auth/login', { email, password });
const { user, accessToken, refreshToken } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
// Redirect is handled by the calling component
};
const register = async (email: string, password: string, name?: string) => {
const response = await apiClient.post('/auth/register', { email, password, name });
const { user, accessToken, refreshToken } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
// Redirect is handled by the calling component
};
const logout = async () => {
try {
await apiClient.post('/auth/logout');
} catch {
// Ignore errors
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
router.push('/login');
}
};
return (
<AuthContext.Provider value={{ user, isLoading, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,24 @@
'use client';
import { QueryProvider } from './query-provider';
import { ThemeProvider } from './theme-provider';
import { AuthProvider } from './auth-provider';
import { Toaster } from '@/components/ui/toaster';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<QueryProvider>
<AuthProvider>
{children}
<Toaster />
</AuthProvider>
</QueryProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,9 @@
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

101
apps/web/styles/globals.css Normal file
View File

@@ -0,0 +1,101 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 224 71% 4%;
--primary: 262 83% 58%;
--primary-foreground: 210 40% 98%;
--secondary: 220 14% 96%;
--secondary-foreground: 220 9% 46%;
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--accent: 262 83% 58%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262 83% 58%;
--radius: 0.75rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 210 40% 98%;
--card: 224 71% 8%;
--card-foreground: 210 40% 98%;
--popover: 224 71% 8%;
--popover-foreground: 210 40% 98%;
--primary: 263 70% 50%;
--primary-foreground: 210 40% 98%;
--secondary: 215 28% 17%;
--secondary-foreground: 210 40% 98%;
--muted: 215 28% 17%;
--muted-foreground: 217 10% 65%;
--accent: 263 70% 50%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62% 30%;
--destructive-foreground: 210 40% 98%;
--border: 215 28% 17%;
--input: 215 28% 17%;
--ring: 263 70% 50%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
}
}
@layer utilities {
.gradient-text {
@apply bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 bg-clip-text text-transparent;
}
.gradient-bg {
@apply bg-gradient-to-br from-violet-600 via-purple-600 to-indigo-600;
}
.glass {
@apply backdrop-blur-xl bg-white/70 dark:bg-gray-900/70 border border-white/20;
}
.card-hover {
@apply transition-all duration-300 hover:shadow-xl hover:shadow-purple-500/10 hover:-translate-y-1;
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-pulse-slow {
animation: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 8s ease infinite;
}
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}

View File

@@ -0,0 +1,78 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;

41
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long