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

@@ -72,4 +72,28 @@ export class AdminController {
async getDailyStats() {
return this.adminService.getDailyStats();
}
@Get("copy-logs")
async getCopyLogs(
@Query("page") page?: string,
@Query("limit") limit?: string,
@Query("userId") userId?: string,
) {
return this.adminService.getCopyLogs(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50,
userId,
);
}
@Get("copy-logs/top")
async getTopCopiedCodes(
@Query("days") days?: string,
@Query("limit") limit?: string,
) {
return this.adminService.getTopCopiedCodes(
days ? parseInt(days, 10) : 30,
limit ? parseInt(limit, 10) : 20,
);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { AdminController } from "./admin.controller";
import { AdminService } from "./admin.service";
import { AnalyticsModule } from "../analytics/analytics.module";
@Module({
imports: [AnalyticsModule],
controllers: [AdminController],
providers: [AdminService],
})

View File

@@ -25,7 +25,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getDashboardStats();
expect(result).toHaveProperty("totalUsers");
@@ -67,7 +67,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getUsers(undefined, 1, 20);
expect(result.items).toBeDefined();
@@ -94,7 +94,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getUsers("ali", 1, 20);
expect(result).toBeDefined();
@@ -119,7 +119,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getUsers(undefined, 3, 10);
expect(result.page).toBe(3);
@@ -152,7 +152,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getUserDetail("u1");
expect(result.id).toBe("u1");
@@ -166,7 +166,7 @@ describe("AdminService", () => {
c.where = vi.fn().mockReturnValue(c);
c.limit = vi.fn().mockReturnValue([]);
const db = { select: vi.fn().mockReturnValue(c) };
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
await expect(service.getUserDetail("nonexistent")).rejects.toThrow(NotFoundException);
});
@@ -182,7 +182,7 @@ describe("AdminService", () => {
c.where = vi.fn().mockReturnValue(c);
c.orderBy = vi.fn().mockReturnValue(pending);
const db = { select: vi.fn().mockReturnValue(c) };
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getPendingPayments();
expect(result).toEqual(pending);
@@ -216,7 +216,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getQueryLogs(1, 50);
expect(result.items).toEqual(logs);
@@ -243,7 +243,7 @@ describe("AdminService", () => {
return c;
}),
};
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getQueryLogs(1, 50, "u1");
expect(result).toBeDefined();
@@ -265,7 +265,7 @@ describe("AdminService", () => {
c.groupBy = vi.fn().mockReturnValue(c);
c.orderBy = vi.fn().mockReturnValue(stats);
const db = { select: vi.fn().mockReturnValue(c) };
const service = new AdminService(db as any);
const service = new AdminService(db as any, {} as any);
const result = await service.getDailyStats();
expect(result).toEqual(stats);

View File

@@ -17,12 +17,16 @@ import {
referrals,
} from "../database/schema/core";
import { hashPassword } from "better-auth/crypto";
import { AnalyticsService } from "../analytics/analytics.service";
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(@Inject(DATABASE) private db: Database) {}
constructor(
@Inject(DATABASE) private db: Database,
private analyticsService: AnalyticsService,
) {}
async createUser(data: {
name: string;
@@ -400,4 +404,12 @@ export class AdminService {
return result;
}
async getCopyLogs(page = 1, limit = 50, userId?: string) {
return this.analyticsService.getCopyLogs(page, limit, userId);
}
async getTopCopiedCodes(days = 30, limit = 20) {
return this.analyticsService.getTopCopiedCodes(days, limit);
}
}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Post } from "@nestjs/common";
import { AnalyticsService } from "./analytics.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("analytics")
export class AnalyticsController {
constructor(private analyticsService: AnalyticsService) {}
@Post("oem-copy")
async trackOemCopy(
@CurrentUser() user: { id: string },
@Body()
body: {
oemCode: string;
partId?: string;
vehicleId?: string;
categoryId?: string;
},
) {
await this.analyticsService.trackOemCodeCopy({
userId: user.id,
oemCode: body.oemCode,
partId: body.partId,
vehicleId: body.vehicleId,
categoryId: body.categoryId,
});
return { tracked: true };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AnalyticsController } from "./analytics.controller";
import { AnalyticsService } from "./analytics.service";
@Module({
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@@ -0,0 +1,85 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { count, desc, eq, gte, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { oemCodeCopies, users } from "../database/schema/core";
@Injectable()
export class AnalyticsService {
private readonly logger = new Logger(AnalyticsService.name);
constructor(@Inject(DATABASE) private db: Database) {}
async trackOemCodeCopy(data: {
userId: string;
oemCode: string;
partId?: string;
vehicleId?: string;
categoryId?: string;
}) {
try {
await this.db.insert(oemCodeCopies).values({
userId: data.userId,
oemCode: data.oemCode,
partId: data.partId ?? null,
vehicleId: data.vehicleId ?? null,
categoryId: data.categoryId ?? null,
});
} catch (err) {
this.logger.error("Failed to track OEM code copy", (err as Error).stack);
}
}
async getCopyLogs(page = 1, limit = 50, userId?: string) {
const offset = (page - 1) * limit;
const conditions = userId ? eq(oemCodeCopies.userId, userId) : undefined;
const [items, totalResult] = await Promise.all([
this.db
.select({
id: oemCodeCopies.id,
userId: oemCodeCopies.userId,
userName: users.name,
userEmail: users.email,
oemCode: oemCodeCopies.oemCode,
partId: oemCodeCopies.partId,
vehicleId: oemCodeCopies.vehicleId,
categoryId: oemCodeCopies.categoryId,
createdAt: oemCodeCopies.createdAt,
})
.from(oemCodeCopies)
.innerJoin(users, eq(oemCodeCopies.userId, users.id))
.where(conditions)
.orderBy(desc(oemCodeCopies.createdAt))
.limit(limit)
.offset(offset),
this.db.select({ count: count() }).from(oemCodeCopies).where(conditions),
]);
return {
items,
total: totalResult[0].count,
page,
limit,
totalPages: Math.ceil(totalResult[0].count / limit),
};
}
async getTopCopiedCodes(days = 30, limit = 20) {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const result = await this.db
.select({
oemCode: oemCodeCopies.oemCode,
copyCount: count().as("copy_count"),
uniqueUsers: sql<number>`count(distinct ${oemCodeCopies.userId})`.as("unique_users"),
})
.from(oemCodeCopies)
.where(gte(oemCodeCopies.createdAt, since))
.groupBy(oemCodeCopies.oemCode)
.orderBy(sql`count(*) desc`)
.limit(limit);
return result;
}
}

View File

@@ -23,6 +23,7 @@ import { JobsModule } from "./jobs/jobs.module";
import { EmexModule } from "./integrations/emex/emex.module";
import { TranslationsModule } from "./translations/translations.module";
import { AdminModule } from "./admin/admin.module";
import { AnalyticsModule } from "./analytics/analytics.module";
import { HealthController } from "./health.controller";
import { AuthGuard } from "./common/guards/auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
@@ -68,6 +69,7 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
EmexModule,
TranslationsModule,
AdminModule,
AnalyticsModule,
],
controllers: [HealthController],
providers: [

View File

@@ -2,6 +2,7 @@ import { Module, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { EmailService } from "../email/email.service";
import { createAuth } from "./auth";
@Module({
@@ -10,7 +11,10 @@ import { createAuth } from "./auth";
exports: [AuthService],
})
export class AuthModule implements OnModuleInit {
constructor(private configService: ConfigService) {}
constructor(
private configService: ConfigService,
private emailService: EmailService,
) {}
onModuleInit() {
const databaseUrl = this.configService.get<string>("database.url")!;
@@ -18,6 +22,9 @@ export class AuthModule implements OnModuleInit {
const baseUrl = this.configService.get<string>("auth.url")!;
const googleClientId = this.configService.get<string>("auth.googleClientId");
const googleClientSecret = this.configService.get<string>("auth.googleClientSecret");
createAuth(databaseUrl, secret, baseUrl, { googleClientId, googleClientSecret });
createAuth(databaseUrl, secret, baseUrl, {
social: { googleClientId, googleClientSecret },
emailService: this.emailService,
});
}
}

View File

@@ -5,6 +5,7 @@ import postgres from "postgres";
import { randomUUID } from "crypto";
import * as schema from "../database/schema/core";
import type { EmailService } from "../email/email.service";
let authInstance: ReturnType<typeof betterAuth> | null = null;
@@ -13,7 +14,12 @@ interface SocialCredentials {
googleClientSecret?: string;
}
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, social?: SocialCredentials) {
interface AuthOptions {
social?: SocialCredentials;
emailService?: EmailService;
}
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, options?: AuthOptions) {
if (authInstance) return authInstance;
const client = postgres(databaseUrl, { max: 5 });
@@ -35,13 +41,30 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string,
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
sendResetPassword: async (data) => {
if (options?.emailService) {
await options.emailService.sendPasswordReset(data.user.email, data.url);
} else {
console.log(`[DEV] Password reset URL for ${data.user.email}: ${data.url}`);
}
},
},
...(social?.googleClientId && social?.googleClientSecret
emailVerification: {
sendVerificationEmail: async (data) => {
if (options?.emailService) {
await options.emailService.sendEmailVerification(data.user.email, data.url);
} else {
console.log(`[DEV] Verification URL for ${data.user.email}: ${data.url}`);
}
},
sendOnSignUp: true,
},
...(options?.social?.googleClientId && options?.social?.googleClientSecret
? {
socialProviders: {
google: {
clientId: social.googleClientId,
clientSecret: social.googleClientSecret,
clientId: options.social.googleClientId,
clientSecret: options.social.googleClientSecret,
},
},
}

View File

@@ -40,6 +40,12 @@ export default () => ({
username: process.env.EMEX_USERNAME,
password: process.env.EMEX_PASSWORD,
},
email: {
postalApiUrl: process.env.POSTAL_API_URL,
postalApiKey: process.env.POSTAL_API_KEY,
fromAddress: process.env.POSTAL_FROM_ADDRESS || "noreply@sase.tr",
fromName: process.env.POSTAL_FROM_NAME || "Sase.tr",
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -210,6 +210,27 @@ export const queryLogs = pgTable(
],
);
// ─── OEM Code Copies ──────────────────────────────────
export const oemCodeCopies = pgTable(
"oem_code_copies",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
oemCode: varchar("oem_code", { length: 100 }).notNull(),
partId: uuid("part_id"),
vehicleId: uuid("vehicle_id"),
categoryId: uuid("category_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("oem_code_copies_user_id_created_at_idx").on(table.userId, table.createdAt),
index("oem_code_copies_oem_code_idx").on(table.oemCode),
index("oem_code_copies_created_at_idx").on(table.createdAt),
],
);
// ─── Vehicles ───────────────────────────────────────
export const vehicles = pgTable(
"vehicles",

View File

@@ -6,27 +6,72 @@ export interface SendEmailOptions {
subject: string;
html: string;
text?: string;
tag?: string;
}
interface PostalApiResponse {
status: string;
data?: { message_id: string };
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly isDev: boolean;
private readonly postalApiUrl: string | undefined;
private readonly postalApiKey: string | undefined;
private readonly fromAddress: string;
private readonly fromName: string;
constructor(private configService: ConfigService) {
this.isDev = configService.get<string>("NODE_ENV") !== "production";
this.postalApiUrl = configService.get<string>("email.postalApiUrl");
this.postalApiKey = configService.get<string>("email.postalApiKey");
this.fromAddress = configService.get<string>("email.fromAddress") || "noreply@sase.tr";
this.fromName = configService.get<string>("email.fromName") || "Sase.tr";
}
private get isConfigured(): boolean {
return !!(this.postalApiUrl && this.postalApiKey);
}
async send(options: SendEmailOptions): Promise<void> {
if (this.isDev) {
if (!this.isConfigured) {
this.logger.log(`[DEV EMAIL] To: ${options.to}`);
this.logger.log(`[DEV EMAIL] Subject: ${options.subject}`);
this.logger.log(`[DEV EMAIL] Body: ${options.text || options.html.substring(0, 200)}`);
return;
}
// Production: integrate with Resend/SMTP here
this.logger.warn("Production email sending not configured yet");
const payload = {
to: [options.to],
from: `${this.fromName} <${this.fromAddress}>`,
subject: options.subject,
html_body: options.html,
...(options.text && { plain_body: options.text }),
...(options.tag && { tag: options.tag }),
};
try {
const response = await fetch(`${this.postalApiUrl}/api/v1/send/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Server-API-Key": this.postalApiKey!,
},
body: JSON.stringify(payload),
});
const result = (await response.json()) as PostalApiResponse;
if (result.status !== "success") {
this.logger.error(`Postal API error: ${JSON.stringify(result)}`);
throw new Error(`Email sending failed: ${result.status}`);
}
this.logger.log(`Email sent to ${options.to} [${options.tag || "no-tag"}]`);
} catch (error) {
this.logger.error(`Failed to send email to ${options.to}: ${error}`);
throw error;
}
}
async sendPasswordReset(to: string, resetUrl: string): Promise<void> {
@@ -40,6 +85,7 @@ export class EmailService {
<p>Bu bağlantı 1 saat geçerlidir.</p>
`,
text: `Şifrenizi sıfırlamak için bu bağlantıyı kullanın: ${resetUrl}`,
tag: "password-reset",
});
}
@@ -52,6 +98,7 @@ export class EmailService {
<p>Sase.tr'ye kaydınız başarılı. Aracınızın VIN numarasıyla yedek parça aramasına başlayabilirsiniz.</p>
`,
text: `Hoş Geldiniz ${name}! Sase.tr'ye kaydınız başarılı.`,
tag: "welcome",
});
}
@@ -64,6 +111,22 @@ export class EmailService {
<p>${amount} tutarındaki ödemeniz onaylanmıştır. Aboneliğiniz aktif edilmiştir.</p>
`,
text: `${amount} tutarındaki ödemeniz onaylanmıştır.`,
tag: "payment-confirmation",
});
}
async sendEmailVerification(to: string, verificationUrl: string): Promise<void> {
await this.send({
to,
subject: "E-posta Doğrulama - Sase.tr",
html: `
<h2>E-posta Doğrulama</h2>
<p>E-posta adresinizi doğrulamak için aşağıdaki bağlantıya tıklayın:</p>
<a href="${verificationUrl}">${verificationUrl}</a>
<p>Bu bağlantı 24 saat geçerlidir.</p>
`,
text: `E-posta adresinizi doğrulamak için bu bağlantıyı kullanın: ${verificationUrl}`,
tag: "email-verification",
});
}
}

View File

@@ -1,6 +1,7 @@
import { Controller, Post, Get, Delete, Param, Body, Query } from "@nestjs/common";
import { VehiclesService } from "./vehicles.service";
import { CategoriesService } from "../categories/categories.service";
import { EmailService } from "../email/email.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
import { Public } from "../common/decorators/public.decorator";
@@ -10,6 +11,7 @@ export class VehiclesController {
constructor(
private vehiclesService: VehiclesService,
private categoriesService: CategoriesService,
private emailService: EmailService,
) {}
@Public()
@@ -39,6 +41,28 @@ export class VehiclesController {
);
}
@Post("report-vin")
async reportVin(
@CurrentUser("id") userId: string,
@CurrentUser("email") userEmail: string,
@Body("vin", VinValidationPipe) vin: string,
) {
await this.emailService.send({
to: "admin@sase.tr",
subject: `Tanınmayan Şase Bildirimi — ${vin}`,
html: `
<h2>Tanınmayan Şase Bildirimi</h2>
<p>Bir kullanıcı aşağıdaki şase numarasının doğru olduğunu bildirdi:</p>
<p><strong>VIN:</strong> <code>${vin}</code></p>
<p><strong>Kullanıcı:</strong> ${userEmail}</p>
<p><strong>Tarih:</strong> ${new Date().toLocaleString("tr-TR", { timeZone: "Europe/Istanbul" })}</p>
`,
text: `Tanınmayan şase bildirimi: ${vin} — Kullanıcı: ${userEmail}`,
tag: "vin-report",
});
return { sent: true };
}
@Get(":vehicleId/categories/:categoryId")
async getCategoryParts(
@Param("vehicleId") vehicleId: string,

View File

@@ -39,6 +39,8 @@ function createService(dbOrOverrides: any = {}) {
};
const pl24Service = {
decodeVin: vi.fn(),
isSupported: vi.fn().mockReturnValue(false),
getBrandName: vi.fn().mockReturnValue(null),
};
const vinApiService = {
decodeVin: vi.fn(),
@@ -47,6 +49,13 @@ function createService(dbOrOverrides: any = {}) {
getScrapedVehicle: vi.fn(),
decodeVin: vi.fn(),
};
const redisService = {
get: vi.fn().mockResolvedValue(null),
getJson: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
setJson: vi.fn().mockResolvedValue(undefined),
del: vi.fn().mockResolvedValue(undefined),
};
const service = new VehiclesService(
db as any,
@@ -54,9 +63,10 @@ function createService(dbOrOverrides: any = {}) {
pl24Service as any,
vinApiService as any,
emexService as any,
redisService as any,
);
return { service, db, corgiService, pl24Service, vinApiService, emexService };
return { service, db, corgiService, pl24Service, vinApiService, emexService, redisService };
}
describe("VehiclesService", () => {
@@ -127,29 +137,27 @@ describe("VehiclesService", () => {
await expect(service.decodeVin("WBAPH5C55BA123456", "u1")).rejects.toThrow(BadRequestException);
});
it("should throw BadRequestException when brand not found in DB", async () => {
it("should save with null brandId when brand not found in DB", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
let selectCall = 0;
const savedVehicle = { id: "v-new", vin: "WBAPH5C55BA123456", brandId: null, source: "corgi" };
const selectChain: Record<string, any> = {};
selectChain.from = vi.fn().mockReturnValue(selectChain);
selectChain.where = vi.fn().mockReturnValue(selectChain);
selectChain.limit = vi.fn().mockReturnValue([]); // no cache, no brand
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]), // no cache, no brand
};
}),
select: vi.fn().mockReturnValue(selectChain),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
returning: vi.fn().mockReturnValue([savedVehicle]),
}),
};
const { service, corgiService } = createService(db);
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "UnknownBrand", modelYear: 2020 });
await expect(service.decodeVin("WBAPH5C55BA123456", "u1")).rejects.toThrow(BadRequestException);
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
});
it("should throw ForbiddenException when user has no active subscription", async () => {
@@ -159,16 +167,17 @@ describe("VehiclesService", () => {
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand found
if (selectCall === 3) return []; // no active subscription
return [];
}),
};
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand found
if (selectCall === 3) return []; // no active subscription
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
@@ -190,17 +199,17 @@ describe("VehiclesService", () => {
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand
if (selectCall === 3) return [{ id: "sub-1", status: "active" }]; // subscription
if (selectCall === 4) return [{ id: "ba-1" }]; // brand access
return [];
}),
};
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand
if (selectCall === 3) return [{ id: "sub-1", brandCount: 0 }]; // subscription (unlimited)
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
@@ -210,7 +219,8 @@ describe("VehiclesService", () => {
const { service, corgiService, pl24Service } = createService(db);
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
pl24Service.decodeVin.mockResolvedValue({ vehicleId: "pl24-v1", modelCode: "320i", yearFrom: 2020 });
pl24Service.isSupported.mockReturnValue(true);
pl24Service.decodeVin.mockResolvedValue({ model: "320i", year: 2020, engineType: "N20", transmission: "Auto", bodyType: "Sedan" });
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
@@ -224,17 +234,17 @@ describe("VehiclesService", () => {
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (selectCall === 1) return [];
if (selectCall === 2) return [{ id: "b1", name: "BMW" }];
if (selectCall === 3) return [{ id: "sub-1", status: "active" }];
if (selectCall === 4) return [{ id: "ba-1" }];
return [];
}),
};
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand
if (selectCall === 3) return [{ id: "sub-1", brandCount: 0 }]; // subscription (unlimited)
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
@@ -242,14 +252,12 @@ describe("VehiclesService", () => {
}),
};
const { service, corgiService, pl24Service, emexService } = createService(db);
const { service, corgiService, emexService } = createService(db);
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
pl24Service.decodeVin.mockResolvedValue(null);
emexService.getScrapedVehicle.mockResolvedValue({ modelCode: "320i", yearFrom: 2020, engine: "N20", rawData: {} });
emexService.decodeVin.mockResolvedValue({ brand: "BMW", model: "320i", year: 2020, engineCode: "N20", transmission: "Auto", bodyType: "Sedan", raw: {} });
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
expect(emexService.getScrapedVehicle).toHaveBeenCalledWith("WBAPH5C55BA123456");
});
it("should fallback to vinApi when PL24 and emex fail", async () => {
@@ -260,17 +268,17 @@ describe("VehiclesService", () => {
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockImplementation(() => {
if (selectCall === 1) return [];
if (selectCall === 2) return [{ id: "b1", name: "BMW" }];
if (selectCall === 3) return [{ id: "sub-1", status: "active" }];
if (selectCall === 4) return [{ id: "ba-1" }];
return [];
}),
};
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockImplementation(() => {
if (selectCall === 1) return []; // no cache
if (selectCall === 2) return [{ id: "b1", name: "BMW" }]; // brand
if (selectCall === 3) return [{ id: "sub-1", brandCount: 0 }]; // subscription (unlimited)
return [];
});
return chain;
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnThis(),
@@ -278,11 +286,8 @@ describe("VehiclesService", () => {
}),
};
const { service, corgiService, pl24Service, emexService, vinApiService } = createService(db);
const { service, corgiService, vinApiService } = createService(db);
corgiService.decodeVin.mockReturnValue({ isKnown: true, brandName: "BMW", modelYear: 2020 });
pl24Service.decodeVin.mockResolvedValue(null);
emexService.getScrapedVehicle.mockResolvedValue(null);
emexService.decodeVin.mockResolvedValue(undefined);
vinApiService.decodeVin.mockResolvedValue({ model: "320i", modelYear: "2020" });
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");

View File

@@ -13,8 +13,22 @@ import { CorgiService } from "../integrations/corgi/corgi.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { EmexService } from "../integrations/emex/emex.service";
import { RedisService } from "../redis/redis.service";
import { isValidVin } from "@sase/shared";
interface VinResolveResult {
brandName: string | null;
model: string | null;
year: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
rawData: any;
source: string;
corgiKnown: boolean;
corgiResult: any;
}
@Injectable()
export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name);
@@ -25,6 +39,7 @@ export class VehiclesService {
private pl24Service: PL24Service,
private vinApiService: VinApiService,
private emexService: EmexService,
private redis: RedisService,
) {}
async decodeVin(vin: string, userId: string) {
@@ -51,109 +66,51 @@ export class VehiclesService {
}
}
// 2. Corgi decode (offline)
const corgiResult = this.corgiService.decodeVin(vin);
const corgiKnown = corgiResult && corgiResult.isKnown;
// 2. Resolve VIN via cached decode chain (Corgi → PL24 → EMEX)
const resolved = await this.resolveVin(vin);
// 3. Brand access check (only if Corgi recognized the brand)
let brandId: string | null = null;
let brandName: string | null = null;
if (corgiKnown) {
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, corgiResult.brandName))
.limit(1);
if (brand.length > 0) {
brandId = brand[0].id;
brandName = corgiResult.brandName;
await this.checkBrandAccess(userId, brandId);
}
}
// 4. PL24 decode (real API) — always attempt, PL24 has its own WMI map
let source = "corgi";
let pl24Vehicle = null;
if (this.pl24Service.isSupported(vin)) {
try {
pl24Vehicle = await this.pl24Service.decodeVin(vin);
// If Corgi didn't know the brand, resolve it from PL24's WMI map
if (!brandId && pl24Vehicle) {
const pl24Brand = this.pl24Service.getBrandName(vin);
if (pl24Brand) {
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, pl24Brand))
.limit(1);
if (brand.length > 0) {
brandId = brand[0].id;
brandName = pl24Brand;
await this.checkBrandAccess(userId, brandId);
}
}
}
} catch (err) {
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
}
}
// 5. Fallback to EMEX if PL24 not available
let emexVehicle: import('../integrations/emex/emex.types').DecodedVehicle | null = null;
if (!pl24Vehicle) {
this.logger.log(`PL24 returned no data for ${vin}, trying EMEX fallback`);
try {
const emexResult = await this.emexService.decodeVin(vin);
if (emexResult && emexResult.brand !== 'UNKNOWN') {
emexVehicle = emexResult;
if (!brandId && emexResult.brand) {
const emexBrand = await this.db
.select()
.from(brands)
.where(eq(brands.name, emexResult.brand))
.limit(1);
if (emexBrand.length > 0) {
brandId = emexBrand[0].id;
brandName = emexResult.brand;
}
}
}
} catch (emexError) {
this.logger.warn(`EMEX fallback failed for ${vin}: ${(emexError as Error).message}`);
}
}
// If nothing recognized this VIN at all, give up
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
if (!resolved) {
await this.logQuery(userId, vin, null, "corgi", false, Date.now() - startTime, "Unknown VIN/brand");
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
}
// 6. Fallback to VIN API if PL24 and EMEX not available
let vinApiData: any = null;
if (!pl24Vehicle && !emexVehicle) {
vinApiData = await this.vinApiService.decodeVin(vin);
source = vinApiData ? "vin-api" : "corgi";
} else if (pl24Vehicle) {
source = "pl24";
} else if (emexVehicle) {
source = "emex";
// 3. Brand access check
let brandId: string | null = null;
let brandName = resolved.brandName;
if (brandName) {
const brand = await this.db
.select()
.from(brands)
.where(eq(brands.name, brandName))
.limit(1);
if (brand.length > 0) {
brandId = brand[0].id;
await this.checkBrandAccess(userId, brandId);
}
}
// 7. Save to DB
// 4. Fallback to VIN API if PL24 and EMEX didn't provide data
let source = resolved.source;
let vinApiData: any = null;
if (resolved.source === "corgi") {
vinApiData = await this.vinApiService.decodeVin(vin);
source = vinApiData ? "vin-api" : "corgi";
}
// 5. Save to DB
const vehicleData = {
userId,
vin,
brandId,
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
model: pl24Vehicle?.model || emexVehicle?.model || vinApiData?.model || null,
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || vinApiData?.engineModel || null,
transmission: pl24Vehicle?.transmission || emexVehicle?.transmission || vinApiData?.transmissionStyle || null,
bodyType: pl24Vehicle?.bodyType || emexVehicle?.bodyType || vinApiData?.bodyClass || null,
brandName,
model: resolved.model || vinApiData?.model || null,
year: resolved.year || (vinApiData ? parseInt(vinApiData.modelYear) : null),
engine: resolved.engine || vinApiData?.engineModel || null,
transmission: resolved.transmission || vinApiData?.transmissionStyle || null,
bodyType: resolved.bodyType || vinApiData?.bodyClass || null,
market: null as string | null,
rawData: pl24Vehicle || emexVehicle?.raw || vinApiData || null,
rawData: resolved.rawData || vinApiData || null,
source,
updatedAt: new Date(),
};
@@ -178,16 +135,43 @@ export class VehiclesService {
/**
* Public VIN preview — no auth, no DB save, no brand access check.
* Uses Corgi → PL24 → EMEX decode chain, returns basic vehicle info.
* Uses resolveVin() which caches results in Redis for 5 minutes.
*/
async previewVin(vin: string) {
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
const resolved = await this.resolveVin(vin);
if (!resolved) {
throw new BadRequestException("Şase numarası tanınamadı");
}
return {
brandName: resolved.brandName,
model: resolved.model,
year: resolved.year,
engine: resolved.engine,
source: resolved.source,
};
}
/**
* Shared VIN decode chain with 5-minute Redis cache.
* Corgi (offline) → PL24 → EMEX fallback.
*/
private async resolveVin(vin: string): Promise<VinResolveResult | null> {
const cacheKey = `vin:resolve:${vin}`;
const cached = await this.redis.getJson<VinResolveResult>(cacheKey);
if (cached) {
this.logger.debug(`VIN resolve cache hit for ${vin}`);
return cached;
}
// 1. Corgi decode (offline)
const corgiResult = this.corgiService.decodeVin(vin);
let brandName = corgiResult?.isKnown ? corgiResult.brandName : null;
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
let brandName = corgiKnown ? corgiResult.brandName : null;
// 2. PL24 decode
let pl24Vehicle: any = null;
@@ -198,35 +182,50 @@ export class VehiclesService {
brandName = this.pl24Service.getBrandName(vin) || null;
}
} catch (err) {
this.logger.warn(`PL24 preview failed for ${vin}: ${(err as Error).message}`);
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
}
}
// 3. EMEX fallback
let emexVehicle: import('../integrations/emex/emex.types').DecodedVehicle | null = null;
let emexVehicle: import("../integrations/emex/emex.types").DecodedVehicle | null = null;
if (!pl24Vehicle) {
try {
const emexResult = await this.emexService.decodeVin(vin);
if (emexResult && emexResult.brand !== 'UNKNOWN') {
if (emexResult && emexResult.brand !== "UNKNOWN") {
emexVehicle = emexResult;
if (!brandName) brandName = emexResult.brand || null;
}
} catch (err) {
this.logger.warn(`EMEX preview failed for ${vin}: ${(err as Error).message}`);
this.logger.warn(`EMEX fallback failed for ${vin}: ${(err as Error).message}`);
}
}
if (!pl24Vehicle && !emexVehicle && !corgiResult?.isKnown) {
throw new BadRequestException("Şase numarası tanınamadı");
// Nothing recognized this VIN
if (!pl24Vehicle && !emexVehicle && !corgiKnown) {
return null;
}
return {
const source = pl24Vehicle ? "pl24" : emexVehicle ? "emex" : "corgi";
const result: VinResolveResult = {
brandName: brandName || corgiResult?.brandName || emexVehicle?.brand || null,
model: pl24Vehicle?.model || emexVehicle?.model || null,
year: pl24Vehicle?.year || emexVehicle?.year || corgiResult?.modelYear || null,
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engineCode || emexVehicle?.engineType || null,
source: pl24Vehicle ? "pl24" : emexVehicle ? "emex" : "corgi",
engine:
pl24Vehicle?.engineType ||
pl24Vehicle?.engineCode ||
emexVehicle?.engineCode ||
emexVehicle?.engineType ||
null,
transmission: pl24Vehicle?.transmission || emexVehicle?.transmission || null,
bodyType: pl24Vehicle?.bodyType || emexVehicle?.bodyType || null,
rawData: pl24Vehicle || emexVehicle?.raw || null,
source,
corgiKnown,
corgiResult: corgiResult || null,
};
await this.redis.setJson(cacheKey, result, 300);
return result;
}
async getHistory(userId: string, page = 1, limit = 20) {

View File

@@ -26,6 +26,7 @@
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.0",
"lucide-react": "^0.468.0",
"posthog-js": "^1.347.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"remotion": "^4.0.422",

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>

File diff suppressed because one or more lines are too long