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:
20
apps/web/.env.example
Normal file
20
apps/web/.env.example
Normal 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=
|
||||
125
apps/web/app/(auth)/forgot-password/page.tsx
Normal file
125
apps/web/app/(auth)/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/web/app/(auth)/layout.tsx
Normal file
73
apps/web/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
apps/web/app/(auth)/login/page.tsx
Normal file
157
apps/web/app/(auth)/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
173
apps/web/app/(auth)/register/page.tsx
Normal file
173
apps/web/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/app/(marketing)/layout.tsx
Normal file
7
apps/web/app/(marketing)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
254
apps/web/app/(marketing)/page.tsx
Normal file
254
apps/web/app/(marketing)/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
176
apps/web/app/dashboard/layout.tsx
Normal file
176
apps/web/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
apps/web/app/dashboard/page.tsx
Normal file
145
apps/web/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/web/app/dashboard/profile/page.tsx
Normal file
161
apps/web/app/dashboard/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
apps/web/app/dashboard/subscription/page.tsx
Normal file
238
apps/web/app/dashboard/subscription/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
297
apps/web/app/dashboard/vehicles/[vin]/categories/page.tsx
Normal file
297
apps/web/app/dashboard/vehicles/[vin]/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
apps/web/app/dashboard/vehicles/[vin]/page.tsx
Normal file
231
apps/web/app/dashboard/vehicles/[vin]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
apps/web/app/dashboard/vehicles/page.tsx
Normal file
135
apps/web/app/dashboard/vehicles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/web/app/dashboard/vehicles/search/page.tsx
Normal file
186
apps/web/app/dashboard/vehicles/search/page.tsx
Normal 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
24
apps/web/app/error.tsx
Normal 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
25
apps/web/app/layout.tsx
Normal 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
9
apps/web/app/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web/app/not-found.tsx
Normal file
14
apps/web/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/components/ui/badge.tsx
Normal file
35
apps/web/components/ui/badge.tsx
Normal 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 };
|
||||
46
apps/web/components/ui/button.tsx
Normal file
46
apps/web/components/ui/button.tsx
Normal 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 };
|
||||
51
apps/web/components/ui/card.tsx
Normal file
51
apps/web/components/ui/card.tsx
Normal 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 };
|
||||
23
apps/web/components/ui/input.tsx
Normal file
23
apps/web/components/ui/input.tsx
Normal 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 };
|
||||
20
apps/web/components/ui/label.tsx
Normal file
20
apps/web/components/ui/label.tsx
Normal 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 };
|
||||
111
apps/web/components/ui/toast.tsx
Normal file
111
apps/web/components/ui/toast.tsx
Normal 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,
|
||||
};
|
||||
33
apps/web/components/ui/toaster.tsx
Normal file
33
apps/web/components/ui/toaster.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/eslint.config.mjs
Normal file
22
apps/web/eslint.config.mjs
Normal 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
154
apps/web/hooks/use-toast.ts
Normal 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 };
|
||||
59
apps/web/lib/api-client.ts
Normal file
59
apps/web/lib/api-client.ts
Normal 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
19
apps/web/lib/constants.ts
Normal 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
29
apps/web/lib/utils.ts
Normal 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
6
apps/web/next-env.d.ts
vendored
Normal 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
23
apps/web/next.config.ts
Normal 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
52
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
apps/web/postcss.config.mjs
Normal file
9
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
97
apps/web/providers/auth-provider.tsx
Normal file
97
apps/web/providers/auth-provider.tsx
Normal 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;
|
||||
}
|
||||
24
apps/web/providers/providers.tsx
Normal file
24
apps/web/providers/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
apps/web/providers/query-provider.tsx
Normal file
23
apps/web/providers/query-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/web/providers/theme-provider.tsx
Normal file
9
apps/web/providers/theme-provider.tsx
Normal 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
101
apps/web/styles/globals.css
Normal 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%; }
|
||||
}
|
||||
78
apps/web/tailwind.config.ts
Normal file
78
apps/web/tailwind.config.ts
Normal 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
41
apps/web/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user