feat: add OEM code copy tracking, PostHog analytics, Postal email integration

- Add oem_code_copies table and analytics module for tracking part code copies
- Integrate PostHog for frontend product analytics (VIN decode, OEM copy events)
- Switch email service from stub to Postal API with proper error handling
- Add copy button to parts panel with clipboard + server-side logging
- Add admin copy-logs page with filtering and top-copied-codes view
- Add VIN report endpoint for users to flag unrecognized chassis numbers
- Add Postal email env vars to config schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-17 18:09:07 +00:00
parent dcbeb83ccc
commit f2126754a6
42 changed files with 1307 additions and 223 deletions

View File

@@ -33,22 +33,27 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
const [currentCategories, setCurrentCategories] = useState(categories);
const [loading, setLoading] = useState(false);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [navKey, setNavKey] = useState(0);
// Prefetch schema images for leaf categories in batches of 2
const prefetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const leafsWithoutImage = currentCategories.filter(
prefetchedRef.current.clear();
const cats = currentCategories;
const leafsWithoutImage = cats.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
!c.schemaImageUrl &&
!prefetchedRef.current.has(c.id),
!c.schemaImageUrl,
);
if (leafsWithoutImage.length === 0) return;
for (const c of leafsWithoutImage) prefetchedRef.current.add(c.id);
if (leafsWithoutImage.length === 0) {
setPrefetchingIds(new Set());
return;
}
const parentId = currentCategories[0]?.parentId;
const parentId = cats[0]?.parentId;
let didCancel = false;
const BATCH_SIZE = 2;
@@ -62,7 +67,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
);
// Refresh after each batch to show images progressively
for (const c of batch) prefetchedRef.current.add(c.id);
if (!didCancel && parentId) {
try {
const refreshed = await api.get<Category[]>(
@@ -87,7 +93,8 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
return () => {
didCancel = true;
};
}, [currentCategories, vehicleId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navKey, vehicleId]);
const handleDrillDown = useCallback(
async (category: Category) => {
@@ -100,6 +107,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
{ id: category.id, name: category.name, categories: currentCategories },
]);
setCurrentCategories(cachedChildren);
setNavKey((k) => k + 1);
// Enrich with schema images from API in background
queryClient
@@ -131,6 +139,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
{ id: category.id, name: category.name, categories: currentCategories },
]);
setCurrentCategories(data);
setNavKey((k) => k + 1);
} else {
// Leaf node — navigate to parts page
navigate({
@@ -152,6 +161,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
const prev = breadcrumbs[breadcrumbs.length - 1];
setCurrentCategories(prev.categories);
setBreadcrumbs((b) => b.slice(0, -1));
setNavKey((k) => k + 1);
}, [breadcrumbs]);
const handleBreadcrumbClick = useCallback(
@@ -160,6 +170,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
// Root
setCurrentCategories(categories);
setBreadcrumbs([]);
setNavKey((k) => k + 1);
return;
}
const target = breadcrumbs[index];
@@ -172,6 +183,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
setCurrentCategories(target.categories);
}
setBreadcrumbs((b) => b.slice(0, index + 1));
setNavKey((k) => k + 1);
},
[breadcrumbs, categories],
);

View File

@@ -99,7 +99,8 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
if (!cancelled) setPrefetching(false);
})();
return () => { cancelled = true; };
}, [expanded, children, vehicleId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded, vehicleId]);
const Icon = getCategoryIcon(category.name);
const isShimmering = parentPrefetching && isLeaf && !category.schemaImageUrl;

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
@@ -179,17 +180,20 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
function handlePayWithCard() {
startAction("payment-iyzico", { plan: planKey, period, amount: String(totalAmount) });
capture("payment_initiated", { method: "iyzico", plan: planKey, period, amount: totalAmount });
iyzicoMutation.mutate();
}
function handleEftProceed() {
startAction("payment-eft", { plan: planKey, period, amount: String(totalAmount) });
capture("payment_initiated", { method: "eft", plan: planKey, period, amount: totalAmount });
eftMutation.mutate();
}
function handleUploadReceipt() {
if (uploadedFile) {
startAction("receipt-upload", { paymentId: eftPaymentId || "" });
capture("receipt_uploaded", { payment_id: eftPaymentId });
uploadMutation.mutate(uploadedFile);
}
}

