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:
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
51
apps/web/src/lib/posthog.ts
Normal file
51
apps/web/src/lib/posthog.ts
Normal 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 };
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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" });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
318
apps/web/src/routes/dashboard/admin/copy-logs.tsx
Normal file
318
apps/web/src/routes/dashboard/admin/copy-logs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -73,6 +73,8 @@ function VehicleCategoryPage() {
|
||||
hotspots={data?.hotspots ?? []}
|
||||
parts={data?.parts ?? []}
|
||||
isLoading={isLoading}
|
||||
vehicleId={id}
|
||||
categoryId={categoryId}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user