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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
29
apps/api/src/analytics/analytics.controller.ts
Normal file
29
apps/api/src/analytics/analytics.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/analytics/analytics.module.ts
Normal file
10
apps/api/src/analytics/analytics.module.ts
Normal 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 {}
|
||||
85
apps/api/src/analytics/analytics.service.ts
Normal file
85
apps/api/src/analytics/analytics.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user