View File

@@ -1,16 +1,45 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useSchemaStore } from "@/stores/schema.store";
import { cn } from "@sase/ui";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import type { Part } from "@/hooks/use-parts";
interface PartsPanelProps {
parts: Part[];
vehicleId?: string;
categoryId?: string;
}
export function PartsPanel({ parts }: PartsPanelProps) {
export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyOemCode = useCallback((e: React.MouseEvent, partId: string, code: string) => {
e.stopPropagation();
navigator.clipboard.writeText(code);
setCopiedId(partId);
setTimeout(() => setCopiedId(null), 1500);
api
.post("/analytics/oem-copy", {
oemCode: code,
partId,
vehicleId,
categoryId,
})
.catch(() => {});
capture("oem_code_copied", {
oem_code: code,
part_id: partId,
vehicle_id: vehicleId,
category_id: categoryId,
});
}, [vehicleId, categoryId]);
useEffect(() => {
if (selectedGroup != null) {
@@ -77,7 +106,22 @@ export function PartsPanel({ parts }: PartsPanelProps) {
</td>
<td className="px-3 py-2 font-medium">{part.name}</td>
<td className="px-3 py-2 font-mono text-xs">
{part.oemCode}
<span className="inline-flex items-center gap-1">
{part.oemCode && (
<button
type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => copyOemCode(e, part.id, part.oemCode)}
>
{copiedId === part.id ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
)}
{part.oemCode}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useSchemaStore } from "@/stores/schema.store";
import { useSchemaInteraction } from "@/hooks/use-schema-interaction";
import { SchemaToolbar } from "./schema-toolbar";
@@ -13,6 +13,8 @@ interface SchemaViewerProps {
hotspots: Hotspot[];
parts: Part[];
isLoading?: boolean;
vehicleId?: string;
categoryId?: string;
}
export function SchemaViewer({
@@ -20,10 +22,13 @@ export function SchemaViewer({
hotspots,
parts,
isLoading,
vehicleId,
categoryId,
}: SchemaViewerProps) {
const { zoom, panX, panY, isFullscreen } = useSchemaStore();
const interaction = useSchemaInteraction();
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const handleFullscreenChange = useCallback(() => {
const store = useSchemaStore.getState();
@@ -55,6 +60,14 @@ export function SchemaViewer({
}
}, [isFullscreen]);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const handler = interaction.onWheel;
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [interaction.onWheel]);
if (isLoading) {
return (
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
@@ -88,8 +101,8 @@ export function SchemaViewer({
{/* Schema viewport */}
<div
ref={viewportRef}
className="relative flex-1 cursor-grab overflow-hidden bg-muted/30 active:cursor-grabbing"
onWheel={interaction.onWheel}
onMouseDown={interaction.onMouseDown}
onMouseMove={interaction.onMouseMove}
onMouseUp={interaction.onMouseUp}
@@ -135,7 +148,7 @@ export function SchemaViewer({
{/* Right side: Parts panel (40%) */}
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
<PartsPanel parts={parts} />
<PartsPanel parts={parts} vehicleId={vehicleId} categoryId={categoryId} />
</div>
</div>
);

View File

@@ -22,6 +22,7 @@ import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Copy, Gift, Link2, Share2, Shield, Trash2, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "@/lib/toast";
import { capture } from "@/lib/posthog";
export function SettingsContent() {
const { t } = useTranslation();
@@ -121,6 +122,7 @@ export function SettingsContent() {
try {
await api.delete("/users/me");
toast.success(t("settings.account.deleted"));
capture("user_logged_out", { reason: "account_deleted" });
signOut();
} catch {
toast.error(t("settings.account.deleteFailed"));

View File

@@ -16,12 +16,10 @@ export function useSchemaInteraction() {
const lastTouchCenter = useRef<{ x: number; y: number } | null>(null);
const onWheel = useCallback(
(e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom(zoom + delta);
}
(e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom(zoom + delta);
},
[zoom, setZoom],
);

View File

@@ -0,0 +1,51 @@
import posthog from "posthog-js";
let initialized = false;
export function initPostHog() {
const key = import.meta.env.VITE_POSTHOG_KEY;
if (!key || initialized) return;
posthog.init(key, {
api_host: "https://eu.i.posthog.com",
person_profiles: "identified_only",
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
session_recording: {
maskAllInputs: false,
maskInputOptions: { password: true },
},
});
initialized = true;
}
export function identifyUser(user: {
id: string;
email: string;
name: string;
role: string;
}) {
posthog.identify(user.id, {
email: user.email,
name: user.name,
role: user.role,
});
}
export function resetUser() {
posthog.reset();
}
export function capture(event: string, properties?: Record<string, unknown>) {
posthog.capture(event, properties);
}
export function capturePageView(path: string) {
posthog.capture("$pageview", {
$current_url: window.location.origin + path,
});
}
export { posthog };

View File

@@ -1,4 +1,5 @@
import { initFaro } from "./lib/faro";
import { initPostHog } from "./lib/posthog";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
@@ -9,6 +10,9 @@ import "./globals.css";
// Initialize frontend observability (async, non-blocking)
initFaro();
// Initialize product analytics
initPostHog();
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -35,6 +35,7 @@ import { Route as DashboardSubscriptionPayRouteImport } from "./routes/dashboard
import { Route as DashboardAdminUsersRouteImport } from "./routes/dashboard/admin/users"
import { Route as DashboardAdminReferralsRouteImport } from "./routes/dashboard/admin/referrals"
import { Route as DashboardAdminPaymentsRouteImport } from "./routes/dashboard/admin/payments"
import { Route as DashboardAdminCopyLogsRouteImport } from "./routes/dashboard/admin/copy-logs"
import { Route as DashboardAdminAnalyticsRouteImport } from "./routes/dashboard/admin/analytics"
import { Route as DashboardVehiclesIdIndexRouteImport } from "./routes/dashboard/vehicles_/$id/index"
import { Route as DashboardVehiclesIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/vehicles_/$id/categories_/$categoryId"
@@ -170,6 +171,11 @@ const DashboardAdminPaymentsRoute = DashboardAdminPaymentsRouteImport.update({
path: "/admin/payments",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardAdminCopyLogsRoute = DashboardAdminCopyLogsRouteImport.update({
id: "/admin/copy-logs",
path: "/admin/copy-logs",
getParentRoute: () => DashboardRoute,
} as any)
const DashboardAdminAnalyticsRoute = DashboardAdminAnalyticsRouteImport.update({
id: "/admin/analytics",
path: "/admin/analytics",
@@ -209,6 +215,7 @@ export interface FileRoutesByFullPath {
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard/": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
"/dashboard/admin/copy-logs": typeof DashboardAdminCopyLogsRoute
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
@@ -238,6 +245,7 @@ export interface FileRoutesByTo {
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
"/dashboard/admin/copy-logs": typeof DashboardAdminCopyLogsRoute
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
@@ -270,6 +278,7 @@ export interface FileRoutesById {
"/dashboard/settings": typeof DashboardSettingsRoute
"/dashboard/": typeof DashboardIndexRoute
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
"/dashboard/admin/copy-logs": typeof DashboardAdminCopyLogsRoute
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
"/dashboard/admin/users": typeof DashboardAdminUsersRoute
@@ -302,6 +311,7 @@ export interface FileRouteTypes {
| "/dashboard/settings"
| "/dashboard/"
| "/dashboard/admin/analytics"
| "/dashboard/admin/copy-logs"
| "/dashboard/admin/payments"
| "/dashboard/admin/referrals"
| "/dashboard/admin/users"
@@ -331,6 +341,7 @@ export interface FileRouteTypes {
| "/dashboard/settings"
| "/dashboard"
| "/dashboard/admin/analytics"
| "/dashboard/admin/copy-logs"
| "/dashboard/admin/payments"
| "/dashboard/admin/referrals"
| "/dashboard/admin/users"
@@ -362,6 +373,7 @@ export interface FileRouteTypes {
| "/dashboard/settings"
| "/dashboard/"
| "/dashboard/admin/analytics"
| "/dashboard/admin/copy-logs"
| "/dashboard/admin/payments"
| "/dashboard/admin/referrals"
| "/dashboard/admin/users"
@@ -570,6 +582,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardAdminPaymentsRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/admin/copy-logs": {
id: "/dashboard/admin/copy-logs"
path: "/admin/copy-logs"
fullPath: "/dashboard/admin/copy-logs"
preLoaderRoute: typeof DashboardAdminCopyLogsRouteImport
parentRoute: typeof DashboardRoute
}
"/dashboard/admin/analytics": {
id: "/dashboard/admin/analytics"
path: "/admin/analytics"
@@ -617,6 +636,7 @@ interface DashboardRouteChildren {
DashboardSettingsRoute: typeof DashboardSettingsRoute
DashboardIndexRoute: typeof DashboardIndexRoute
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
DashboardAdminPaymentsRoute: typeof DashboardAdminPaymentsRoute
DashboardAdminReferralsRoute: typeof DashboardAdminReferralsRoute
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
@@ -634,6 +654,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardSettingsRoute: DashboardSettingsRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,
DashboardAdminPaymentsRoute: DashboardAdminPaymentsRoute,
DashboardAdminReferralsRoute: DashboardAdminReferralsRoute,
DashboardAdminUsersRoute: DashboardAdminUsersRoute,

View File

@@ -1,8 +1,10 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { createRootRouteWithContext, Outlet, useLocation } from "@tanstack/react-router";
import { Toaster } from "@/lib/toast";
import type { QueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { getUserSettings } from "@/lib/user-settings";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { useAuth } from "@/hooks/use-auth";
interface RouterContext {
queryClient: QueryClient;
@@ -21,6 +23,28 @@ function applyTheme(theme: "light" | "dark" | "system") {
}
function RootComponent() {
const location = useLocation();
const { user } = useAuth();
// Pageview tracking
useEffect(() => {
capturePageView(location.pathname);
}, [location.pathname]);
// User identification
useEffect(() => {
if (user) {
identifyUser({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
} else {
resetUser();
}
}, [user?.id]);
useEffect(() => {
const theme = getUserSettings().theme ?? "dark";
applyTheme(theme);

View File

@@ -19,15 +19,22 @@ function ForgotPasswordPage() {
setLoading(true);
try {
await fetch("/api/auth/forget-password", {
const res = await fetch("/api/auth/request-password-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, redirectTo: "/reset-password" }),
body: JSON.stringify({
email,
redirectTo: `${window.location.origin}/reset-password`,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || "Bir hata oluştu.");
}
setSent(true);
toast.success("Şifre sıfırlama bağlantısı gönderildi.");
} catch {
toast.error("Bir hata oluştu.");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Bir hata oluştu.");
} finally {
setLoading(false);
}

View File

@@ -5,6 +5,7 @@ import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
export const Route = createFileRoute("/_auth/login")({
@@ -24,6 +25,7 @@ function LoginPage() {
try {
await signIn.email({ email, password });
capture("user_logged_in", { method: "email" });
navigate({ to: "/dashboard/search" });
} catch {
toast.error("Giriş başarısız. E-posta veya şifre hatalı.");
@@ -108,6 +110,7 @@ function LoginPage() {
className="w-full"
onClick={() => {
startAction("login", { method: "google" });
capture("user_logged_in", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/search" });
}}
>

View File

@@ -5,6 +5,7 @@ import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { ShieldCheck } from "lucide-react";
@@ -25,6 +26,7 @@ function RegisterPage() {
try {
await signUp.email({ name, email, password });
capture("user_signed_up", { method: "email" });
toast.success("Hesap oluşturuldu!");
window.location.href = "/dashboard/subscription?welcome=1";
} catch {
@@ -111,6 +113,7 @@ function RegisterPage() {
className="w-full"
onClick={() => {
startAction("register", { method: "google" });
capture("user_signed_up", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" });
}}
>

View File

@@ -25,9 +25,11 @@ import {
BookOpen,
Sun,
Moon,
Copy,
} from "lucide-react";
import { useState } from "react";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { capture, resetUser } from "@/lib/posthog";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
@@ -57,6 +59,7 @@ const adminItems = [
{ to: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users },
{ to: "/dashboard/admin/payments", label: "Ödemeler", icon: DollarSign },
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
] as const;
@@ -127,6 +130,12 @@ function DashboardLayout() {
return theme === "dark";
});
const handleSignOut = () => {
capture("user_logged_out");
resetUser();
signOut();
};
const toggleTheme = () => {
const next = isDark ? "light" : "dark";
document.documentElement.classList.toggle("dark", next === "dark");
@@ -278,7 +287,7 @@ function DashboardLayout() {
<div className={`border-t border-border ${collapsed ? "p-2" : "p-3"}`}>
<button
type="button"
onClick={() => signOut()}
onClick={handleSignOut}
title={collapsed ? user.name ?? ıkış" : undefined}
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5"}`}
>
@@ -356,7 +365,7 @@ function DashboardLayout() {
</button>
<button
type="button"
onClick={() => signOut()}
onClick={handleSignOut}
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground lg:hidden"
>
<LogOut className="size-4" />
@@ -407,7 +416,7 @@ function DashboardLayout() {
<button
type="button"
onClick={() => {
signOut();
handleSignOut();
setMobileOpen(false);
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-accent"

View File

@@ -0,0 +1,318 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Tabs, TabsList, TabsTrigger } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Search,
X,
Copy,
TrendingUp,
} from "lucide-react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/dashboard/admin/copy-logs")({
component: AdminCopyLogsPage,
});
interface CopyLogItem {
id: string;
userId: string;
userName: string;
userEmail: string;
oemCode: string;
partId: string | null;
vehicleId: string | null;
categoryId: string | null;
createdAt: string;
}
interface CopyLogResponse {
items: CopyLogItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
interface TopCopiedCode {
oemCode: string;
copyCount: number;
uniqueUsers: number;
}
function AdminCopyLogsPage() {
const { user, isLoading: authLoading } = useAuth();
const navigate = useNavigate();
const [tab, setTab] = useState<"logs" | "top">("logs");
const [userIdFilter, setUserIdFilter] = useState("");
const [debouncedUserId, setDebouncedUserId] = useState("");
const [page, setPage] = useState(1);
const limit = 50;
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
navigate({ to: "/dashboard/search" });
}
}, [authLoading, user, navigate]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedUserId(userIdFilter);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [userIdFilter]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "copy-logs", debouncedUserId, page, limit],
queryFn: () => {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
if (debouncedUserId) params.set("userId", debouncedUserId);
return api.get<CopyLogResponse>(
`/admin/copy-logs?${params.toString()}`,
);
},
enabled: user?.role === "admin" && tab === "logs",
});
const { data: topCodes, isLoading: topLoading } = useQuery({
queryKey: ["admin", "copy-logs", "top"],
queryFn: () => api.get<TopCopiedCode[]>("/admin/copy-logs/top?days=30&limit=20"),
enabled: user?.role === "admin" && tab === "top",
});
if (authLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (user?.role !== "admin") return null;
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">OEM Kod Kopyalama</h2>
<Badge variant="outline">{data?.total ?? 0} kayit</Badge>
</div>
{/* Tabs */}
<Tabs value={tab} onValueChange={(v) => setTab(v as "logs" | "top")}>
<TabsList>
<TabsTrigger value="logs" className="gap-1.5">
<Copy className="size-3.5" />
Kopyalama Kayitlari
</TabsTrigger>
<TabsTrigger value="top" className="gap-1.5">
<TrendingUp className="size-3.5" />
En Cok Kopyalanan
</TabsTrigger>
</TabsList>
</Tabs>
{tab === "logs" && (
<>
{/* Filter */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[250px] max-w-md flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Kullanici ID ile filtrele..."
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value)}
className="pl-10"
/>
{userIdFilter && (
<button
type="button"
onClick={() => setUserIdFilter("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`copy-skel-${i}`} className="h-12 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Copy className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Kopyalama kaydi bulunamadi</p>
<p className="text-sm text-muted-foreground">
{userIdFilter
? "Bu kullaniciya ait kopyalama kaydi yok"
: "Henuz hicbir OEM kodu kopyalanmamis"}
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="overflow-x-auto p-0">
<div className="min-w-[700px]">
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>Kullanici</span>
<span>OEM Kodu</span>
<span>Tarih</span>
<span>Detay</span>
</div>
<div className="divide-y">
{data.items.map((log) => (
<div
key={log.id}
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
>
<div className="truncate">
<p className="truncate font-medium">{log.userName}</p>
<p className="truncate text-xs text-muted-foreground">
{log.userEmail}
</p>
</div>
<div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
{log.oemCode}
</code>
</div>
<div className="text-xs text-muted-foreground">
{formatDate(log.createdAt)}
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
{log.vehicleId && (
<Badge variant="outline" className="text-[10px]">
Arac
</Badge>
)}
{log.categoryId && (
<Badge variant="outline" className="text-[10px]">
Kategori
</Badge>
)}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Sayfa {data.page} / {data.totalPages} (Toplam {data.total})
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
Onceki
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= data.totalPages}
onClick={() => setPage((p) => p + 1)}
>
Sonraki
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
{tab === "top" && (
<>
{topLoading ? (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={`top-skel-${i}`} className="h-12 w-full" />
))}
</div>
) : !topCodes || topCodes.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<TrendingUp className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Veri bulunamadi</p>
<p className="text-sm text-muted-foreground">
Son 30 gunde kopyalanan OEM kodu yok
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="overflow-x-auto p-0">
<div className="min-w-[500px]">
<div className="grid grid-cols-4 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>#</span>
<span>OEM Kodu</span>
<span className="text-center">Kopyalanma</span>
<span className="text-center">Benzersiz Kullanici</span>
</div>
<div className="divide-y">
{topCodes.map((item, idx) => (
<div
key={item.oemCode}
className="grid grid-cols-4 items-center gap-4 px-6 py-3 text-sm"
>
<span className="text-muted-foreground">{idx + 1}</span>
<div>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-semibold">
{item.oemCode}
</code>
</div>
<div className="text-center font-medium">
{item.copyCount}
</div>
<div className="text-center text-muted-foreground">
{item.uniqueUsers}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
UserCog,
Receipt,
Activity,
Copy,
} from "lucide-react";
import { useEffect } from "react";
@@ -153,6 +154,11 @@ function AdminDashboardPage() {
label: "Sorgu Analizi",
icon: Activity,
},
{
to: "/dashboard/admin/copy-logs",
label: "OEM Kopyalama",
icon: Copy,
},
];
return (

View File

@@ -8,9 +8,11 @@ import {
Loader2,
Clock,
AlertCircle,
Send,
} from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
// ─── HELPERS ──────────────────────────────────────────────────────────────────
@@ -46,6 +48,8 @@ function SearchPage() {
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
// Live preview state
const [preview, setPreview] = useState<{
@@ -127,6 +131,7 @@ function SearchPage() {
const cleanVin = vin.toUpperCase().trim();
startAction("vin-decode", { vin: cleanVin });
capture("vin_decoded", { vin: cleanVin });
if (!isValidVin(cleanVin)) {
setError(
"Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
@@ -137,11 +142,14 @@ function SearchPage() {
setLoading(true);
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
capture("vin_decode_success", { vin: cleanVin, vehicle_id: data.id });
navigate({
to: "/dashboard/vehicles/$id",
params: { id: data.id },
});
} catch (err) {
const message = err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
capture("vin_decode_error", { vin: cleanVin, error: message });
if (err instanceof ApiError) {
setError(err.message);
} else {
@@ -152,11 +160,27 @@ function SearchPage() {
}
}
async function handleReportVin() {
setReportSending(true);
try {
await api.post("/vehicles/report-vin", { vin: vin.toUpperCase().trim() });
setReportSent(true);
toast.success("Bildirim gönderildi", {
description: "Şase numarası sistem yöneticisine iletildi.",
});
} catch {
toast.error("Bildirim gönderilemedi");
} finally {
setReportSending(false);
}
}
function handleVinChange(raw: string) {
const upper = raw.toUpperCase();
const { cleaned, corrections } = sanitizeVin(upper);
setVin(cleaned);
setError(null);
setReportSent(false);
if (corrections.length > 0) {
const unique = [...new Set(corrections)];
toast.info(`Otomatik düzeltildi: ${unique.join(", ")}`, {
@@ -279,6 +303,29 @@ function SearchPage() {
</p>
</div>
)}
{/* Report unrecognized VIN to admin */}
{error?.includes("tanınamadı") && !reportSent && (
<Button
type="button"
variant="outline"
onClick={handleReportVin}
disabled={reportSending}
className="h-10 w-full rounded-xl"
>
{reportSending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Send className="mr-2 size-4" />
)}
Şase no doğru, sistem yöneticisine gönder
</Button>
)}
{reportSent && (
<p className="text-center text-sm text-muted-foreground">
Bildirim gönderildi. En kısa sürede incelenecektir.
</p>
)}
</form>
</div>

View File

@@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { capture, posthog } from "@/lib/posthog";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
@@ -148,6 +149,17 @@ function SubscriptionPage() {
const subscription = subData?.subscription;
const eligibleForTrial = subData?.eligibleForTrial ?? false;
// Set subscription properties on user in PostHog
useEffect(() => {
if (subscription) {
posthog.people?.set({
subscription_status: subscription.status,
subscription_plan: subscription.plan?.key,
billing_period: subscription.billingPeriod,
});
}
}, [subscription?.status, subscription?.plan?.key]);
const cancelMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/cancel"),
onSuccess: () => {
@@ -217,6 +229,7 @@ function SubscriptionPage() {
}, [onboardingPhase]);
function handleSelectPlan(planKey: string) {
capture("plan_selected", { plan: planKey });
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
}
@@ -234,6 +247,7 @@ function SubscriptionPage() {
}
startAction("proceed-to-payment", { plan: selectedPlanKey, period: billingPeriod });
capture("checkout_started", { plan: selectedPlanKey, period: billingPeriod });
navigate({
to: "/dashboard/subscription/pay",
search: {
@@ -473,6 +487,7 @@ function SubscriptionPage() {
variant="destructive"
onClick={() => {
startAction("subscription-cancel");
capture("subscription_cancelled");
cancelMutation.mutate();
}}
disabled={cancelMutation.isPending}
@@ -486,7 +501,7 @@ function SubscriptionPage() {
</Dialog>
)}
{subscription.status === "cancelled" && (
<Button onClick={() => resumeMutation.mutate()} disabled={resumeMutation.isPending}>
<Button onClick={() => { capture("subscription_resumed"); resumeMutation.mutate(); }} disabled={resumeMutation.isPending}>
{resumeMutation.isPending
? t("subscription.resuming")
: t("subscription.resumeSubscription")}
@@ -525,6 +540,7 @@ function SubscriptionPage() {
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => {
startAction("trial-start");
capture("trial_started");
trialMutation.mutate();
}}
disabled={trialMutation.isPending}

View File

@@ -73,6 +73,8 @@ function VehicleCategoryPage() {
hotspots={data?.hotspots ?? []}
parts={data?.parts ?? []}
isLoading={isLoading}
vehicleId={id}
categoryId={categoryId}
/>
</Suspense>
</div>