feat: admin user creation, Vite migration, dialog fix, pl24 integration
- Add POST /admin/users endpoint with password hashing and role support - Add user creation dialog to admin users page - Migrate web from Next.js to Vite + TanStack Router - Fix Dialog component positioning for Tailwind CSS v4 - Add @source directive for @sase/ui package scanning - Add pl24 integration parsers and vehicle decode flow - Backup old Next.js app to apps/web-nj Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
225
apps/web/src/components/categories/category-grid.tsx
Normal file
225
apps/web/src/components/categories/category-grid.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: Category[];
|
||||
partCount?: number;
|
||||
}
|
||||
|
||||
interface CategoryGridProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbItem {
|
||||
id: string;
|
||||
name: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
|
||||
const navigate = useNavigate();
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<BreadcrumbItem[]>([]);
|
||||
const [currentCategories, setCurrentCategories] = useState(categories);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDrillDown = useCallback(
|
||||
async (category: Category) => {
|
||||
// If children are already loaded, drill down immediately
|
||||
if (category.children && category.children.length > 0) {
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
{ id: category.id, name: category.name, categories: currentCategories },
|
||||
]);
|
||||
setCurrentCategories(category.children);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch children from API
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<Category[]>(
|
||||
`/categories/${category.id}/children`,
|
||||
);
|
||||
if (data && data.length > 0) {
|
||||
setBreadcrumbs((prev) => [
|
||||
...prev,
|
||||
{ id: category.id, name: category.name, categories: currentCategories },
|
||||
]);
|
||||
setCurrentCategories(data);
|
||||
} else {
|
||||
// Leaf node — navigate to parts page
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id: vehicleId, categoryId: category.id },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fetch failed — treat as leaf
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentCategories, navigate, vehicleId],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (breadcrumbs.length === 0) return;
|
||||
const prev = breadcrumbs[breadcrumbs.length - 1];
|
||||
setCurrentCategories(prev.categories);
|
||||
setBreadcrumbs((b) => b.slice(0, -1));
|
||||
}, [breadcrumbs]);
|
||||
|
||||
const handleBreadcrumbClick = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
// Root
|
||||
setCurrentCategories(categories);
|
||||
setBreadcrumbs([]);
|
||||
return;
|
||||
}
|
||||
const target = breadcrumbs[index];
|
||||
// Navigate to the children that were shown when this breadcrumb was created
|
||||
// We need to re-fetch or use stored data from the next breadcrumb
|
||||
if (index < breadcrumbs.length - 1) {
|
||||
const next = breadcrumbs[index + 1];
|
||||
setCurrentCategories(next.categories);
|
||||
} else {
|
||||
setCurrentCategories(target.categories);
|
||||
}
|
||||
setBreadcrumbs((b) => b.slice(0, index + 1));
|
||||
},
|
||||
[breadcrumbs, categories],
|
||||
);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Kategori bulunamadi.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBreadcrumbClick(-1)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Kategoriler
|
||||
</button>
|
||||
{breadcrumbs.map((bc, i) => (
|
||||
<span key={bc.id} className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
{i === breadcrumbs.length - 1 ? (
|
||||
<span className="font-medium text-foreground">{bc.name}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{bc.name}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{currentCategories.map((category) => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isLeaf =
|
||||
category.children !== undefined && category.children.length === 0;
|
||||
|
||||
if (isLeaf) {
|
||||
return (
|
||||
<Link
|
||||
key={category.id}
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id: vehicleId, categoryId: category.id }}
|
||||
>
|
||||
<CategoryCard
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleDrillDown(category)}
|
||||
className="text-left"
|
||||
>
|
||||
<CategoryCard
|
||||
name={category.name}
|
||||
partCount={category.partCount}
|
||||
Icon={Icon}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCard({
|
||||
name,
|
||||
partCount,
|
||||
Icon,
|
||||
}: {
|
||||
name: string;
|
||||
partCount?: number;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border hover:border-foreground/20 hover:shadow-md 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-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
{name}
|
||||
</h3>
|
||||
{partCount != null && partCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{partCount} parça</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, ChevronDown, FolderOpen, Folder } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getCategoryIcon } from "@/lib/category-icons";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -14,10 +14,9 @@ interface Category {
|
||||
interface CategoryTreeProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export function CategoryTree({ categories, vehicleId, basePath }: CategoryTreeProps) {
|
||||
export function CategoryTree({ categories, vehicleId }: CategoryTreeProps) {
|
||||
if (!categories || categories.length === 0) {
|
||||
return (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
@@ -33,7 +32,6 @@ export function CategoryTree({ categories, vehicleId, basePath }: CategoryTreePr
|
||||
key={category.id}
|
||||
category={category}
|
||||
vehicleId={vehicleId}
|
||||
basePath={basePath}
|
||||
level={0}
|
||||
/>
|
||||
))}
|
||||
@@ -44,16 +42,45 @@ export function CategoryTree({ categories, vehicleId, basePath }: CategoryTreePr
|
||||
interface CategoryNodeProps {
|
||||
category: Category;
|
||||
vehicleId: string;
|
||||
basePath?: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProps) {
|
||||
function CategoryNode({ category, vehicleId, level }: CategoryNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = category.children && category.children.length > 0;
|
||||
const href = basePath
|
||||
? `${basePath}/${category.id}`
|
||||
: `/vehicles/${vehicleId}/categories/${category.id}`;
|
||||
const [children, setChildren] = useState<Category[]>(category.children || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(category.children !== undefined);
|
||||
|
||||
const hasChildren = children.length > 0;
|
||||
const isLeaf = fetched && children.length === 0;
|
||||
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (expanded) {
|
||||
setExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already fetched children, just expand
|
||||
if (fetched) {
|
||||
setExpanded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazy-load children from API
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<Category[]>(`/categories/${category.id}/children`);
|
||||
setChildren(data || []);
|
||||
setFetched(true);
|
||||
setExpanded(true);
|
||||
} catch {
|
||||
// If fetch fails, mark as fetched (leaf node)
|
||||
setFetched(true);
|
||||
setExpanded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [expanded, fetched, category.id]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -61,10 +88,16 @@ function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProp
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
{loading ? (
|
||||
<span className="flex h-5 w-5 items-center justify-center">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
) : isLeaf ? (
|
||||
<span className="h-5 w-5" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
onClick={handleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
|
||||
>
|
||||
{expanded ? (
|
||||
@@ -73,17 +106,28 @@ function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProp
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
{expanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
{(() => {
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
return <Icon className="h-4 w-4 text-muted-foreground" />;
|
||||
})()}
|
||||
{isLeaf ? (
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id: vehicleId, categoryId: category.id }}
|
||||
className="flex-1 truncate hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</Link>
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
)}
|
||||
<Link href={href} className="flex-1 truncate hover:underline">
|
||||
{category.name}
|
||||
</Link>
|
||||
{category.partCount != null && category.partCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{category.partCount}
|
||||
@@ -92,12 +136,11 @@ function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProp
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{category.children!.map((child) => (
|
||||
{children.map((child) => (
|
||||
<CategoryNode
|
||||
key={child.id}
|
||||
category={child}
|
||||
vehicleId={vehicleId}
|
||||
basePath={basePath}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Globe, LogOut, Menu } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
|
||||
const localeOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "tr", label: "TR" },
|
||||
{ value: "en", label: "EN" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const { user, signOut } = useAuth();
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
const langRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (langRef.current && !langRef.current.contains(e.target as Node)) {
|
||||
setLangOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-14 items-center justify-between border-b px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold lg:hidden">Sase.tr</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language Toggle */}
|
||||
<div className="relative" ref={langRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setLangOpen(!langOpen)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">{locale.toUpperCase()}</span>
|
||||
</Button>
|
||||
{langOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-32 rounded-md border bg-background shadow-md">
|
||||
{localeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-accent ${
|
||||
locale === opt.value ? "bg-accent font-medium" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setLocale(opt.value);
|
||||
setLangOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.value === "tr" ? "Turkce" : "English"}
|
||||
{locale === opt.value && <span className="ml-auto text-primary">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="hidden text-sm text-muted-foreground sm:inline">{user?.name}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => signOut()}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<MobileNav open={mobileOpen} onClose={() => setMobileOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Search, History, CreditCard, Receipt, Settings, ShieldCheck, Users, Activity, Gift, X } from "lucide-react";
|
||||
import { Button } from "@sase/ui";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard/search", label: "Arama", icon: Search },
|
||||
{ href: "/dashboard/history", label: "Geçmiş", icon: History },
|
||||
{ href: "/dashboard/subscription", label: "Abonelik", icon: CreditCard },
|
||||
{ href: "/dashboard/billing", label: "Fatura", icon: Receipt },
|
||||
{ href: "/dashboard/settings", label: "Ayarlar", icon: Settings },
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
{ href: "/dashboard/admin", label: "Admin Panel", icon: ShieldCheck, exact: true },
|
||||
{ href: "/dashboard/admin/users", label: "Kullanicilar", icon: Users },
|
||||
{ href: "/dashboard/admin/payments", label: "Odeme Onaylari", icon: Receipt },
|
||||
{ href: "/dashboard/admin/referrals", label: "Referanslar", icon: Gift },
|
||||
{ href: "/dashboard/admin/analytics", label: "Sorgu Analizi", icon: Activity },
|
||||
];
|
||||
|
||||
interface MobileNavProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileNav({ open, onClose }: MobileNavProps) {
|
||||
const pathname = usePathname();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="fixed inset-y-0 left-0 w-64 bg-background border-r shadow-lg">
|
||||
<div className="flex h-14 items-center justify-between border-b px-6">
|
||||
<span className="font-bold text-lg">Sase.tr</span>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="space-y-1 p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="my-4 border-t" />
|
||||
<p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Yonetim
|
||||
</p>
|
||||
{adminItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = "exact" in item && item.exact
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@sase/ui";
|
||||
import { Search, History, CreditCard, Receipt, Settings, ShieldCheck, Users, Activity, Gift } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard/search", label: "Arama", icon: Search },
|
||||
{ href: "/dashboard/history", label: "Geçmiş", icon: History },
|
||||
{ href: "/dashboard/subscription", label: "Abonelik", icon: CreditCard },
|
||||
{ href: "/dashboard/billing", label: "Fatura", icon: Receipt },
|
||||
{ href: "/dashboard/settings", label: "Ayarlar", icon: Settings },
|
||||
];
|
||||
|
||||
const adminItems = [
|
||||
{ href: "/dashboard/admin", label: "Admin Panel", icon: ShieldCheck, exact: true },
|
||||
{ href: "/dashboard/admin/users", label: "Kullanicilar", icon: Users },
|
||||
{ href: "/dashboard/admin/payments", label: "Odeme Onaylari", icon: Receipt },
|
||||
{ href: "/dashboard/admin/referrals", label: "Referanslar", icon: Gift },
|
||||
{ href: "/dashboard/admin/analytics", label: "Sorgu Analizi", icon: Activity },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 border-r bg-muted/30 lg:block">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-14 items-center border-b px-6">
|
||||
<Link href="/dashboard/search" className="flex items-center gap-2 font-bold text-lg">
|
||||
Sase.tr
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="my-4 border-t" />
|
||||
<p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Yonetim
|
||||
</p>
|
||||
{adminItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = "exact" in item && item.exact
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
@@ -8,9 +6,9 @@ import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Separator } from "@sase/ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
FileText,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -32,6 +29,12 @@ interface Brand {
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
interface PaymentContentProps {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
}
|
||||
|
||||
const planConfig: Record<string, { priceMonthly: number; priceYearly: number }> = {
|
||||
brand1: { priceMonthly: 20000, priceYearly: 200000 },
|
||||
brand2: { priceMonthly: 35000, priceYearly: 350000 },
|
||||
@@ -56,16 +59,11 @@ function formatTRY(amount: number): string {
|
||||
|
||||
type Step = "summary" | "payment" | "confirmation";
|
||||
|
||||
export function PaymentContent() {
|
||||
export function PaymentContent({ planKey, period, brandIds }: PaymentContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const planKey = searchParams.get("plan") || "";
|
||||
const period = (searchParams.get("period") || "monthly") as "monthly" | "yearly";
|
||||
const brandIds = searchParams.get("brands")?.split(",").filter(Boolean) || [];
|
||||
|
||||
const [step, setStep] = useState<Step>("summary");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"iyzico" | "eft">("iyzico");
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
@@ -199,7 +197,7 @@ export function PaymentContent() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => router.push("/dashboard/subscription")}
|
||||
onClick={() => navigate({ to: "/dashboard/subscription" })}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("common.back")}
|
||||
@@ -221,7 +219,7 @@ export function PaymentContent() {
|
||||
? t("payment.confirmationDescription")
|
||||
: t("payment.eftConfirmationDescription")}
|
||||
</p>
|
||||
<Button className="mt-6" onClick={() => router.push("/dashboard/search")}>
|
||||
<Button className="mt-6" onClick={() => navigate({ to: "/dashboard/search" })}>
|
||||
{t("payment.goToDashboard")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -237,7 +235,7 @@ export function PaymentContent() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
step === "payment" ? setStep("summary") : router.push("/dashboard/subscription")
|
||||
step === "payment" ? setStep("summary") : navigate({ to: "/dashboard/subscription" })
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import type { Hotspot } from "@/hooks/use-parts";
|
||||
|
||||
@@ -78,7 +76,7 @@ export function HotspotOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
}: HotspotOverlayProps) {
|
||||
const { highlightedPartId, selectedPartId, setHighlightedPart, setSelectedPart } =
|
||||
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||
useSchemaStore();
|
||||
|
||||
return (
|
||||
@@ -89,8 +87,8 @@ export function HotspotOverlay({
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{hotspots.map((hotspot) => {
|
||||
const isHighlighted = highlightedPartId === hotspot.partId;
|
||||
const isSelected = selectedPartId === hotspot.partId;
|
||||
const isHighlighted = highlightedGroup === hotspot.group;
|
||||
const isSelected = selectedGroup === hotspot.group;
|
||||
|
||||
return (
|
||||
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
|
||||
@@ -98,11 +96,11 @@ export function HotspotOverlay({
|
||||
hotspot={hotspot}
|
||||
isHighlighted={isHighlighted}
|
||||
isSelected={isSelected}
|
||||
onMouseEnter={() => setHighlightedPart(hotspot.partId)}
|
||||
onMouseLeave={() => setHighlightedPart(null)}
|
||||
onMouseEnter={() => setHighlightedGroup(hotspot.group)}
|
||||
onMouseLeave={() => setHighlightedGroup(null)}
|
||||
onClick={() =>
|
||||
setSelectedPart(
|
||||
selectedPartId === hotspot.partId ? null : hotspot.partId,
|
||||
setSelectedGroup(
|
||||
selectedGroup === hotspot.group ? null : hotspot.group,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { cn } from "@sase/ui";
|
||||
@@ -10,18 +8,18 @@ interface PartsPanelProps {
|
||||
}
|
||||
|
||||
export function PartsPanel({ parts }: PartsPanelProps) {
|
||||
const { highlightedPartId, selectedPartId, setHighlightedPart, setSelectedPart } =
|
||||
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
|
||||
useSchemaStore();
|
||||
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
|
||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPartId) {
|
||||
const row = rowRefs.current.get(selectedPartId);
|
||||
if (selectedGroup != null) {
|
||||
const row = rowRefs.current.get(selectedGroup);
|
||||
if (row) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, [selectedPartId]);
|
||||
}, [selectedGroup]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
@@ -45,17 +43,17 @@ export function PartsPanel({ parts }: PartsPanelProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((part) => {
|
||||
const isHighlighted = highlightedPartId === part.id;
|
||||
const isSelected = selectedPartId === part.id;
|
||||
const group = part.hotspotIndex;
|
||||
const isHighlighted = group != null && highlightedGroup === group;
|
||||
const isSelected = group != null && selectedGroup === group;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={part.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
rowRefs.current.set(part.id, el);
|
||||
} else {
|
||||
rowRefs.current.delete(part.id);
|
||||
// Store ref for the first part in each group (for scroll-to)
|
||||
if (group != null && el && !rowRefs.current.has(group)) {
|
||||
rowRefs.current.set(group, el);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -65,16 +63,16 @@ export function PartsPanel({ parts }: PartsPanelProps) {
|
||||
isHighlighted && !isSelected && "bg-accent",
|
||||
!isSelected && !isHighlighted && "hover:bg-accent/50",
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedPart(part.id)}
|
||||
onMouseLeave={() => setHighlightedPart(null)}
|
||||
onMouseEnter={() => setHighlightedGroup(group)}
|
||||
onMouseLeave={() => setHighlightedGroup(null)}
|
||||
onClick={() =>
|
||||
setSelectedPart(
|
||||
selectedPartId === part.id ? null : part.id,
|
||||
setSelectedGroup(
|
||||
selectedGroup === group ? null : group,
|
||||
)
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{part.index}
|
||||
{part.hotspotIndex}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium">{part.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@sase/ui";
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Maximize, Minimize } from "lucide-react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useSchemaStore } from "@/stores/schema.store";
|
||||
import { useSchemaInteraction } from "@/hooks/use-schema-interaction";
|
||||
@@ -110,7 +108,6 @@ export function SchemaViewer({
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={schemaPic.url}
|
||||
alt={schemaPic.label || "Sema goruntusu"}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
@@ -341,7 +339,7 @@ export function SettingsContent() {
|
||||
<Label>{t("settings.referral.shareLink")}</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={`${typeof window !== "undefined" ? window.location.origin : ""}/register?ref=${user.referralCode}`}
|
||||
value={`${window.location.origin}/register?ref=${user.referralCode}`}
|
||||
readOnly
|
||||
className="text-sm"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Badge } from "@sase/ui";
|
||||
|
||||
@@ -10,14 +8,11 @@ interface VehicleCardProps {
|
||||
brandName: string;
|
||||
model: string;
|
||||
year?: number | string | null;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function VehicleCard({ id, vin, brandName, model, year, href }: VehicleCardProps) {
|
||||
const linkHref = href || `/dashboard/vehicles/${id}`;
|
||||
|
||||
export function VehicleCard({ id, vin, brandName, model, year }: VehicleCardProps) {
|
||||
return (
|
||||
<Link href={linkHref}>
|
||||
<Link to="/dashboard/vehicles/$id" params={{ id }}>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Search } from "lucide-react";
|
||||
import { isValidVin } from "@sase/shared";
|
||||
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
function isValidVin(vin: string): boolean {
|
||||
if (!vin || vin.length !== 17) return false;
|
||||
return VIN_REGEX.test(vin.toUpperCase());
|
||||
}
|
||||
|
||||
interface VinInputProps {
|
||||
onSubmit: (vin: string) => void;
|
||||
|
||||
Reference in New Issue
Block a user