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:
Sase Dev
2026-02-12 14:24:58 +00:00
parent bf7f876abd
commit 996d614d50
211 changed files with 23805 additions and 549 deletions

View File

@@ -1,5 +1,3 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { BarChart3 } from "lucide-react";

View 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>
);
}

View File

@@ -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}
/>
))}

View File

@@ -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">&#10003;</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)} />
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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" />

View File

@@ -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,
)
}
/>

View File

@@ -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">

View File

@@ -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";

View File

@@ -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"}

View File

@@ -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"
/>

View File

@@ -1,5 +1,3 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent } from "@sase/ui";

View File

@@ -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">

View File

@@ -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;