feat: add OpenTelemetry observability, Faro frontend monitoring, remove legacy Next.js app

- Add OpenTelemetry SDK with tracing, metrics, and OTLP export for API and worker
- Integrate Grafana Faro for frontend real-user monitoring
- Instrument health checks, database, Bull queues, and HTTP exception filter
- Add Grafana dashboard JSON for service overview
- Remove deprecated apps/web-nj (Next.js) — fully replaced by Vite+React frontend
- Update nginx config with OTEL collector proxy
- Minor UI fixes in schema viewer, category components, and subscription flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-16 17:20:49 +00:00
parent 87d8eea298
commit dcbeb83ccc
113 changed files with 2745 additions and 7351 deletions

View File

@@ -20,6 +20,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@kubiks/otel-drizzle": "^2.1.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.0",
@@ -27,10 +28,26 @@
"@nestjs/schedule": "^4.1.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/throttler": "^6.3.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
"@opentelemetry/instrumentation": "^0.212.0",
"@opentelemetry/instrumentation-express": "^0.59.0",
"@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
"@opentelemetry/resources": "^2.5.1",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.5.1",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-trace-base": "^2.5.1",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@sase/config": "workspace:*",
"@sase/shared": "workspace:*",
"better-auth": "^1.2.0",
"bullmq": "^5.30.0",
"bullmq-otel": "^1.2.0",
"dotenv": "^16.4.0",
"drizzle-orm": "^0.41.0",
"helmet": "^8.1.0",

View File

@@ -1,5 +1,6 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
import { trace, SpanStatusCode } from "@opentelemetry/api";
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
@@ -29,6 +30,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
this.logger.error(`Unhandled error: ${exception.message}`, exception.stack);
}
// Record error on active OTel span
const span = trace.getActiveSpan();
if (span) {
span.setAttribute("error.code", code);
span.setAttribute("http.status_code", status);
if (exception instanceof Error) {
span.recordException(exception);
}
if (status >= 500) {
span.setStatus({ code: SpanStatusCode.ERROR, message });
}
}
response.status(status).json({
success: false,
error: {

View File

@@ -1,5 +1,6 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from "@nestjs/common";
import { Observable, tap } from "rxjs";
import { trace } from "@opentelemetry/api";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
@@ -14,7 +15,18 @@ export class LoggingInterceptor implements NestInterceptor {
tap(() => {
const response = context.switchToHttp().getResponse();
const elapsed = Date.now() - now;
this.logger.log(`${method} ${url} ${response.statusCode} ${elapsed}ms`);
const span = trace.getActiveSpan();
if (span) {
span.setAttribute("http.response_time_ms", elapsed);
if (request.user?.id) {
span.setAttribute("user.id", request.user.id);
}
}
const traceId = span?.spanContext().traceId;
const traceTag = traceId ? ` [trace:${traceId}]` : "";
this.logger.log(`${method} ${url} ${response.statusCode} ${elapsed}ms${traceTag}`);
}),
);
}

View File

@@ -40,4 +40,10 @@ export default () => ({
username: process.env.EMEX_USERNAME,
password: process.env.EMEX_PASSWORD,
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
serviceName: process.env.OTEL_SERVICE_NAME || "sase-api",
sampleRate: parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0"),
},
});

View File

@@ -6,6 +6,7 @@ import * as core from "./schema/core";
import * as pl24 from "./schema/pl24";
import * as emex from "./schema/emex";
import * as relations from "./schema/relations";
import { isOtelEnabled } from "../telemetry";
export const DATABASE = "DATABASE";
@@ -23,10 +24,20 @@ export const DatabaseProvider: Provider = {
connect_timeout: 10,
});
const db = drizzle(client, {
let db: Database = drizzle(client, {
schema: { ...core, ...pl24, ...emex, ...relations },
});
if (isOtelEnabled) {
try {
const { instrumentDrizzle } = require("@kubiks/otel-drizzle");
db = instrumentDrizzle(db);
console.log("[otel] Drizzle ORM instrumented");
} catch (err) {
console.warn("[otel] Drizzle instrumentation unavailable:", (err as Error).message);
}
}
console.log("Database connected");
return db;
},

View File

@@ -1,11 +1,54 @@
import { Controller, Get } from "@nestjs/common";
import { Controller, Get, Inject } from "@nestjs/common";
import { Public } from "./common/decorators/public.decorator";
import { DATABASE, Database } from "./database/database.provider";
import { RedisService } from "./redis/redis.service";
import { isOtelEnabled } from "./telemetry";
import { sql } from "drizzle-orm";
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
]);
}
@Controller("health")
export class HealthController {
constructor(
@Inject(DATABASE) private readonly db: Database,
private readonly redis: RedisService,
) {}
@Get()
@Public()
check() {
return { status: "ok", timestamp: new Date().toISOString() };
async check() {
const checks: Record<string, string> = {};
// Database check (3s timeout)
try {
await withTimeout(this.db.execute(sql`SELECT 1`), 3000);
checks.database = "ok";
} catch {
checks.database = "error";
}
// Redis check (3s timeout)
try {
await withTimeout(this.redis.getClient().ping(), 3000);
checks.redis = "ok";
} catch {
checks.redis = "error";
}
// Telemetry status
checks.telemetry = isOtelEnabled ? "enabled" : "disabled";
const status = checks.database === "ok" && checks.redis === "ok" ? "ok" : "degraded";
return {
status,
timestamp: new Date().toISOString(),
checks,
};
}
}

View File

@@ -1,4 +1,5 @@
import { ConnectionOptions } from "bullmq";
import { isOtelEnabled } from "../telemetry";
export function getBullConnection(): ConnectionOptions {
return {
@@ -8,6 +9,17 @@ export function getBullConnection(): ConnectionOptions {
};
}
export function getBullTelemetry() {
if (!isOtelEnabled) return undefined;
try {
const { BullMQOtel } = require("bullmq-otel");
return new BullMQOtel("sase");
} catch (err) {
console.warn("[otel] BullMQ telemetry unavailable:", (err as Error).message);
return undefined;
}
}
export const QUEUE_NAMES = {
EMEX_SCRAPE: "emex-scrape",
SUBSCRIPTION_EXPIRY: "subscription-expiry",

View File

@@ -1,14 +1,16 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
export const EMEX_SCRAPE_QUEUE = "EMEX_SCRAPE_QUEUE";
export const EmexScrapeQueueProvider: Provider = {
provide: EMEX_SCRAPE_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.EMEX_SCRAPE, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: {

View File

@@ -1,14 +1,16 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
export const QUERY_CLEANUP_QUEUE = "QUERY_CLEANUP_QUEUE";
export const QueryCleanupQueueProvider: Provider = {
provide: QUERY_CLEANUP_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.QUERY_CLEANUP, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 2,
backoff: {

View File

@@ -1,14 +1,16 @@
import { Provider } from "@nestjs/common";
import { Queue } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "../bull.config";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "../bull.config";
export const SUBSCRIPTION_EXPIRY_QUEUE = "SUBSCRIPTION_EXPIRY_QUEUE";
export const SubscriptionExpiryQueueProvider: Provider = {
provide: SUBSCRIPTION_EXPIRY_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.SUBSCRIPTION_EXPIRY, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: {

View File

@@ -1,3 +1,5 @@
import "./telemetry/tracing"; // MUST be first — instruments modules before they load
import { NestFactory } from "@nestjs/core";
import { ConfigService } from "@nestjs/config";
import helmet from "helmet";
@@ -21,7 +23,7 @@ async function bootstrap() {
origin: corsOrigins,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
allowedHeaders: ["Content-Type", "Authorization", "Cookie", "traceparent", "tracestate"],
exposedHeaders: ["set-cookie"],
maxAge: 86400,
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("Telemetry module", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});
afterEach(() => {
process.env = originalEnv;
});
describe("isOtelEnabled", () => {
it("should be false when OTEL_ENABLED is not set", async () => {
delete process.env.OTEL_ENABLED;
const { isOtelEnabled } = await import("../index");
expect(isOtelEnabled).toBe(false);
});
it("should be false when OTEL_ENABLED is 'false'", async () => {
process.env.OTEL_ENABLED = "false";
const { isOtelEnabled } = await import("../index");
expect(isOtelEnabled).toBe(false);
});
it("should be true when OTEL_ENABLED is 'true'", async () => {
process.env.OTEL_ENABLED = "true";
const { isOtelEnabled } = await import("../index");
expect(isOtelEnabled).toBe(true);
});
});
describe("getTracer", () => {
it("should return a tracer without errors when OTel is disabled", async () => {
process.env.OTEL_ENABLED = "false";
const { getTracer } = await import("../index");
const tracer = getTracer("test");
expect(tracer).toBeDefined();
});
});
describe("getMeter", () => {
it("should return a meter without errors when OTel is disabled", async () => {
process.env.OTEL_ENABLED = "false";
const { getMeter } = await import("../index");
const meter = getMeter("test");
expect(meter).toBeDefined();
});
});
describe("sdk-factory", () => {
it("should not throw when OTel is enabled but endpoint is missing", async () => {
process.env.OTEL_ENABLED = "true";
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const { createNodeSDK } = await import("../sdk-factory");
expect(() =>
createNodeSDK({
serviceName: "test",
instrumentations: [],
}),
).not.toThrow();
});
it("should return null when endpoint is not configured", async () => {
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
const { createNodeSDK } = await import("../sdk-factory");
const sdk = createNodeSDK({
serviceName: "test",
instrumentations: [],
});
expect(sdk).toBeNull();
});
});
describe("metrics", () => {
it("should create metrics without errors", async () => {
process.env.OTEL_ENABLED = "false";
const { getAppMetrics } = await import("../metrics");
const metrics = getAppMetrics();
expect(metrics).toBeDefined();
expect(metrics.vinDecodeCounter).toBeDefined();
expect(metrics.vinDecodeDuration).toBeDefined();
expect(metrics.activeSubscriptions).toBeDefined();
expect(metrics.paymentCounter).toBeDefined();
expect(metrics.jobCounter).toBeDefined();
});
it("should return same instance on multiple calls", async () => {
const { getAppMetrics } = await import("../metrics");
const a = getAppMetrics();
const b = getAppMetrics();
expect(a).toBe(b);
});
});
});

View File

@@ -0,0 +1,28 @@
import { trace, metrics, SpanStatusCode, type Span } from "@opentelemetry/api";
export const isOtelEnabled = process.env.OTEL_ENABLED === "true";
export function getTracer(name = "sase-api") {
return trace.getTracer(name);
}
export function getMeter(name = "sase-api") {
return metrics.getMeter(name);
}
export function recordSpanError(error: Error, span?: Span) {
const activeSpan = span || trace.getActiveSpan();
if (activeSpan) {
activeSpan.recordException(error);
activeSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
}
}
export function setSpanAttributes(attributes: Record<string, string | number | boolean>) {
const activeSpan = trace.getActiveSpan();
if (activeSpan) {
activeSpan.setAttributes(attributes);
}
}
export { trace, metrics, SpanStatusCode };

View File

@@ -0,0 +1,33 @@
import { getMeter } from "./index";
let _metrics: ReturnType<typeof createMetrics> | null = null;
function createMetrics() {
const meter = getMeter("sase-api");
return {
vinDecodeCounter: meter.createCounter("sase.vin_decode.total", {
description: "Total VIN decode attempts",
}),
vinDecodeDuration: meter.createHistogram("sase.vin_decode.duration_ms", {
description: "VIN decode duration in milliseconds",
unit: "ms",
}),
activeSubscriptions: meter.createObservableGauge("sase.subscriptions.active", {
description: "Number of active subscriptions",
}),
paymentCounter: meter.createCounter("sase.payments.total", {
description: "Total payment attempts",
}),
jobCounter: meter.createCounter("sase.jobs.total", {
description: "Total background jobs processed",
}),
};
}
export function getAppMetrics() {
if (!_metrics) {
_metrics = createMetrics();
}
return _metrics;
}

View File

@@ -0,0 +1,81 @@
import { NodeSDK } from "@opentelemetry/sdk-node";
import { resourceFromAttributes } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
} from "@opentelemetry/semantic-conventions";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchSpanProcessor, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import type { Instrumentation } from "@opentelemetry/instrumentation";
export interface SDKConfig {
serviceName: string;
instrumentations: Instrumentation[];
}
function parseHeaders(headerString: string): Record<string, string> {
if (!headerString) return {};
const headers: Record<string, string> = {};
for (const pair of headerString.split(",")) {
const [key, ...valueParts] = pair.split("=");
if (key && valueParts.length > 0) {
headers[key.trim()] = valueParts.join("=").trim();
}
}
return headers;
}
export function createNodeSDK(config: SDKConfig): NodeSDK | null {
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) {
console.log(`[otel] No OTLP endpoint configured — telemetry will not be exported`);
return null;
}
const headers = parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || "");
const sampleRate = parseFloat(process.env.OTEL_TRACE_SAMPLE_RATE || "1.0");
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: config.serviceName,
"service.version": process.env.npm_package_version || "0.1.0",
[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV || "development",
});
const traceExporter = new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
});
const metricExporter = new OTLPMetricExporter({
url: `${endpoint}/v1/metrics`,
headers,
});
const logExporter = new OTLPLogExporter({
url: `${endpoint}/v1/logs`,
headers,
});
const sdk = new NodeSDK({
resource,
spanProcessors: [new BatchSpanProcessor(traceExporter)],
metricReader: new PeriodicExportingMetricReader({
exporter: metricExporter,
exportIntervalMillis: 30_000,
}),
logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
instrumentations: config.instrumentations,
sampler: sampleRate < 1.0 ? new TraceIdRatioBasedSampler(sampleRate) : undefined,
});
sdk.start();
console.log(
`[otel] Telemetry enabled — service: ${config.serviceName}, endpoint: ${endpoint}, sample: ${sampleRate}`,
);
return sdk;
}

View File

@@ -0,0 +1,42 @@
import "dotenv/config"; // Load env vars before checking OTEL_ENABLED
import { isOtelEnabled } from "./index";
if (isOtelEnabled) {
// Dynamic imports to avoid loading OTel modules when disabled
const { createNodeSDK } = require("./sdk-factory") as typeof import("./sdk-factory");
const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");
const { ExpressInstrumentation } = require("@opentelemetry/instrumentation-express");
const { NestInstrumentation } = require("@opentelemetry/instrumentation-nestjs-core");
const { IORedisInstrumentation } = require("@opentelemetry/instrumentation-ioredis");
const sdk = createNodeSDK({
serviceName: process.env.OTEL_SERVICE_NAME || "sase-api",
instrumentations: [
new HttpInstrumentation({
ignoreIncomingRequestHook: (req: { url?: string }) => {
const url = req.url || "";
return url === "/api/health" || url.startsWith("/assets/");
},
}),
new ExpressInstrumentation(),
new NestInstrumentation(),
new IORedisInstrumentation(),
],
});
if (sdk) {
const shutdown = async () => {
try {
await sdk.shutdown();
console.log("[otel] Telemetry shut down cleanly");
} catch (err) {
console.error("[otel] Error during shutdown:", err);
}
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
} else {
console.log("[otel] Telemetry disabled (OTEL_ENABLED !== 'true')");
}

View File

@@ -0,0 +1,20 @@
import "dotenv/config"; // Load env vars before checking OTEL_ENABLED
import { isOtelEnabled } from "./index";
import type { NodeSDK } from "@opentelemetry/sdk-node";
let sdk: NodeSDK | null = null;
if (isOtelEnabled) {
const { createNodeSDK } = require("./sdk-factory") as typeof import("./sdk-factory");
const { HttpInstrumentation } = require("@opentelemetry/instrumentation-http");
const { IORedisInstrumentation } = require("@opentelemetry/instrumentation-ioredis");
sdk = createNodeSDK({
serviceName: process.env.OTEL_SERVICE_NAME || "sase-worker",
instrumentations: [new HttpInstrumentation(), new IORedisInstrumentation()],
});
} else {
console.log("[worker][otel] Telemetry disabled (OTEL_ENABLED !== 'true')");
}
export { sdk };

View File

@@ -1,11 +1,15 @@
import "./telemetry/worker-tracing"; // MUST be first — instruments modules before they load
import "dotenv/config";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { Worker } from "bullmq";
import { getBullConnection, QUEUE_NAMES } from "./jobs/bull.config";
import { getBullConnection, getBullTelemetry, QUEUE_NAMES } from "./jobs/bull.config";
import { processEmexScrape } from "./jobs/processors/emex-scrape.processor";
import { processSubscriptionExpiry } from "./jobs/processors/subscription-expiry.processor";
import { processQueryCleanup } from "./jobs/processors/query-cleanup.processor";
import { isOtelEnabled } from "./telemetry";
import { sdk } from "./telemetry/worker-tracing";
// ─── Database connection (standalone, no NestJS) ──────
const databaseUrl = process.env.DATABASE_URL;
@@ -20,10 +24,22 @@ const sql = postgres(databaseUrl, {
connect_timeout: 10,
});
const db = drizzle(sql);
let db = drizzle(sql);
// Conditionally instrument Drizzle with OTel
if (isOtelEnabled) {
try {
const { instrumentDrizzle } = require("@kubiks/otel-drizzle");
db = instrumentDrizzle(db);
console.log("[worker][otel] Drizzle ORM instrumented");
} catch (err) {
console.warn("[worker][otel] Drizzle instrumentation unavailable:", (err as Error).message);
}
}
// ─── BullMQ connection config ─────────────────────────
const connection = getBullConnection();
const telemetry = getBullTelemetry();
// ─── Workers ──────────────────────────────────────────
const workers: Worker[] = [];
@@ -37,6 +53,7 @@ const emexScrapeWorker = new Worker(
{
connection,
concurrency: 3,
...(telemetry ? { telemetry } : {}),
limiter: {
max: 10,
duration: 60000,
@@ -65,6 +82,7 @@ const subscriptionExpiryWorker = new Worker(
{
connection,
concurrency: 1,
...(telemetry ? { telemetry } : {}),
},
);
@@ -87,6 +105,7 @@ const queryCleanupWorker = new Worker(
{
connection,
concurrency: 1,
...(telemetry ? { telemetry } : {}),
},
);
@@ -105,16 +124,22 @@ console.log("[worker] BullMQ workers started");
console.log(`[worker] Listening on queues: ${Object.values(QUEUE_NAMES).join(", ")}`);
console.log(`[worker] Redis: ${process.env.REDIS_HOST || "localhost"}:${process.env.REDIS_PORT || 6379}`);
// ─── Graceful shutdown ────────────────────────────────
// ─── Graceful shutdown (OTel → Workers → DB) ─────────
async function shutdown(signal: string) {
console.log(`[worker] Received ${signal}, shutting down gracefully...`);
try {
// Close all workers (wait for current jobs to finish)
// 1. Shut down OTel SDK (flush remaining spans)
if (sdk) {
await sdk.shutdown();
console.log("[worker][otel] Telemetry shut down");
}
// 2. Close all workers (wait for current jobs to finish)
await Promise.all(workers.map((w) => w.close()));
console.log("[worker] All workers closed");
// Close database connection
// 3. Close database connection
await sql.end();
console.log("[worker] Database connection closed");

View File

@@ -1,2 +0,0 @@
NEXT_PUBLIC_API_URL=http://localhost:4000/api
NODE_ENV=development

View File

@@ -1,43 +0,0 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
test("localhost:3000 prod - login debug", async ({ page }) => {
const allRequests: string[] = [];
page.on("request", (req) => {
allRequests.push(`${req.method()} ${req.url()}`);
});
const consoleMessages: string[] = [];
page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
});
await page.goto(`${BASE_URL}/login`);
await page.fill('input[type="email"]', "admin@sase.tr");
await page.fill('input[type="password"]', "Sase2026");
await page.click('button[type="submit"]');
await page.waitForTimeout(5000);
console.log("\n=== ALL REQUESTS ===");
for (const r of allRequests) {
if (r.includes("/api/") || r.includes("/auth/")) console.log(r);
}
const cookies = await page.context().cookies();
console.log("\n=== COOKIES ===");
for (const c of cookies) {
console.log(` ${c.name} = ${c.value.substring(0, 20)}... (domain: ${c.domain}, secure: ${c.secure})`);
}
console.log("\n=== CONSOLE ===");
for (const m of consoleMessages) {
if (m.includes("error") || m.includes("Error") || m.includes("auth") || m.includes("fetch")) console.log(m);
}
const currentUrl = page.url();
console.log("\nCurrent URL:", currentUrl);
expect(currentUrl).toContain("/dashboard");
});

View File

@@ -1,95 +0,0 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "https://v2.sase.tr";
test("v2.sase.tr - should login with admin@sase.tr / Sase2026", async ({ page }) => {
// Capture ALL network requests/responses for debugging
const allRequests: string[] = [];
page.on("request", (req) => {
if (req.url().includes("/auth/") || req.url().includes("/api/")) {
allRequests.push(`${req.method()} ${req.url()}`);
}
});
const allResponses: { url: string; status: number; headers: Record<string, string>; body: string }[] = [];
page.on("response", async (res) => {
if (res.url().includes("/auth/")) {
let body = "";
try {
body = await res.text();
} catch {
body = "(could not read body)";
}
const headers = res.headers();
allResponses.push({
url: res.url(),
status: res.status(),
headers: {
"set-cookie": headers["set-cookie"] || "(none)",
"content-type": headers["content-type"] || "(none)",
"access-control-allow-origin": headers["access-control-allow-origin"] || "(none)",
"access-control-allow-credentials": headers["access-control-allow-credentials"] || "(none)",
},
body: body.substring(0, 500),
});
}
});
// Capture console errors
const consoleMessages: string[] = [];
page.on("console", (msg) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
});
// Navigate to login page
await page.goto(`${BASE_URL}/login`);
console.log("Page loaded:", page.url());
// Fill email
await page.fill('input[type="email"]', "admin@sase.tr");
// Fill password
await page.fill('input[type="password"]', "Sase2026");
// Click submit
await page.click('button[type="submit"]');
// Wait for navigation or error
await page.waitForTimeout(8000);
// Log debug info
console.log("\n=== ALL API REQUESTS ===");
for (const r of allRequests) console.log(r);
console.log("\n=== AUTH RESPONSES (with headers) ===");
for (const r of allResponses) {
console.log(`${r.status} ${r.url}`);
console.log(` Headers:`, JSON.stringify(r.headers, null, 2));
console.log(` Body: ${r.body}\n`);
}
// Check cookies
const cookies = await page.context().cookies();
console.log("\n=== BROWSER COOKIES ===");
for (const c of cookies) {
console.log(` ${c.name} = ${c.value.substring(0, 30)}... (domain: ${c.domain}, secure: ${c.secure}, sameSite: ${c.sameSite}, path: ${c.path})`);
}
// Current URL
const currentUrl = page.url();
console.log("\nCurrent URL:", currentUrl);
// Check for error toast
const toast = page.locator('[data-sonner-toast]');
const toastCount = await toast.count();
if (toastCount > 0) {
const toastText = await toast.first().textContent();
console.log("Toast message:", toastText);
}
// Console messages
console.log("\n=== CONSOLE MESSAGES ===");
for (const m of consoleMessages) console.log(m);
// Verify login succeeded
expect(currentUrl).toContain("/dashboard");
});

View File

@@ -1,75 +0,0 @@
import { test, expect } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
test.describe("Login Flow", () => {
test("should load login page", async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
await expect(page.getByText("Giriş Yap", { exact: true }).first()).toBeVisible();
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
});
test("should login with admin@sase.tr / Sase2026", async ({ page }) => {
await page.goto(`${BASE_URL}/login`);
// Fill email
await page.fill('input[type="email"]', "admin@sase.tr");
// Fill password
await page.fill('input[type="password"]', "Sase2026");
// Listen for network requests to debug
const requests: string[] = [];
page.on("request", (req) => {
if (req.url().includes("/auth/")) {
requests.push(`${req.method()} ${req.url()}`);
}
});
const responses: { url: string; status: number; body: string }[] = [];
page.on("response", async (res) => {
if (res.url().includes("/auth/")) {
let body = "";
try {
body = await res.text();
} catch {
body = "(could not read body)";
}
responses.push({ url: res.url(), status: res.status(), body: body.substring(0, 500) });
}
});
// Click submit
await page.click('button[type="submit"]');
// Wait for navigation or error
await page.waitForTimeout(5000);
// Log debug info
console.log("\n=== AUTH REQUESTS ===");
for (const r of requests) console.log(r);
console.log("\n=== AUTH RESPONSES ===");
for (const r of responses) console.log(`${r.status} ${r.url}\n Body: ${r.body}\n`);
// Check current URL
const currentUrl = page.url();
console.log("Current URL:", currentUrl);
// Check for error toast
const toast = page.locator('[data-sonner-toast]');
const toastCount = await toast.count();
if (toastCount > 0) {
const toastText = await toast.first().textContent();
console.log("Toast message:", toastText);
}
// Check console errors
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
// Verify login succeeded (should redirect to /dashboard/search)
expect(currentUrl).toContain("/dashboard");
});
});

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,24 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
transpilePackages: ["@sase/ui", "@sase/shared"],
images: {
remotePatterns: [
{
protocol: "https",
hostname: "storage.sase.tr",
},
],
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api"}/:path*`,
},
];
},
};
export default nextConfig;

View File

@@ -1,44 +0,0 @@
{
"name": "web-nj",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf .next"
},
"dependencies": {
"@sase/shared": "workspace:*",
"@sase/ui": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"better-auth": "^1.2.0",
"clsx": "^2.1.0",
"lucide-react": "^0.468.0",
"next": "^16.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^28.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vitest": "^3.2.4"
}
}

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -1,90 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "sonner";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await fetch("/api/auth/forget-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, redirectTo: "/reset-password" }),
});
setSent(true);
toast.success("Şifre sıfırlama bağlantısı gönderildi.");
} catch {
toast.error("Bir hata oluştu.");
} finally {
setLoading(false);
}
}
if (sent) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle>E-posta Gönderildi</CardTitle>
<CardDescription>
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen e-postanızı kontrol edin.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/login">
<Button variant="outline" className="w-full">
Giriş Sayfasına Dön
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Şifremi Unuttum</CardTitle>
<CardDescription>E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Gönderiliyor..." : "Bağlantı Gönder"}
</Button>
</form>
<div className="mt-4 text-center text-sm">
<Link href="/login" className="text-muted-foreground hover:underline">
Giriş Sayfasına Dön
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,102 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn } from "@/lib/auth-client";
import { toast } from "sonner";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await signIn.email({ email, password });
router.push("/dashboard/search");
} catch {
toast.error("Giriş başarısız. E-posta veya şifre hatalı.");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Giriş Yap</CardTitle>
<CardDescription>Sase.tr hesabınıza giriş yapın</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
</Button>
</form>
<div className="mt-4 space-y-3">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "google" })}
>
Google ile Giriş Yap
</Button>
</div>
<div className="mt-4 text-center text-sm">
<Link href="/forgot-password" className="text-muted-foreground hover:underline">
Şifremi Unuttum
</Link>
</div>
<div className="mt-2 text-center text-sm">
Hesabınız yok mu?{" "}
<Link href="/register" className="font-medium hover:underline">
Kayıt Ol
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,93 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Label } from "@sase/ui";
import { signUp } from "@/lib/auth-client";
import { toast } from "sonner";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await signUp.email({ name, email, password });
toast.success("Hesap oluşturuldu!");
router.push("/dashboard/search");
} catch {
toast.error("Kayıt başarısız. Bu e-posta zaten kullanılıyor olabilir.");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Kayıt Ol</CardTitle>
<CardDescription>Yeni bir Sase.tr hesabı oluşturun</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Ad Soyad</Label>
<Input
id="name"
type="text"
placeholder="Ad Soyad"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-posta</Label>
<Input
id="email"
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>
<Input
id="password"
type="password"
placeholder="En az 8 karakter, 1 büyük harf, 1 rakam"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : "Kayıt Ol"}
</Button>
</form>
<div className="mt-4 text-center text-sm">
Zaten hesabınız var mı?{" "}
<Link href="/login" className="font-medium hover:underline">
Giriş Yap
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,75 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Label } from "@sase/ui";
import { toast } from "sonner";
import { Suspense } from "react";
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") || "";
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newPassword: password, token }),
});
toast.success("Şifreniz başarıyla güncellendi.");
router.push("/login");
} catch {
toast.error("Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Yeni Şifre</Label>
<Input
id="password"
type="password"
placeholder="En az 8 karakter"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Güncelleniyor..." : "Şifreyi Güncelle"}
</Button>
</form>
);
}
export default function ResetPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Şifre Sıfırla</CardTitle>
<CardDescription>Yeni şifrenizi belirleyin</CardDescription>
</CardHeader>
<CardContent>
<Suspense>
<ResetPasswordForm />
</Suspense>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,275 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
Search,
X,
CheckCircle,
XCircle,
Activity,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface QueryLogItem {
id: string;
userId: string;
userName: string;
userEmail: string;
vin: string;
brandId: string | null;
brandName: string | null;
source: string | null;
success: boolean;
errorMessage: string | null;
responseTimeMs: number | null;
createdAt: string;
}
interface QueryLogResponse {
items: QueryLogItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export default function AdminAnalyticsPage() {
const { t } = useTranslation();
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [userIdFilter, setUserIdFilter] = useState("");
const [debouncedUserId, setDebouncedUserId] = useState("");
const [page, setPage] = useState(1);
const limit = 50;
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
router.push("/dashboard/search");
}
}, [authLoading, user, router]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedUserId(userIdFilter);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [userIdFilter]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "query-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<QueryLogResponse>(
`/admin/query-logs?${params.toString()}`,
);
},
enabled: user?.role === "admin",
});
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">Sorgu Analizi</h2>
<Badge variant="outline">{data?.total ?? 0} kayit</Badge>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[250px] max-w-md">
<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={`log-skeleton-${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">
<Activity className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Sorgu kaydi bulunamadi</p>
<p className="text-sm text-muted-foreground">
{userIdFilter
? "Bu kullaniciya ait sorgu kaydi yok"
: "Henuz hicbir sorgu yapilmamis"}
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0 overflow-x-auto">
{/* Table Header */}
<div className="min-w-[900px]">
<div className="grid grid-cols-8 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
<span>Kullanici</span>
<span>VIN</span>
<span>Marka</span>
<span>Kaynak</span>
<span className="text-center">Durum</span>
<span className="text-right">Yanit Suresi</span>
<span>Tarih</span>
<span />
</div>
{/* Table Rows */}
<div className="divide-y">
{data.items.map((log) => (
<div
key={log.id}
className="grid grid-cols-8 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 py-0.5 text-xs">
{log.vin}
</code>
</div>
<div className="truncate">
<span className="text-sm">
{log.brandName || "-"}
</span>
</div>
<div>
{log.source ? (
<Badge variant="outline">{log.source}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="text-center">
{log.success ? (
<CheckCircle className="mx-auto h-4 w-4 text-green-500" />
) : (
<XCircle className="mx-auto h-4 w-4 text-red-500" />
)}
</div>
<div className="text-right">
{log.responseTimeMs !== null ? (
<span
className={
log.responseTimeMs > 5000
? "text-red-500"
: log.responseTimeMs > 2000
? "text-amber-500"
: "text-green-500"
}
>
{log.responseTimeMs}ms
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(log.createdAt)}
</div>
<div>
{log.errorMessage && (
<span
title={log.errorMessage}
className="cursor-help text-xs text-red-500 underline decoration-dotted"
>
Hata
</span>
)}
</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>
)}
</div>
);
}

View File

@@ -1,222 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Badge } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
Users,
CreditCard,
TrendingUp,
Search,
UserPlus,
Clock,
UserCog,
Receipt,
Activity,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
const DailyChart = dynamic(
() =>
import("@/components/admin/daily-chart").then((mod) => ({
default: mod.DailyChart,
})),
{
ssr: false,
loading: () => <Skeleton className="h-80 w-full rounded-lg" />,
},
);
interface DashboardStats {
totalUsers: number;
activeSubscriptions: number;
totalRevenue: number;
totalQueries: number;
newUsersThisMonth: number;
pendingPayments: number;
}
interface DailyStat {
date: string;
count: number;
successCount: number;
failureCount: number;
}
export default function AdminDashboardPage() {
const { t } = useTranslation();
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
router.push("/dashboard/search");
}
}, [authLoading, user, router]);
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ["admin", "dashboard"],
queryFn: () => api.get<DashboardStats>("/admin/dashboard"),
enabled: user?.role === "admin",
});
const { data: dailyStats, isLoading: dailyLoading } = useQuery({
queryKey: ["admin", "stats", "daily"],
queryFn: () => api.get<DailyStat[]>("/admin/stats/daily"),
enabled: user?.role === "admin",
});
if (authLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`skeleton-${i}`} className="h-32" />
))}
</div>
</div>
);
}
if (user?.role !== "admin") return null;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(amount / 100);
};
const statCards = [
{
label: "Toplam Kullanici",
value: stats?.totalUsers ?? 0,
icon: Users,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
label: "Aktif Abonelik",
value: stats?.activeSubscriptions ?? 0,
icon: CreditCard,
color: "text-green-600",
bg: "bg-green-50",
},
{
label: "Toplam Gelir",
value: formatCurrency(stats?.totalRevenue ?? 0),
icon: TrendingUp,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
label: "Sorgu (30 Gun)",
value: stats?.totalQueries ?? 0,
icon: Search,
color: "text-purple-600",
bg: "bg-purple-50",
},
{
label: "Yeni Kullanici (Ay)",
value: stats?.newUsersThisMonth ?? 0,
icon: UserPlus,
color: "text-orange-600",
bg: "bg-orange-50",
},
{
label: "Bekleyen Odeme",
value: stats?.pendingPayments ?? 0,
icon: Clock,
color: "text-red-600",
bg: "bg-red-50",
},
];
const quickLinks = [
{
href: "/dashboard/admin/users",
label: "Kullanici Yonetimi",
icon: UserCog,
},
{
href: "/dashboard/admin/payments",
label: "Odeme Onaylari",
icon: Receipt,
},
{
href: "/dashboard/admin/analytics",
label: "Sorgu Analizi",
icon: Activity,
},
];
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Admin Panel</h2>
<Badge variant="secondary">Admin</Badge>
</div>
{/* Quick Links */}
<div className="flex flex-wrap gap-2">
{quickLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className="inline-flex items-center gap-2 rounded-lg border bg-card px-4 py-2 text-sm font-medium transition-colors hover:bg-accent"
>
<Icon className="h-4 w-4" />
{link.label}
</Link>
);
})}
</div>
{/* Stat Cards */}
{statsLoading ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`stat-skeleton-${i}`} className="h-32" />
))}
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{statCards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.label}>
<CardContent className="flex items-center gap-4 p-6">
<div
className={`flex h-12 w-12 items-center justify-center rounded-lg ${card.bg}`}
>
<Icon className={`h-6 w-6 ${card.color}`} />
</div>
<div>
<p className="text-sm text-muted-foreground">
{card.label}
</p>
<p className="text-2xl font-bold">{card.value}</p>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Daily Query Chart - lazy loaded */}
<DailyChart dailyStats={dailyStats} isLoading={dailyLoading} />
</div>
);
}

View File

@@ -1,264 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle,
XCircle,
ExternalLink,
Receipt,
AlertTriangle,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface PendingPayment {
id: string;
userId: string;
userName: string;
userEmail: string;
subscriptionId: string;
amount: number;
currency: string;
method: string;
status: string;
eftReceiptUrl: string | null;
createdAt: string;
}
export default function AdminPaymentsPage() {
const { t } = useTranslation();
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const queryClient = useQueryClient();
const [confirmAction, setConfirmAction] = useState<{
id: string;
type: "approve" | "reject";
} | null>(null);
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
router.push("/dashboard/search");
}
}, [authLoading, user, router]);
const { data: payments, isLoading } = useQuery({
queryKey: ["admin", "payments", "pending"],
queryFn: () => api.get<PendingPayment[]>("/admin/payments/pending"),
enabled: user?.role === "admin",
});
const approveMutation = useMutation({
mutationFn: (paymentId: string) =>
api.patch(`/payments/eft/${paymentId}/approve`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
setConfirmAction(null);
},
});
const rejectMutation = useMutation({
mutationFn: (paymentId: string) =>
api.patch(`/payments/eft/${paymentId}/reject`, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "payments"] });
queryClient.invalidateQueries({ queryKey: ["admin", "dashboard"] });
setConfirmAction(null);
},
});
if (authLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (user?.role !== "admin") return null;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(amount / 100);
};
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-5xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">EFT Odeme Onaylari</h2>
<Badge variant="secondary">
{payments?.length ?? 0} bekleyen
</Badge>
</div>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={`payment-skeleton-${i}`} className="h-32 w-full" />
))}
</div>
) : !payments || payments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<CheckCircle className="h-12 w-12 text-green-500" />
<p className="text-lg font-medium">
Bekleyen odeme bulunmuyor
</p>
<p className="text-sm text-muted-foreground">
Tum EFT odemeleri islenmis durumda
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{payments.map((payment) => (
<Card key={payment.id}>
<CardContent className="p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* User Info */}
<div className="space-y-1">
<p className="font-medium">{payment.userName}</p>
<p className="text-sm text-muted-foreground">
{payment.userEmail}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDate(payment.createdAt)}</span>
<span>|</span>
<span>ID: {payment.id.substring(0, 8)}...</span>
</div>
</div>
{/* Amount & Receipt */}
<div className="flex flex-col items-end gap-2">
<p className="text-xl font-bold">
{formatCurrency(payment.amount)}
</p>
{payment.eftReceiptUrl ? (
<a
href={payment.eftReceiptUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
Dekont Goruntule
</a>
) : (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<Receipt className="h-3 w-3" />
Dekont yuklenmemis
</span>
)}
</div>
</div>
{/* Confirm Dialog */}
{confirmAction && confirmAction.id === payment.id ? (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 text-amber-600" />
<div className="flex-1">
<p className="font-medium">
{confirmAction.type === "approve"
? "Odemeyi onaylamak istediginize emin misiniz?"
: "Odemeyi reddetmek istediginize emin misiniz?"}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{confirmAction.type === "approve"
? "Bu islem aboneligi aktif hale getirecektir."
: "Bu islem odemeyi basarisiz olarak isaretleyecektir."}
</p>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
variant={
confirmAction.type === "approve"
? "default"
: "destructive"
}
disabled={
approveMutation.isPending ||
rejectMutation.isPending
}
onClick={() => {
if (confirmAction.type === "approve") {
approveMutation.mutate(payment.id);
} else {
rejectMutation.mutate(payment.id);
}
}}
>
{approveMutation.isPending ||
rejectMutation.isPending
? "Isleniyor..."
: "Evet, onayla"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmAction(null)}
>
Iptal
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="mt-4 flex items-center gap-2 border-t pt-4">
<Button
size="sm"
onClick={() =>
setConfirmAction({
id: payment.id,
type: "approve",
})
}
>
<CheckCircle className="mr-1 h-4 w-4" />
Onayla
</Button>
<Button
size="sm"
variant="destructive"
onClick={() =>
setConfirmAction({
id: payment.id,
type: "reject",
})
}
>
<XCircle className="mr-1 h-4 w-4" />
Reddet
</Button>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -1,309 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
Search,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Gift,
Users,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface ReferralItem {
id: string;
referredId: string;
referredName: string;
referredEmail: string;
rewardApplied: boolean;
createdAt: string;
}
interface ReferrerSummary {
referrerId: string;
referrerName: string;
referrerEmail: string;
referralCode: string | null;
totalReferrals: number;
referrals: ReferralItem[];
}
interface ReferralsResponse {
items: ReferrerSummary[];
total: number;
totalReferrals: number;
page: number;
limit: number;
totalPages: number;
}
export default function AdminReferralsPage() {
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
const [expandedReferrer, setExpandedReferrer] = useState<string | null>(null);
const limit = 20;
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
router.push("/dashboard/search");
}
}, [authLoading, user, router]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "referrals", debouncedSearch, page, limit],
queryFn: () => {
const params = new URLSearchParams();
if (debouncedSearch) params.set("search", debouncedSearch);
params.set("page", String(page));
params.set("limit", String(limit));
return api.get<ReferralsResponse>(`/admin/referrals?${params.toString()}`);
},
enabled: user?.role === "admin",
});
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",
});
};
const getTierBadge = (count: number) => {
if (count >= 5) return <Badge variant="default">Tier 2 (+30 gun)</Badge>;
if (count >= 3) return <Badge variant="secondary">Tier 1 (+7 gun)</Badge>;
return <Badge variant="outline">{count}/3</Badge>;
};
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Referans Yonetimi</h2>
<div className="flex items-center gap-3">
<Badge variant="outline">
<Users className="mr-1 h-3 w-3" />
{data?.total ?? 0} referans veren
</Badge>
<Badge variant="secondary">
<Gift className="mr-1 h-3 w-3" />
{data?.totalReferrals ?? 0} toplam referans
</Badge>
</div>
</div>
{/* Stats Cards */}
{data && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.totalReferrals}</div>
<p className="text-sm text-muted-foreground">Toplam Referans</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{data.items.filter((r) => r.totalReferrals >= 3).length}
</div>
<p className="text-sm text-muted-foreground">Tier 1+ (3+ referans)</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">
{data.items.filter((r) => r.totalReferrals >= 5).length}
</div>
<p className="text-sm text-muted-foreground">Tier 2 (5+ referans)</p>
</CardContent>
</Card>
</div>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Referans veren isim veya e-posta ile ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
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>
{/* Referrers List */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`ref-skeleton-${i}`} className="h-20 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Gift className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">
{search ? "Sonuc bulunamadi" : "Henuz referans bulunmuyor"}
</p>
<p className="text-sm text-muted-foreground">
Kullanicilar referans kodlarini paylastikca burada gorunecek
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{data.items.map((referrer) => {
const isExpanded = expandedReferrer === referrer.referrerId;
return (
<Card key={referrer.referrerId}>
<CardContent className="p-0">
{/* Referrer Row */}
<button
type="button"
className="flex w-full items-center justify-between p-6 text-left transition-colors hover:bg-muted/50"
onClick={() =>
setExpandedReferrer(isExpanded ? null : referrer.referrerId)
}
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
{referrer.totalReferrals}
</div>
<div>
<p className="font-medium">{referrer.referrerName}</p>
<p className="text-sm text-muted-foreground">
{referrer.referrerEmail}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{referrer.referralCode && (
<Badge variant="outline" className="font-mono">
{referrer.referralCode}
</Badge>
)}
{getTierBadge(referrer.totalReferrals)}
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Referrals */}
{isExpanded && (
<div className="border-t bg-muted/20 px-6 py-4">
<p className="mb-3 text-sm font-medium text-muted-foreground">
Davet edilen kullanicilar ({referrer.referrals.length})
</p>
<div className="space-y-2">
{referrer.referrals.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between rounded-lg border bg-background p-3"
>
<div>
<p className="text-sm font-medium">{ref.referredName}</p>
<p className="text-xs text-muted-foreground">
{ref.referredEmail}
</p>
</div>
<div className="flex items-center gap-2">
{ref.rewardApplied && (
<Badge variant="default" className="text-xs">
Odul verildi
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatDate(ref.createdAt)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* 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>
)}
</div>
);
}

View File

@@ -1,460 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { useAuth } from "@/hooks/use-auth";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
Search,
ChevronLeft,
ChevronRight,
Eye,
ArrowLeft,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface UserItem {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
subscriptionStatus: string;
createdAt: string;
}
interface UserListResponse {
items: UserItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
interface Subscription {
id: string;
planId: string;
status: string;
billingPeriod: string;
startDate: string | null;
endDate: string | null;
createdAt: string;
}
interface Payment {
id: string;
amount: number;
currency: string;
method: string;
status: string;
createdAt: string;
}
interface UserDetail {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
referralCode: string | null;
createdAt: string;
updatedAt: string;
subscriptions: Subscription[];
payments: Payment[];
}
export default function AdminUsersPage() {
const { t } = useTranslation();
const { user, isLoading: authLoading } = useAuth();
const router = useRouter();
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [page, setPage] = useState(1);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const limit = 20;
useEffect(() => {
if (!authLoading && user?.role !== "admin") {
router.push("/dashboard/search");
}
}, [authLoading, user, router]);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, 300);
return () => clearTimeout(timer);
}, [search]);
const { data, isLoading } = useQuery({
queryKey: ["admin", "users", debouncedSearch, page, limit],
queryFn: () => {
const params = new URLSearchParams();
if (debouncedSearch) params.set("search", debouncedSearch);
params.set("page", String(page));
params.set("limit", String(limit));
return api.get<UserListResponse>(`/admin/users?${params.toString()}`);
},
enabled: user?.role === "admin",
});
const { data: userDetail, isLoading: detailLoading } = useQuery({
queryKey: ["admin", "users", selectedUserId],
queryFn: () => api.get<UserDetail>(`/admin/users/${selectedUserId}`),
enabled: !!selectedUserId && user?.role === "admin",
});
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",
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(amount / 100);
};
const roleVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
admin: "destructive",
user: "secondary",
};
const subStatusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
active: "default",
none: "outline",
};
// User Detail View
if (selectedUserId) {
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedUserId(null)}
>
<ArrowLeft className="mr-1 h-4 w-4" />
Geri
</Button>
<h2 className="text-2xl font-bold">Kullanici Detayi</h2>
</div>
{detailLoading ? (
<div className="space-y-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : userDetail ? (
<>
{/* User Info */}
<Card>
<CardHeader>
<CardTitle>Kullanici Bilgileri</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Ad</p>
<p className="font-medium">{userDetail.name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">E-posta</p>
<p className="font-medium">{userDetail.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rol</p>
<Badge variant={roleVariants[userDetail.role] || "secondary"}>
{userDetail.role}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">E-posta Dogrulandi</p>
<Badge variant={userDetail.emailVerified ? "default" : "outline"}>
{userDetail.emailVerified ? "Evet" : "Hayir"}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Referans Kodu</p>
<p className="font-medium">{userDetail.referralCode || "-"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Kayit Tarihi</p>
<p className="font-medium">{formatDate(userDetail.createdAt)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Subscriptions */}
<Card>
<CardHeader>
<CardTitle>
Abonelikler ({userDetail.subscriptions.length})
</CardTitle>
</CardHeader>
<CardContent>
{userDetail.subscriptions.length === 0 ? (
<p className="py-4 text-center text-muted-foreground">
Abonelik bulunmuyor
</p>
) : (
<div className="space-y-3">
{userDetail.subscriptions.map((sub) => (
<div
key={sub.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant={
sub.status === "active"
? "default"
: sub.status === "cancelled"
? "destructive"
: "secondary"
}
>
{sub.status}
</Badge>
<span className="text-sm text-muted-foreground">
{sub.billingPeriod}
</span>
</div>
<p className="text-xs text-muted-foreground">
{sub.startDate ? formatDate(sub.startDate) : "-"}{" "}
- {sub.endDate ? formatDate(sub.endDate) : "-"}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatDate(sub.createdAt)}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Payments */}
<Card>
<CardHeader>
<CardTitle>
Odemeler ({userDetail.payments.length})
</CardTitle>
</CardHeader>
<CardContent>
{userDetail.payments.length === 0 ? (
<p className="py-4 text-center text-muted-foreground">
Odeme bulunmuyor
</p>
) : (
<div className="space-y-3">
{userDetail.payments.map((payment) => (
<div
key={payment.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Badge
variant={
payment.status === "completed"
? "default"
: payment.status === "failed"
? "destructive"
: "secondary"
}
>
{payment.status}
</Badge>
<span className="text-sm">
{payment.method.toUpperCase()}
</span>
</div>
<div className="text-right">
<p className="font-medium">
{formatCurrency(payment.amount)}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(payment.createdAt)}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Kullanici bulunamadi
</CardContent>
</Card>
)}
</div>
);
}
// User List View
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Kullanici Yonetimi</h2>
<Badge variant="outline">{data?.total ?? 0} kullanici</Badge>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="E-posta veya isim ile ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
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>
{/* Table */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`row-skeleton-${i}`} className="h-14 w-full" />
))}
</div>
) : !data || data.items.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{search ? "Sonuc bulunamadi" : "Henuz kullanici yok"}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
{/* Table Header */}
<div className="hidden items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground lg:grid lg:grid-cols-7">
<span>Ad</span>
<span className="col-span-2">E-posta</span>
<span>Rol</span>
<span>Abonelik</span>
<span>Kayit Tarihi</span>
<span className="text-right">Islem</span>
</div>
{/* Table Rows */}
<div className="divide-y">
{data.items.map((u) => (
<div
key={u.id}
className="grid cursor-pointer items-center gap-4 px-6 py-4 transition-colors hover:bg-muted/50 lg:grid-cols-7"
onClick={() => setSelectedUserId(u.id)}
>
<div>
<p className="font-medium">{u.name}</p>
</div>
<div className="col-span-2">
<p className="text-sm text-muted-foreground">{u.email}</p>
</div>
<div>
<Badge variant={roleVariants[u.role] || "secondary"}>
{u.role}
</Badge>
</div>
<div>
<Badge variant={subStatusVariants[u.subscriptionStatus] || "outline"}>
{u.subscriptionStatus === "active" ? "Aktif" : "Yok"}
</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">
{formatDate(u.createdAt)}
</p>
</div>
<div className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setSelectedUserId(u.id);
}}
>
<Eye className="mr-1 h-3 w-3" />
Detay
</Button>
</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>
)}
</div>
);
}

View File

@@ -1,190 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Separator } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Download, Filter } from "lucide-react";
import { useState } from "react";
interface Payment {
id: string;
amount: number;
method: "iyzico" | "eft";
status: "completed" | "pending" | "failed" | "refunded";
planName?: string;
receiptUrl?: string;
createdAt: string;
}
export default function BillingPage() {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState<string>("all");
const [methodFilter, setMethodFilter] = useState<string>("all");
const { data: payments, isLoading } = useQuery({
queryKey: ["payments", "me"],
queryFn: () => api.get<Payment[]>("/payments/me"),
});
const statusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
completed: "default",
pending: "secondary",
failed: "destructive",
refunded: "outline",
};
const filteredPayments = payments?.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) return false;
if (methodFilter !== "all" && p.method !== methodFilter) return false;
return true;
});
return (
<div className="mx-auto max-w-4xl space-y-6">
<h2 className="text-2xl font-bold">{t("billing.title")}</h2>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{t("common.filter")}:</span>
</div>
{/* Status Filter */}
<div className="flex gap-1">
{["all", "completed", "pending", "failed", "refunded"].map((status) => (
<Button
key={status}
variant={statusFilter === status ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(status)}
>
{status === "all" ? t("common.all") : t(`billing.statusLabels.${status}`)}
</Button>
))}
</div>
<Separator orientation="vertical" className="h-6" />
{/* Method Filter */}
<div className="flex gap-1">
{["all", "iyzico", "eft"].map((method) => (
<Button
key={method}
variant={methodFilter === method ? "default" : "outline"}
size="sm"
onClick={() => setMethodFilter(method)}
>
{method === "all" ? t("common.all") : t(`billing.methodLabels.${method}`)}
</Button>
))}
</div>
</div>
{/* Loading State */}
{isLoading ? (
<div className="space-y-4">
{["s1", "s2", "s3", "s4", "s5"].map((id) => (
<Skeleton key={id} className="h-16 w-full" />
))}
</div>
) : !filteredPayments || filteredPayments.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t("billing.noPayments")}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>{t("billing.payments")}</CardTitle>
</CardHeader>
<CardContent>
{/* Table Header */}
<div className="hidden items-center gap-4 border-b pb-3 text-sm font-medium text-muted-foreground sm:grid sm:grid-cols-6">
<span>{t("billing.date")}</span>
<span>{t("billing.plan")}</span>
<span className="text-right">{t("billing.amount")}</span>
<span className="text-center">{t("billing.method")}</span>
<span className="text-center">{t("billing.status")}</span>
<span className="text-right" />
</div>
{/* Table Rows */}
<div className="divide-y">
{filteredPayments.map((payment) => (
<div key={payment.id} className="grid items-center gap-4 py-4 sm:grid-cols-6">
{/* Date */}
<div>
<p className="text-sm font-medium sm:font-normal">
{new Date(payment.createdAt).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
<p className="text-xs text-muted-foreground sm:hidden">
{payment.planName || "-"}
</p>
</div>
{/* Plan */}
<div className="hidden sm:block">
<p className="text-sm">{payment.planName || "-"}</p>
</div>
{/* Amount */}
<div className="text-right">
<span className="font-medium">
{new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(payment.amount / 100)}
</span>
</div>
{/* Method */}
<div className="text-center">
<Badge variant={payment.method === "iyzico" ? "secondary" : "outline"}>
{t(`billing.methodLabels.${payment.method}`)}
</Badge>
</div>
{/* Status */}
<div className="text-center">
<Badge variant={statusVariants[payment.status] || "secondary"}>
{t(`billing.statusLabels.${payment.status}`)}
</Badge>
</div>
{/* Actions */}
<div className="text-right">
{payment.method === "eft" && payment.receiptUrl && (
<Button variant="ghost" size="sm" asChild>
<a
href={payment.receiptUrl}
target="_blank"
rel="noopener noreferrer"
download
>
<Download className="mr-1 h-3 w-3" />
{t("billing.downloadReceipt")}
</a>
</Button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,62 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import Link from "next/link";
export default function HistoryPage() {
const { data, isLoading } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<any[]>("/vehicles/history"),
});
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h2 className="text-2xl font-bold">Arama Geçmişi</h2>
<p className="text-muted-foreground">Daha önce aradığınız araçlar</p>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : !data || data.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Henüz arama yapmadınız.
<br />
<Link href="/dashboard/search" className="text-primary hover:underline">
VIN arama sayfasına gidin
</Link>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{data.map((vehicle: any) => (
<Link key={vehicle.id} href={`/dashboard/vehicles/${vehicle.id}`}>
<Card className="transition-shadow hover:shadow-md cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{vehicle.brandName} {vehicle.model}
</CardTitle>
<Badge variant="secondary">{vehicle.year}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="font-mono text-sm text-muted-foreground">{vehicle.vin}</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -1,106 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Search } from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "sonner";
import { isValidVin } from "@sase/shared";
export default function SearchPage() {
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
setError(null);
setResult(null);
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin)) {
setError("Geçersiz VIN. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
setLoading(true);
try {
const data = await api.post("/vehicles/decode", { vin: cleanVin });
setResult(data);
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Bir hata oluştu. Lütfen tekrar deneyin.");
}
toast.error("VIN arama başarısız");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h2 className="text-2xl font-bold">VIN Arama</h2>
<p className="text-muted-foreground">Araç VIN numarasını girerek yedek parça kataloğuna erişin</p>
</div>
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-3">
<Input
placeholder="VIN numarasını girin (17 karakter)"
value={vin}
onChange={(e) => {
setVin(e.target.value.toUpperCase());
setError(null);
}}
maxLength={17}
className="font-mono text-lg tracking-wider"
/>
<Button type="submit" disabled={loading || vin.length !== 17}>
{loading ? (
<span className="animate-spin">...</span>
) : (
<Search className="h-4 w-4" />
)}
Ara
</Button>
</form>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
{result && (
<Card>
<CardHeader>
<CardTitle>
{result.brandName} {result.model} {result.year && `(${result.year})`}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">VIN:</span>{" "}
<span className="font-mono">{result.vin}</span>
</div>
<div>
<span className="text-muted-foreground">Motor:</span> {result.engine || "-"}
</div>
<div>
<span className="text-muted-foreground">Vites:</span> {result.transmission || "-"}
</div>
<div>
<span className="text-muted-foreground">Kasa:</span> {result.bodyType || "-"}
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,148 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Search } from "lucide-react";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "sonner";
interface PartSearchResult {
part: {
id: string;
oemCode: string;
name: string;
categoryId: string;
vehicleId: string;
};
vehicle: {
vin: string;
brandName: string;
model: string;
year: number | null;
};
}
export default function PartsSearchPage() {
const [oem, setOem] = useState("");
const [searchTerm, setSearchTerm] = useState("");
const { data, isLoading, isError, error } = useQuery<PartSearchResult[]>({
queryKey: ["parts", "search", searchTerm],
queryFn: () => api.get(`/parts/search?oem=${encodeURIComponent(searchTerm)}`),
enabled: searchTerm.length > 0,
});
function handleSearch(e: React.FormEvent) {
e.preventDefault();
const cleaned = oem.trim();
if (!cleaned) {
toast.error("Lütfen bir OEM parça numarası girin");
return;
}
setSearchTerm(cleaned);
}
return (
<div className="mx-auto max-w-5xl space-y-6">
<div>
<h2 className="text-2xl font-bold">OEM Parça Arama</h2>
<p className="text-muted-foreground">
OEM parça numarasını girerek eşleşen parçaları bulun
</p>
</div>
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-3">
<Input
placeholder="OEM parça numarası (ör. 11 42 7 837 997)"
value={oem}
onChange={(e) => setOem(e.target.value)}
className="font-mono text-lg tracking-wider"
/>
<Button type="submit" disabled={isLoading || oem.trim().length === 0}>
{isLoading ? (
<span className="animate-spin">...</span>
) : (
<Search className="h-4 w-4" />
)}
Ara
</Button>
</form>
</CardContent>
</Card>
{isLoading && (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)}
{isError && (
<Card>
<CardContent className="py-8 text-center text-destructive">
{error instanceof ApiError
? error.message
: "Arama sırasında bir hata oluştu. Lütfen tekrar deneyin."}
</CardContent>
</Card>
)}
{data && data.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<p className="text-lg font-medium">Sonuç bulunamadı</p>
<p className="mt-1">
&quot;{searchTerm}&quot; ile eşleşen parça bulunamadı. Lütfen farklı bir
numara deneyin.
</p>
</CardContent>
</Card>
)}
{data && data.length > 0 && (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">OEM Kodu</th>
<th className="px-4 py-3 text-left font-medium">Parça Adı</th>
<th className="px-4 py-3 text-left font-medium">VIN</th>
<th className="px-4 py-3 text-left font-medium">Marka</th>
<th className="px-4 py-3 text-left font-medium">Model</th>
<th className="px-4 py-3 text-left font-medium">Yıl</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => (
<tr
key={`${item.part.id}-${index}`}
className="border-b transition-colors hover:bg-muted/50"
>
<td className="px-4 py-3 font-mono">{item.part.oemCode}</td>
<td className="px-4 py-3">{item.part.name}</td>
<td className="px-4 py-3 font-mono text-xs">
{item.vehicle.vin}
</td>
<td className="px-4 py-3">{item.vehicle.brandName}</td>
<td className="px-4 py-3">{item.vehicle.model}</td>
<td className="px-4 py-3">{item.vehicle.year ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,24 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { Skeleton } from "@sase/ui";
const SettingsContent = dynamic(
() =>
import("@/components/settings/settings-content").then((mod) => ({
default: mod.SettingsContent,
})),
{
loading: () => (
<div className="mx-auto max-w-3xl space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
),
},
);
export default function SettingsPage() {
return <SettingsContent />;
}

View File

@@ -1,416 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Separator } from "@sase/ui";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Crown } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
const BrandSelector = dynamic(
() =>
import("@/components/subscription/brand-selector").then((mod) => ({
default: mod.BrandSelector,
})),
{
loading: () => (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`brand-skel-${i}`} className="h-24 w-full rounded-lg" />
))}
</div>
),
},
);
interface SubscriptionBrand {
brandId: string;
brandName: string;
}
interface Subscription {
status: string;
plan?: { name: string; key: string };
billingPeriod: string;
brands?: SubscriptionBrand[];
startDate?: string;
endDate?: string;
}
const plans = [
{
key: "brand1",
brandLimit: 1,
priceMonthly: 20000,
priceYearly: 200000,
features: ["vinSearch", "partsCatalog", "schemaViewer"],
},
{
key: "brand2",
brandLimit: 2,
priceMonthly: 35000,
priceYearly: 350000,
popular: true,
features: ["vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
},
{
key: "brand3",
brandLimit: 3,
priceMonthly: 50000,
priceYearly: 500000,
features: ["vinSearch", "partsCatalog", "schemaViewer", "prioritySupport"],
},
{
key: "full",
brandLimit: 999,
priceMonthly: 99900,
priceYearly: 999000,
features: [
"allBrands",
"vinSearch",
"partsCatalog",
"schemaViewer",
"prioritySupport",
"oemSearch",
],
},
];
function formatTRY(amount: number): string {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(amount / 100);
}
export default function SubscriptionPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const router = useRouter();
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const { data: subscription, isLoading } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<Subscription>("/subscriptions/me"),
});
const cancelMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/cancel"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["subscription"] });
toast.success(t("subscription.cancelled"));
setCancelDialogOpen(false);
},
onError: () => {
toast.error(t("errors.generic"));
},
});
const resumeMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/resume"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["subscription"] });
toast.success(t("subscription.resumed"));
},
onError: () => {
toast.error(t("errors.generic"));
},
});
function handleSelectPlan(planKey: string) {
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
}
function handleProceedToPayment() {
if (!selectedPlanKey) return;
const plan = plans.find((p) => p.key === selectedPlanKey);
if (!plan) return;
const isFull = plan.key === "full";
if (!isFull && selectedBrandIds.length === 0) {
toast.error(t("subscription.selectBrandsDescription"));
return;
}
const params = new URLSearchParams({
plan: selectedPlanKey,
period: billingPeriod,
brands: selectedBrandIds.join(","),
});
router.push(`/dashboard/subscription/pay?${params.toString()}`);
}
const statusVariants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
active: "default",
pending: "secondary",
cancelled: "destructive",
expired: "outline",
};
if (isLoading) {
return (
<div className="mx-auto max-w-5xl space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
);
}
return (
<div className="mx-auto max-w-5xl space-y-8">
<h2 className="text-2xl font-bold">{t("subscription.title")}</h2>
{/* Active Subscription Status */}
{subscription && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Crown className="h-5 w-5 text-primary" />
<CardTitle>{t("subscription.currentPlan")}</CardTitle>
</div>
<Badge variant={statusVariants[subscription.status] || "secondary"}>
{t(`subscription.statusLabels.${subscription.status}`)}
</Badge>
</div>
<CardDescription>
{subscription.plan?.name} &mdash;{" "}
{subscription.billingPeriod === "yearly" ? t("common.yearly") : t("common.monthly")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{subscription.brands && subscription.brands.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium">{t("subscription.accessibleBrands")}:</p>
<div className="flex flex-wrap gap-2">
{subscription.brands.map((b) => (
<Badge key={b.brandId} variant="outline">
{b.brandName}
</Badge>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-6 text-sm">
{subscription.startDate && (
<div>
<span className="text-muted-foreground">{t("subscription.startDate")}: </span>
<span className="font-medium">
{new Date(subscription.startDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
{subscription.endDate && (
<div>
<span className="text-muted-foreground">{t("subscription.endDate")}: </span>
<span className="font-medium">
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
</div>
<div className="flex gap-3">
{subscription.status === "active" && (
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogTrigger asChild>
<Button variant="destructive">{t("subscription.cancelSubscription")}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("subscription.cancelConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("subscription.cancelConfirmDescription")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelDialogOpen(false)}>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
>
{cancelMutation.isPending
? t("subscription.cancelling")
: t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{subscription.status === "cancelled" && (
<Button onClick={() => resumeMutation.mutate()} disabled={resumeMutation.isPending}>
{resumeMutation.isPending
? t("subscription.resuming")
: t("subscription.resumeSubscription")}
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* No Subscription Banner */}
{!subscription && (
<Card className="border-dashed">
<CardContent className="py-8 text-center">
<p className="mb-2 text-lg font-medium text-muted-foreground">
{t("subscription.noSubscription")}
</p>
<p className="text-sm text-muted-foreground">
{t("subscription.selectBrandsDescription")}
</p>
</CardContent>
</Card>
)}
<Separator />
{/* Billing Period Toggle */}
<div className="flex items-center justify-center gap-4">
<Button
variant={billingPeriod === "monthly" ? "default" : "outline"}
size="sm"
onClick={() => setBillingPeriod("monthly")}
>
{t("common.monthly")}
</Button>
<Button
variant={billingPeriod === "yearly" ? "default" : "outline"}
size="sm"
onClick={() => setBillingPeriod("yearly")}
>
{t("common.yearly")}
</Button>
</div>
{/* Plan Comparison Cards */}
<div>
<h3 className="mb-4 text-lg font-semibold">{t("subscription.planComparison")}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{plans.map((plan) => {
const isSelected = selectedPlanKey === plan.key;
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
const isCurrentPlan =
subscription?.status === "active" && subscription?.plan?.key === plan.key;
return (
<Card
key={plan.key}
className={`relative cursor-pointer transition-all hover:shadow-md ${
isSelected ? "border-primary ring-2 ring-primary/20" : ""
} ${isCurrentPlan ? "border-green-500/50 bg-green-50/50 dark:bg-green-950/10" : ""} ${
plan.popular ? "border-primary shadow-lg" : ""
}`}
onClick={() => handleSelectPlan(plan.key)}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2">
{t("subscription.popular")}
</Badge>
)}
{isCurrentPlan && (
<Badge
variant="outline"
className="absolute -top-3 right-4 border-green-500 bg-green-50 text-green-700"
>
{t("subscription.currentPlan")}
</Badge>
)}
<CardHeader>
<CardTitle className="text-lg">
{t(`subscription.plans.${plan.key}.name`)}
</CardTitle>
<CardDescription>
{t(`subscription.plans.${plan.key}.description`)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-3xl font-bold">{formatTRY(price)}</span>
<span className="text-muted-foreground">
{billingPeriod === "monthly" ? t("common.perMonth") : t("common.perYear")}
</span>
</div>
<ul className="space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={isSelected ? "default" : plan.popular ? "default" : "outline"}
onClick={(e) => {
e.stopPropagation();
handleSelectPlan(plan.key);
}}
>
{isSelected ? t("subscription.choosePlan") : t("subscription.subscribe")}
</Button>
</CardFooter>
</Card>
);
})}
</div>
</div>
{/* Brand Selector (shown when a plan is selected) */}
{selectedPlanKey && (
<>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("subscription.selectBrands")}</h3>
<p className="text-sm text-muted-foreground">
{t("subscription.selectBrandsDescription")}
</p>
<BrandSelector
maxBrands={plans.find((p) => p.key === selectedPlanKey)?.brandLimit || 1}
selectedBrandIds={selectedBrandIds}
onSelectionChange={setSelectedBrandIds}
isFullPlan={selectedPlanKey === "full"}
/>
<div className="flex justify-end">
<Button size="lg" onClick={handleProceedToPayment}>
{t("subscription.subscribe")} &rarr;
</Button>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,36 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { Skeleton } from "@sase/ui";
import { Suspense } from "react";
const PaymentContent = dynamic(
() =>
import("@/components/payment/payment-content").then((mod) => ({
default: mod.PaymentContent,
})),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl space-y-6">
<Skeleton className="h-8 w-24" />
<div className="flex items-center justify-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<Skeleton className="h-96 w-full" />
</div>
),
},
);
export default function PaymentPage() {
return (
<Suspense>
<PaymentContent />
</Suspense>
);
}

View File

@@ -1,33 +0,0 @@
"use client";
import { useEffect } from "react";
import { Button } from "@sase/ui";
import { Card, CardContent } from "@sase/ui";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Dashboard error:", error);
}, [error]);
return (
<div className="flex flex-1 items-center justify-center p-6">
<Card className="mx-auto max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
<div className="space-y-2">
<h2 className="text-2xl font-bold text-destructive">Bir hata oluştu</h2>
<p className="text-muted-foreground">
Bu sayfada beklenmeyen bir hata meydana geldi. Lütfen tekrar deneyin.
</p>
</div>
<Button onClick={reset}>Tekrar dene</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,43 +0,0 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { Skeleton } from "@sase/ui";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/login");
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="space-y-4 w-64">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
);
}
if (!isAuthenticated) return null;
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { Skeleton } from "@sase/ui";
export default function DashboardLoading() {
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
{/* Page title skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
{/* Content card skeleton */}
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
{/* Additional content skeleton */}
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}

View File

@@ -1,86 +0,0 @@
"use client";
import { use } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useCategoryParts } from "@/hooks/use-parts";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
const SchemaViewer = dynamic(
() =>
import("@/components/schema/schema-viewer").then((mod) => ({
default: mod.SchemaViewer,
})),
{
ssr: false,
loading: () => (
<div className="flex h-[600px] gap-4 rounded-lg border border-border">
<div className="flex w-[60%] items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-[40%] space-y-3 p-4">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={`schema-skel-${i}`} className="h-10 w-full" />
))}
</div>
</div>
),
},
);
interface PageProps {
params: Promise<{
id: string;
categoryId: string;
}>;
}
export default function VehicleCategoryPage({ params }: PageProps) {
const { id, categoryId } = use(params);
const router = useRouter();
const { data, isLoading, error } = useCategoryParts(id, categoryId);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => router.back()}
title="Geri don"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">
{data?.name || "Kategori Detayi"}
</h1>
{data?.description && (
<p className="text-sm text-muted-foreground">
{data.description}
</p>
)}
</div>
</div>
{/* Error state */}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Veriler yuklenirken bir hata olustu. Lutfen tekrar deneyin.
</div>
)}
{/* Schema Viewer */}
<SchemaViewer
schemaPic={data?.schemaPics?.[0] ?? null}
hotspots={data?.hotspots ?? []}
parts={data?.parts ?? []}
isLoading={isLoading}
/>
</div>
);
}

View File

@@ -1,28 +0,0 @@
"use client";
import { useEffect } from "react";
import { Button } from "@sase/ui";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Global error:", error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center">
<div className="space-y-2">
<h1 className="text-4xl font-bold text-destructive">Bir hata oluştu</h1>
<p className="text-muted-foreground">
Beklenmeyen bir hata meydana geldi. Lütfen tekrar deneyin.
</p>
</div>
<Button onClick={reset}>Tekrar dene</Button>
</div>
);
}

View File

@@ -1,58 +0,0 @@
@import "tailwindcss";
@theme {
--color-background: #ffffff;
--color-foreground: #0a0a0a;
--color-muted: #f5f5f5;
--color-muted-foreground: #737373;
--color-border: #e5e5e5;
--color-input: #e5e5e5;
--color-ring: #0a0a0a;
--color-primary: #0a0a0a;
--color-primary-foreground: #fafafa;
--color-secondary: #f5f5f5;
--color-secondary-foreground: #171717;
--color-accent: #f5f5f5;
--color-accent-foreground: #171717;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
--color-card: #ffffff;
--color-card-foreground: #0a0a0a;
--color-popover: #ffffff;
--color-popover-foreground: #0a0a0a;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
}
}
.dark {
--color-background: #0a0a0a;
--color-foreground: #fafafa;
--color-muted: #262626;
--color-muted-foreground: #a3a3a3;
--color-border: #262626;
--color-input: #262626;
--color-ring: #d4d4d4;
--color-primary: #fafafa;
--color-primary-foreground: #171717;
--color-secondary: #262626;
--color-secondary-foreground: #fafafa;
--color-accent: #262626;
--color-accent-foreground: #fafafa;
--color-destructive: #dc2626;
--color-destructive-foreground: #fafafa;
--color-card: #0a0a0a;
--color-card-foreground: #fafafa;
--color-popover: #0a0a0a;
--color-popover-foreground: #fafafa;
}

View File

@@ -1,21 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Sase.tr — Yedek Parça Arama",
description: "VIN numarasıyla araç yedek parça katalog arama sistemi",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -1,19 +0,0 @@
import Link from "next/link";
import { Button } from "@sase/ui";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-4 text-center">
<div className="space-y-2">
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
<h2 className="text-2xl font-semibold">Sayfa bulunamadı</h2>
<p className="text-muted-foreground">
Aradığınız sayfa mevcut değil veya taşınmış olabilir.
</p>
</div>
<Link href="/">
<Button>Ana sayfaya dön</Button>
</Link>
</div>
);
}

View File

@@ -1,105 +0,0 @@
import Link from "next/link";
import { Button } from "@sase/ui";
import type { Metadata } from "next";
export const dynamic = "force-static";
export const metadata: Metadata = {
title: "Sase.tr — VIN ile Yedek Parça Katalog Arama",
description:
"Araç VIN numaranızı girin, orijinal yedek parça kataloglarına anında erişin. BMW, Mercedes, Audi, Volkswagen ve 20+ marka desteği.",
};
export default function HomePage() {
return (
<div className="flex min-h-screen flex-col">
{/* Navbar */}
<header className="border-b">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<span className="text-xl font-bold">Sase.tr</span>
<div className="flex items-center gap-4">
<Link href="/pricing">
<Button variant="ghost">Fiyatlar</Button>
</Link>
<Link href="/login">
<Button variant="ghost">Giriş Yap</Button>
</Link>
<Link href="/register">
<Button>Kayıt Ol</Button>
</Link>
</div>
</div>
</header>
{/* Hero */}
<section className="flex flex-1 flex-col items-center justify-center px-4 py-24 text-center">
<h1 className="max-w-3xl text-4xl font-bold tracking-tight sm:text-6xl">
VIN ile Yedek Parça <span className="text-primary">Katalog Arama</span>
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground">
Araç VIN numaranızı girin, orijinal yedek parça kataloglarına anında erişin.
BMW, Mercedes, Audi, Volkswagen ve 20+ marka desteği.
</p>
<div className="mt-8 flex gap-4">
<Link href="/register">
<Button size="lg">Hemen Başla</Button>
</Link>
<Link href="/pricing">
<Button variant="outline" size="lg">
Fiyatları Gör
</Button>
</Link>
</div>
</section>
{/* Features */}
<section className="border-t bg-muted/30 py-24">
<div className="container mx-auto px-4">
<h2 className="text-center text-3xl font-bold">Neden Sase.tr?</h2>
<div className="mt-12 grid gap-8 md:grid-cols-3">
{[
{
title: "VIN Decode",
description:
"17 haneli VIN numarasıyla aracınızı tanımlayın, motor, kasa ve vites bilgilerine erişin.",
},
{
title: "Parça Kataloğu",
description:
"Orijinal parça numaraları, şema diyagramları ve detaylı parça bilgileri.",
},
{
title: "Interaktif Şema",
description:
"Teknik diyagramlar üzerinde tıklayarak parçaları bulun. Zoom, pan ve hotspot desteği.",
},
].map((feature) => (
<div key={feature.title} className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-lg font-semibold">{feature.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-24 text-center">
<h2 className="text-3xl font-bold">Hemen Başlayın</h2>
<p className="mt-4 text-muted-foreground">
Kayıt olun, plan seçin ve yedek parça aramasına başlayın.
</p>
<div className="mt-8">
<Link href="/register">
<Button size="lg">Ücretsiz Deneyin</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t py-8 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.</p>
</footer>
</div>
);
}

View File

@@ -1,124 +0,0 @@
import Link from "next/link";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
import { Badge } from "@sase/ui";
import type { Metadata } from "next";
export const dynamic = "force-static";
export const metadata: Metadata = {
title: "Fiyatlandırma — Sase.tr",
description:
"İhtiyacınıza uygun planı seçin. 1 marka, 2 marka, 3 marka veya full paket seçenekleri. Tüm fiyatlar KDV dahildir.",
};
const plans = [
{
name: "1 Marka",
description: "Tek marka için yedek parça erişimi",
priceMonthly: "200",
priceYearly: "2.000",
features: ["1 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici"],
},
{
name: "2 Marka",
description: "İki farklı marka için erişim",
priceMonthly: "350",
priceYearly: "3.500",
popular: true,
features: ["2 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
},
{
name: "3 Marka",
description: "Üç marka için kapsamlı erişim",
priceMonthly: "500",
priceYearly: "5.000",
features: ["3 marka seçimi", "Sınırsız VIN arama", "Parça kataloğu", "Şema görüntüleyici", "Öncelikli destek"],
},
{
name: "Full Paket",
description: "Tüm markalara sınırsız erişim",
priceMonthly: "999",
priceYearly: "9.990",
features: [
"Tüm markalar",
"Sınırsız VIN arama",
"Parça kataloğu",
"Şema görüntüleyici",
"Öncelikli destek",
"OEM parça arama",
],
},
];
export default function PricingPage() {
return (
<div className="min-h-screen">
<header className="border-b">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Link href="/" className="text-xl font-bold">
Sase.tr
</Link>
<div className="flex items-center gap-4">
<Link href="/login">
<Button variant="ghost">Giriş Yap</Button>
</Link>
<Link href="/register">
<Button>Kayıt Ol</Button>
</Link>
</div>
</div>
</header>
<main className="container mx-auto px-4 py-24">
<div className="text-center">
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
<p className="mt-4 text-lg text-muted-foreground">
İhtiyacınıza uygun planı seçin. Tüm fiyatlar KDV dahildir.
</p>
</div>
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{plans.map((plan) => (
<Card
key={plan.name}
className={plan.popular ? "border-primary shadow-lg relative" : ""}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2">
Popüler
</Badge>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-3xl font-bold">{plan.priceMonthly} TL</span>
<span className="text-muted-foreground">/ay</span>
</div>
<p className="text-sm text-muted-foreground">
veya {plan.priceYearly} TL/yıl
</p>
<ul className="space-y-2 text-sm">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<span className="text-primary">&#10003;</span>
{f}
</li>
))}
</ul>
<Link href="/register">
<Button className="w-full" variant={plan.popular ? "default" : "outline"}>
Başla
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
</main>
</div>
);
}

View File

@@ -1,101 +0,0 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { BarChart3 } from "lucide-react";
interface DailyStat {
date: string;
count: number;
successCount: number;
failureCount: number;
}
interface DailyChartProps {
dailyStats: DailyStat[] | undefined;
isLoading: boolean;
}
export function DailyChart({ dailyStats, isLoading }: DailyChartProps) {
const maxCount = dailyStats
? Math.max(...dailyStats.map((d) => d.count), 1)
: 1;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Gunluk Sorgu Istatistikleri (Son 30 Gun)
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-64 w-full" />
) : !dailyStats || dailyStats.length === 0 ? (
<p className="py-12 text-center text-muted-foreground">
Henuz veri bulunmuyor
</p>
) : (
<div className="flex items-end gap-1 overflow-x-auto pb-2" style={{ height: 256 }}>
{dailyStats.map((day) => {
const heightPercent = (day.count / maxCount) * 100;
const successPercent =
day.count > 0
? (day.successCount / day.count) * 100
: 0;
const dateLabel = new Date(day.date).toLocaleDateString(
"tr-TR",
{ day: "2-digit", month: "2-digit" },
);
return (
<div
key={day.date}
className="group relative flex flex-1 min-w-[16px] flex-col items-center justify-end"
style={{ height: "100%" }}
>
{/* Tooltip */}
<div className="absolute -top-8 hidden rounded bg-popover px-2 py-1 text-xs shadow-md group-hover:block">
{day.count} sorgu
</div>
{/* Bar */}
<div
className="relative w-full max-w-[24px] overflow-hidden rounded-t"
style={{ height: `${Math.max(heightPercent, 2)}%` }}
>
{/* Success portion */}
<div
className="absolute bottom-0 w-full bg-primary"
style={{ height: `${successPercent}%` }}
/>
{/* Failure portion */}
<div
className="absolute top-0 w-full bg-destructive/60"
style={{
height: `${100 - successPercent}%`,
}}
/>
</div>
{/* Date label */}
<span className="mt-1 text-[9px] text-muted-foreground">
{dateLabel}
</span>
</div>
);
})}
</div>
)}
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded bg-primary" />
Basarili
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded bg-destructive/60" />
Basarisiz
</span>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,108 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronRight, ChevronDown, FolderOpen, Folder } from "lucide-react";
interface Category {
id: string;
name: string;
children?: Category[];
partCount?: number;
}
interface CategoryTreeProps {
categories: Category[];
vehicleId: string;
basePath?: string;
}
export function CategoryTree({ categories, vehicleId, basePath }: CategoryTreeProps) {
if (!categories || categories.length === 0) {
return (
<p className="py-4 text-center text-sm text-muted-foreground">
Kategori bulunamadi.
</p>
);
}
return (
<div className="space-y-1">
{categories.map((category) => (
<CategoryNode
key={category.id}
category={category}
vehicleId={vehicleId}
basePath={basePath}
level={0}
/>
))}
</div>
);
}
interface CategoryNodeProps {
category: Category;
vehicleId: string;
basePath?: string;
level: number;
}
function CategoryNode({ category, vehicleId, basePath, level }: CategoryNodeProps) {
const [expanded, setExpanded] = useState(false);
const hasChildren = category.children && category.children.length > 0;
const href = basePath
? `${basePath}/${category.id}`
: `/vehicles/${vehicleId}/categories/${category.id}`;
return (
<div>
<div
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-muted"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="h-5 w-5" />
)}
{expanded ? (
<FolderOpen className="h-4 w-4 text-muted-foreground" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
<Link href={href} className="flex-1 truncate hover:underline">
{category.name}
</Link>
{category.partCount != null && category.partCount > 0 && (
<span className="text-xs text-muted-foreground">
{category.partCount}
</span>
)}
</div>
{hasChildren && expanded && (
<div>
{category.children!.map((child) => (
<CategoryNode
key={child.id}
category={child}
vehicleId={vehicleId}
basePath={basePath}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,90 +0,0 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { useTranslation } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import { Button } from "@sase/ui";
import { Globe, LogOut, Menu } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { MobileNav } from "./mobile-nav";
const localeOptions: { value: Locale; label: string }[] = [
{ value: "tr", label: "TR" },
{ value: "en", label: "EN" },
];
export function Header() {
const { user, signOut } = useAuth();
const { t, locale, setLocale } = useTranslation();
const [mobileOpen, setMobileOpen] = useState(false);
const [langOpen, setLangOpen] = useState(false);
const langRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (langRef.current && !langRef.current.contains(e.target as Node)) {
setLangOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<>
<header className="flex h-14 items-center justify-between border-b px-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-semibold lg:hidden">Sase.tr</h1>
</div>
<div className="flex items-center gap-3">
{/* Language Toggle */}
<div className="relative" ref={langRef}>
<Button
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => setLangOpen(!langOpen)}
>
<Globe className="h-4 w-4" />
<span className="text-xs font-medium">{locale.toUpperCase()}</span>
</Button>
{langOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-32 rounded-md border bg-background shadow-md">
{localeOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-accent ${
locale === opt.value ? "bg-accent font-medium" : ""
}`}
onClick={() => {
setLocale(opt.value);
setLangOpen(false);
}}
>
{opt.value === "tr" ? "Turkce" : "English"}
{locale === opt.value && <span className="ml-auto text-primary">&#10003;</span>}
</button>
))}
</div>
)}
</div>
<span className="hidden text-sm text-muted-foreground sm:inline">{user?.name}</span>
<Button variant="ghost" size="icon" onClick={() => signOut()}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
<MobileNav open={mobileOpen} onClose={() => setMobileOpen(false)} />
</>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@sase/ui";
import { Search, History, CreditCard, Receipt, Settings, ShieldCheck, Users, Activity, Gift, X } from "lucide-react";
import { Button } from "@sase/ui";
import { useAuth } from "@/hooks/use-auth";
const navItems = [
{ href: "/dashboard/search", label: "Arama", icon: Search },
{ href: "/dashboard/history", label: "Geçmiş", icon: History },
{ href: "/dashboard/subscription", label: "Abonelik", icon: CreditCard },
{ href: "/dashboard/billing", label: "Fatura", icon: Receipt },
{ href: "/dashboard/settings", label: "Ayarlar", icon: Settings },
];
const adminItems = [
{ href: "/dashboard/admin", label: "Admin Panel", icon: ShieldCheck, exact: true },
{ href: "/dashboard/admin/users", label: "Kullanicilar", icon: Users },
{ href: "/dashboard/admin/payments", label: "Odeme Onaylari", icon: Receipt },
{ href: "/dashboard/admin/referrals", label: "Referanslar", icon: Gift },
{ href: "/dashboard/admin/analytics", label: "Sorgu Analizi", icon: Activity },
];
interface MobileNavProps {
open: boolean;
onClose: () => void;
}
export function MobileNav({ open, onClose }: MobileNavProps) {
const pathname = usePathname();
const { isAdmin } = useAuth();
if (!open) return null;
return (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="fixed inset-y-0 left-0 w-64 bg-background border-r shadow-lg">
<div className="flex h-14 items-center justify-between border-b px-6">
<span className="font-bold text-lg">Sase.tr</span>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
<nav className="space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
{isAdmin && (
<>
<div className="my-4 border-t" />
<p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Yonetim
</p>
{adminItems.map((item) => {
const Icon = item.icon;
const isActive = "exact" in item && item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</>
)}
</nav>
</div>
</div>
);
}

View File

@@ -1,91 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@sase/ui";
import { Search, History, CreditCard, Receipt, Settings, ShieldCheck, Users, Activity, Gift } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
const navItems = [
{ href: "/dashboard/search", label: "Arama", icon: Search },
{ href: "/dashboard/history", label: "Geçmiş", icon: History },
{ href: "/dashboard/subscription", label: "Abonelik", icon: CreditCard },
{ href: "/dashboard/billing", label: "Fatura", icon: Receipt },
{ href: "/dashboard/settings", label: "Ayarlar", icon: Settings },
];
const adminItems = [
{ href: "/dashboard/admin", label: "Admin Panel", icon: ShieldCheck, exact: true },
{ href: "/dashboard/admin/users", label: "Kullanicilar", icon: Users },
{ href: "/dashboard/admin/payments", label: "Odeme Onaylari", icon: Receipt },
{ href: "/dashboard/admin/referrals", label: "Referanslar", icon: Gift },
{ href: "/dashboard/admin/analytics", label: "Sorgu Analizi", icon: Activity },
];
export function Sidebar() {
const pathname = usePathname();
const { isAdmin } = useAuth();
return (
<aside className="hidden w-64 border-r bg-muted/30 lg:block">
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<Link href="/dashboard/search" className="flex items-center gap-2 font-bold text-lg">
Sase.tr
</Link>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
{isAdmin && (
<>
<div className="my-4 border-t" />
<p className="mb-1 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Yonetim
</p>
{adminItems.map((item) => {
const Icon = item.icon;
const isActive = "exact" in item && item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</>
)}
</nav>
</div>
</aside>
);
}

View File

@@ -1,536 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { Separator } from "@sase/ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
Building2,
CheckCircle2,
Clock,
Copy,
CreditCard,
FileText,
Upload,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
interface Brand {
id: string;
name: string;
slug: string;
logoUrl?: string;
}
const planConfig: Record<string, { priceMonthly: number; priceYearly: number }> = {
brand1: { priceMonthly: 20000, priceYearly: 200000 },
brand2: { priceMonthly: 35000, priceYearly: 350000 },
brand3: { priceMonthly: 50000, priceYearly: 500000 },
full: { priceMonthly: 99900, priceYearly: 999000 },
};
const bankDetails = {
bankName: "Ziraat Bankası",
accountHolder: "Sase Teknoloji A.Ş.",
iban: "TR33 0001 0000 1234 5678 9012 34",
description: "Sase.tr Abonelik",
};
function formatTRY(amount: number): string {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
minimumFractionDigits: 2,
}).format(amount / 100);
}
type Step = "summary" | "payment" | "confirmation";
export function PaymentContent() {
const { t } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const fileInputRef = useRef<HTMLInputElement>(null);
const planKey = searchParams.get("plan") || "";
const period = (searchParams.get("period") || "monthly") as "monthly" | "yearly";
const brandIds = searchParams.get("brands")?.split(",").filter(Boolean) || [];
const [step, setStep] = useState<Step>("summary");
const [paymentMethod, setPaymentMethod] = useState<"iyzico" | "eft">("iyzico");
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [eftPaymentId, setEftPaymentId] = useState<string | null>(null);
const config = planConfig[planKey];
const totalAmount = config
? period === "monthly"
? config.priceMonthly
: config.priceYearly
: 0;
const { data: brands } = useQuery({
queryKey: ["brands"],
queryFn: () => api.get<Brand[]>("/brands"),
});
const selectedBrands = brands?.filter((b) => brandIds.includes(b.id)) || [];
const iyzicoMutation = useMutation({
mutationFn: () =>
api.post<{ redirectUrl: string }>("/payments/iyzico/initialize", {
planKey,
billingPeriod: period,
brandIds,
}),
onSuccess: (data) => {
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
onError: () => {
toast.error(t("payment.initializeFailed"));
},
});
const eftMutation = useMutation({
mutationFn: () =>
api.post<{ paymentId: string }>("/payments/eft", {
planKey,
billingPeriod: period,
brandIds,
}),
onSuccess: (data) => {
setEftPaymentId(data.paymentId);
toast.success(t("payment.processingPayment"));
},
onError: () => {
toast.error(t("errors.generic"));
},
});
const uploadMutation = useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return api.upload<{ success: boolean }>(`/payments/eft/${eftPaymentId}/receipt`, formData);
},
onSuccess: () => {
toast.success(t("payment.receiptUploaded"));
setStep("confirmation");
},
onError: () => {
toast.error(t("payment.uploadFailed"));
},
});
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) {
validateAndSetFile(file);
}
}, []);
function validateAndSetFile(file: File) {
const validTypes = ["image/png", "image/jpeg", "application/pdf"];
if (!validTypes.includes(file.type)) {
toast.error(t("errors.invalidFileType"));
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error(t("errors.fileTooBig"));
return;
}
setUploadedFile(file);
}
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
validateAndSetFile(file);
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
toast.success(t("common.copied"));
}
function handlePayWithCard() {
iyzicoMutation.mutate();
}
function handleEftProceed() {
eftMutation.mutate();
}
function handleUploadReceipt() {
if (uploadedFile) {
uploadMutation.mutate(uploadedFile);
}
}
if (!planKey || !config) {
return (
<div className="mx-auto max-w-2xl py-12 text-center">
<p className="text-muted-foreground">{t("common.noData")}</p>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push("/dashboard/subscription")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("common.back")}
</Button>
</div>
);
}
// Step 3: Confirmation
if (step === "confirmation") {
return (
<div className="mx-auto max-w-lg space-y-6">
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="mx-auto mb-4 h-16 w-16 text-green-500" />
<h2 className="mb-2 text-2xl font-bold">{t("payment.confirmation")}</h2>
<p className="text-muted-foreground">
{paymentMethod === "iyzico"
? t("payment.confirmationDescription")
: t("payment.eftConfirmationDescription")}
</p>
<Button className="mt-6" onClick={() => router.push("/dashboard/search")}>
{t("payment.goToDashboard")}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Back Button */}
<Button
variant="ghost"
size="sm"
onClick={() =>
step === "payment" ? setStep("summary") : router.push("/dashboard/subscription")
}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("common.back")}
</Button>
{/* Steps Indicator */}
<div className="flex items-center justify-center gap-2">
{[
{ key: "summary", label: t("payment.step1") },
{ key: "payment", label: t("payment.step2") },
{ key: "confirmation", label: t("payment.step3") },
].map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
s.key === step || (step === "payment" && i === 0)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{i + 1}
</div>
<span className="hidden text-sm sm:inline">{s.label}</span>
{i < 2 && <div className="h-px w-8 bg-border" />}
</div>
))}
</div>
{/* Step 1: Summary */}
{step === "summary" && (
<Card>
<CardHeader>
<CardTitle>{t("payment.summary")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t("payment.selectedPlan")}</span>
<span className="font-medium">
{t(`subscription.plans.${planKey}.name`)} (
{period === "monthly" ? t("common.monthly") : t("common.yearly")})
</span>
</div>
<Separator />
<div>
<span className="mb-2 block text-sm text-muted-foreground">
{t("payment.selectedBrands")}
</span>
<div className="flex flex-wrap gap-2">
{planKey === "full" ? (
<Badge>{t("subscription.allBrandsSelected")}</Badge>
) : selectedBrands.length > 0 ? (
selectedBrands.map((brand) => (
<Badge key={brand.id} variant="outline">
{brand.name}
</Badge>
))
) : (
brandIds.map((id) => (
<Badge key={id} variant="outline">
{id}
</Badge>
))
)}
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="text-lg font-semibold">{t("payment.totalAmount")}</span>
<span className="text-2xl font-bold text-primary">{formatTRY(totalAmount)}</span>
</div>
<Button className="w-full" size="lg" onClick={() => setStep("payment")}>
{t("common.next")} &rarr;
</Button>
</CardContent>
</Card>
)}
{/* Step 2: Payment */}
{step === "payment" && (
<Card>
<CardHeader>
<CardTitle>{t("payment.paymentMethod")}</CardTitle>
<CardDescription>
{t("payment.totalAmount")}: {formatTRY(totalAmount)}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs
defaultValue="iyzico"
onValueChange={(v) => setPaymentMethod(v as "iyzico" | "eft")}
>
<TabsList className="w-full">
<TabsTrigger value="iyzico" className="flex-1">
<CreditCard className="mr-2 h-4 w-4" />
{t("payment.creditCard")}
</TabsTrigger>
<TabsTrigger value="eft" className="flex-1">
<Building2 className="mr-2 h-4 w-4" />
{t("payment.eftTransfer")}
</TabsTrigger>
</TabsList>
{/* iyzico Credit Card Tab */}
<TabsContent value="iyzico" className="space-y-4 pt-4">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
3D Secure ile guvenli odeme. iyzico altyapisi kullanilmaktadir. Butona
tikladiginizda iyzico odeme sayfasina yonlendirileceksiniz.
</p>
</div>
<Button
className="w-full"
size="lg"
onClick={handlePayWithCard}
disabled={iyzicoMutation.isPending}
>
{iyzicoMutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{t("payment.paying")}
</>
) : (
<>
<CreditCard className="mr-2 h-4 w-4" />
{t("payment.payWithCard")} - {formatTRY(totalAmount)}
</>
)}
</Button>
</TabsContent>
{/* EFT/Havale Tab */}
<TabsContent value="eft" className="space-y-6 pt-4">
{/* Bank Account Details */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="font-semibold">{t("payment.bankDetails")}</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<Label className="text-muted-foreground">{t("payment.bankName")}</Label>
<p className="font-medium">{bankDetails.bankName}</p>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-muted-foreground">
{t("payment.accountHolder")}
</Label>
<p className="font-medium">{bankDetails.accountHolder}</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
<Label className="text-muted-foreground">{t("payment.iban")}</Label>
<p className="font-mono font-medium">{bankDetails.iban}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(bankDetails.iban.replace(/\s/g, ""))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div>
<Label className="text-muted-foreground">{t("payment.description")}</Label>
<p className="font-medium">{bankDetails.description}</p>
</div>
<div>
<Label className="text-muted-foreground">{t("payment.totalAmount")}</Label>
<p className="text-lg font-bold text-primary">{formatTRY(totalAmount)}</p>
</div>
</div>
</div>
{/* EFT Initiate */}
{!eftPaymentId && (
<Button
className="w-full"
onClick={handleEftProceed}
disabled={eftMutation.isPending}
>
{eftMutation.isPending ? t("payment.processingPayment") : "EFT/Havale Yaptim"}
</Button>
)}
{/* Receipt Upload */}
{eftPaymentId && (
<div className="space-y-3">
<h4 className="font-semibold">{t("payment.uploadReceipt")}</h4>
<p className="text-sm text-muted-foreground">
{t("payment.uploadReceiptDescription")}
</p>
<button
type="button"
className={`flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-primary/50"
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/png,image/jpeg,application/pdf"
onChange={handleFileSelect}
/>
{uploadedFile ? (
<div className="flex items-center gap-2">
<FileText className="h-8 w-8 text-primary" />
<div>
<p className="text-sm font-medium">{uploadedFile.name}</p>
<p className="text-xs text-muted-foreground">
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
) : (
<>
<Upload className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("payment.dragDrop")}</p>
<p className="text-xs text-muted-foreground">
{t("payment.supportedFormats")}
</p>
</>
)}
</button>
{uploadedFile && (
<Button
className="w-full"
onClick={handleUploadReceipt}
disabled={uploadMutation.isPending}
>
{uploadMutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{t("payment.uploading")}
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{t("payment.uploadReceipt")}
</>
)}
</Button>
)}
{/* Payment Status Tracker */}
<div className="rounded-lg border p-4">
<h5 className="mb-3 text-sm font-medium">{t("payment.paymentStatus")}</h5>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-sm">EFT/Havale kaydı oluşturuldu</span>
</div>
<div className="flex items-center gap-3">
{uploadedFile ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : (
<Clock className="h-5 w-5 text-muted-foreground" />
)}
<span className="text-sm">Dekont yüklendi</span>
</div>
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-muted-foreground" />
<span className="text-sm">{t("payment.waitingApproval")}</span>
</div>
</div>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -1,137 +0,0 @@
"use client";
import { useSchemaStore } from "@/stores/schema.store";
import type { Hotspot } from "@/hooks/use-parts";
interface HotspotOverlayProps {
hotspots: Hotspot[];
imageWidth: number;
imageHeight: number;
}
function HotspotShape({
hotspot,
isHighlighted,
isSelected,
onMouseEnter,
onMouseLeave,
onClick,
}: {
hotspot: Hotspot;
isHighlighted: boolean;
isSelected: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
onClick: () => void;
}) {
const fillOpacity = isSelected ? 0.35 : isHighlighted ? 0.25 : 0.08;
const strokeColor = isSelected
? "#ef4444"
: isHighlighted
? "#3b82f6"
: "#6b7280";
const fillColor = isSelected
? "#ef4444"
: isHighlighted
? "#3b82f6"
: "#9ca3af";
const strokeWidth = isSelected || isHighlighted ? 2.5 : 1.5;
const commonProps = {
fill: fillColor,
fillOpacity,
stroke: strokeColor,
strokeWidth,
className: "cursor-pointer transition-all duration-150",
onMouseEnter,
onMouseLeave,
onClick,
};
if (hotspot.shape === "circle") {
const [cx, cy, r] = hotspot.coordinates;
return <circle cx={cx} cy={cy} r={r} {...commonProps} />;
}
if (hotspot.shape === "rect") {
const [x, y, w, h] = hotspot.coordinates;
return <rect x={x} y={y} width={w} height={h} rx={2} {...commonProps} />;
}
if (hotspot.shape === "polygon") {
const points = hotspot.coordinates
.reduce<string[]>((acc, val, i) => {
if (i % 2 === 0) {
acc.push(`${val},${hotspot.coordinates[i + 1]}`);
}
return acc;
}, [])
.join(" ");
return <polygon points={points} {...commonProps} />;
}
return null;
}
export function HotspotOverlay({
hotspots,
imageWidth,
imageHeight,
}: HotspotOverlayProps) {
const { highlightedPartId, selectedPartId, setHighlightedPart, setSelectedPart } =
useSchemaStore();
return (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${imageWidth} ${imageHeight}`}
preserveAspectRatio="xMidYMid meet"
style={{ pointerEvents: "none" }}
>
{hotspots.map((hotspot) => {
const isHighlighted = highlightedPartId === hotspot.partId;
const isSelected = selectedPartId === hotspot.partId;
return (
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
<HotspotShape
hotspot={hotspot}
isHighlighted={isHighlighted}
isSelected={isSelected}
onMouseEnter={() => setHighlightedPart(hotspot.partId)}
onMouseLeave={() => setHighlightedPart(null)}
onClick={() =>
setSelectedPart(
selectedPartId === hotspot.partId ? null : hotspot.partId,
)
}
/>
{(isHighlighted || isSelected) && hotspot.label && (
<text
x={
hotspot.shape === "circle"
? hotspot.coordinates[0]
: hotspot.shape === "rect"
? hotspot.coordinates[0] + hotspot.coordinates[2] / 2
: hotspot.coordinates[0]
}
y={
hotspot.shape === "circle"
? hotspot.coordinates[1] - hotspot.coordinates[2] - 4
: hotspot.shape === "rect"
? hotspot.coordinates[1] - 4
: hotspot.coordinates[1] - 4
}
textAnchor="middle"
className="pointer-events-none select-none fill-foreground text-xs font-medium"
style={{ fontSize: 12 }}
>
{hotspot.label}
</text>
)}
</g>
);
})}
</svg>
);
}

View File

@@ -1,101 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useSchemaStore } from "@/stores/schema.store";
import { cn } from "@sase/ui";
import type { Part } from "@/hooks/use-parts";
interface PartsPanelProps {
parts: Part[];
}
export function PartsPanel({ parts }: PartsPanelProps) {
const { highlightedPartId, selectedPartId, setHighlightedPart, setSelectedPart } =
useSchemaStore();
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
useEffect(() => {
if (selectedPartId) {
const row = rowRefs.current.get(selectedPartId);
if (row) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}, [selectedPartId]);
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Parcalar</h3>
<p className="text-xs text-muted-foreground">
{parts.length} parca listeleniyor
</p>
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parca Adi</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
</tr>
</thead>
<tbody>
{parts.map((part) => {
const isHighlighted = highlightedPartId === part.id;
const isSelected = selectedPartId === part.id;
return (
<tr
key={part.id}
ref={(el) => {
if (el) {
rowRefs.current.set(part.id, el);
} else {
rowRefs.current.delete(part.id);
}
}}
className={cn(
"cursor-pointer border-b border-border/50 transition-colors duration-150",
isSelected &&
"bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
!isSelected && !isHighlighted && "hover:bg-accent/50",
)}
onMouseEnter={() => setHighlightedPart(part.id)}
onMouseLeave={() => setHighlightedPart(null)}
onClick={() =>
setSelectedPart(
selectedPartId === part.id ? null : part.id,
)
}
>
<td className="px-3 py-2 text-muted-foreground">
{part.index}
</td>
<td className="px-3 py-2 font-medium">{part.name}</td>
<td className="px-3 py-2 font-mono text-xs">
{part.oemCode}
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">
{part.position}
</td>
</tr>
);
})}
</tbody>
</table>
{parts.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Bu kategori icin parca bulunamadi.
</div>
)}
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
"use client";
import { Button } from "@sase/ui";
import { ZoomIn, ZoomOut, RotateCcw, Maximize, Minimize } from "lucide-react";
import { useSchemaStore } from "@/stores/schema.store";
export function SchemaToolbar() {
const { zoom, isFullscreen, setZoom, resetView, toggleFullscreen } =
useSchemaStore();
return (
<div className="flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-sm backdrop-blur-sm">
<Button
variant="ghost"
size="icon"
onClick={() => setZoom(zoom - 0.25)}
disabled={zoom <= 0.5}
title="Uzaklaştır"
>
<ZoomOut className="h-4 w-4" />
</Button>
<span className="min-w-[3rem] text-center text-xs font-medium text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<Button
variant="ghost"
size="icon"
onClick={() => setZoom(zoom + 0.25)}
disabled={zoom >= 5}
title="Yakınlaştır"
>
<ZoomIn className="h-4 w-4" />
</Button>
<div className="mx-1 h-6 w-px bg-border" />
<Button
variant="ghost"
size="icon"
onClick={resetView}
title="Görünümü sıfırla"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleFullscreen}
title={isFullscreen ? "Tam ekrandan çık" : "Tam ekran"}
>
{isFullscreen ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</Button>
</div>
);
}

View File

@@ -1,143 +0,0 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useSchemaStore } from "@/stores/schema.store";
import { useSchemaInteraction } from "@/hooks/use-schema-interaction";
import { SchemaToolbar } from "./schema-toolbar";
import { HotspotOverlay } from "./hotspot-overlay";
import { PartsPanel } from "./parts-panel";
import { Skeleton } from "@sase/ui";
import { cn } from "@sase/ui";
import type { Part, Hotspot, SchemaPic } from "@/hooks/use-parts";
interface SchemaViewerProps {
schemaPic: SchemaPic | null;
hotspots: Hotspot[];
parts: Part[];
isLoading?: boolean;
}
export function SchemaViewer({
schemaPic,
hotspots,
parts,
isLoading,
}: SchemaViewerProps) {
const { zoom, panX, panY, isFullscreen } = useSchemaStore();
const interaction = useSchemaInteraction();
const containerRef = useRef<HTMLDivElement>(null);
const handleFullscreenChange = useCallback(() => {
const store = useSchemaStore.getState();
const isCurrentlyFullscreen = !!document.fullscreenElement;
if (store.isFullscreen !== isCurrentlyFullscreen) {
store.toggleFullscreen();
}
}, []);
useEffect(() => {
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, [handleFullscreenChange]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (isFullscreen && !document.fullscreenElement) {
container.requestFullscreen?.().catch(() => {
/* silently fail if not allowed */
});
} else if (!isFullscreen && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {
/* silently fail */
});
}
}, [isFullscreen]);
if (isLoading) {
return (
<div className="flex h-[600px] gap-4 rounded-lg border border-border">
<div className="flex w-[60%] items-center justify-center">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-[40%] space-y-3 p-4">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</div>
);
}
return (
<div
ref={containerRef}
className={cn(
"flex rounded-lg border border-border bg-background",
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "h-[700px]",
)}
>
{/* Left side: Schema image + hotspot overlay (60%) */}
<div className="relative flex w-[60%] flex-col border-r border-border">
{/* Toolbar */}
<div className="absolute left-3 top-3 z-20">
<SchemaToolbar />
</div>
{/* Schema viewport */}
<div
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}
onMouseLeave={interaction.onMouseUp}
onTouchStart={interaction.onTouchStart}
onTouchMove={interaction.onTouchMove}
onTouchEnd={interaction.onTouchEnd}
>
{schemaPic ? (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `scale(${zoom}) translate(${panX / zoom}px, ${panY / zoom}px)`,
transformOrigin: "center center",
willChange: "transform",
}}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={schemaPic.url}
alt={schemaPic.label || "Sema goruntusu"}
width={schemaPic.width}
height={schemaPic.height}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
<HotspotOverlay
hotspots={hotspots}
imageWidth={schemaPic.width}
imageHeight={schemaPic.height}
/>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Sema goruntusu bulunamadi
</div>
)}
</div>
</div>
{/* Right side: Parts panel (40%) */}
<div className="w-[40%]">
<PartsPanel parts={parts} />
</div>
</div>
);
}

View File

@@ -1,447 +0,0 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { signIn } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Label } from "@sase/ui";
import { Separator } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@sase/ui";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@sase/ui";
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 "sonner";
export function SettingsContent() {
const { t } = useTranslation();
const { user, signOut } = useAuth();
// Profile state
const [name, setName] = useState(user?.name || "");
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
// Security state
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [changingPassword, setChangingPassword] = useState(false);
// Account deletion state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
// Referral stats
const { data: referralStats } = useQuery({
queryKey: ["referral-stats"],
queryFn: () => api.get<{ totalReferrals: number; rewardDays: number }>("/referrals/stats"),
retry: false,
});
// Google connection status
const { data: connections } = useQuery({
queryKey: ["connections"],
queryFn: () => api.get<{ google: boolean }>("/users/me/connections"),
retry: false,
});
useEffect(() => {
if (user?.name) setName(user.name);
}, [user?.name]);
async function handleUpdateProfile(e: React.FormEvent) {
e.preventDefault();
setSavingProfile(true);
try {
await api.patch("/users/me", { name, phone });
toast.success(t("settings.profile.updated"));
} catch {
toast.error(t("settings.profile.updateFailed"));
} finally {
setSavingProfile(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error(t("auth.passwordsDoNotMatch"));
return;
}
setChangingPassword(true);
try {
await api.post("/users/me/change-password", {
currentPassword,
newPassword,
});
toast.success(t("auth.passwordChanged"));
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
toast.error(t("auth.passwordChangeFailed"));
} finally {
setChangingPassword(false);
}
}
async function handleLinkGoogle() {
try {
await signIn.social({ provider: "google" });
toast.success(t("settings.connections.linkSuccess"));
} catch {
toast.error(t("settings.connections.linkFailed"));
}
}
async function handleUnlinkGoogle() {
try {
await api.delete("/users/me/connections/google");
toast.success(t("settings.connections.unlinkSuccess"));
} catch {
toast.error(t("errors.generic"));
}
}
async function handleDeleteAccount() {
if (deleteConfirmText !== t("settings.account.confirmWord")) return;
setDeleting(true);
try {
await api.delete("/users/me");
toast.success(t("settings.account.deleted"));
signOut();
} catch {
toast.error(t("settings.account.deleteFailed"));
} finally {
setDeleting(false);
}
}
function copyReferralCode() {
if (user?.referralCode) {
navigator.clipboard.writeText(user.referralCode);
toast.success(t("settings.referral.codeCopied"));
}
}
function copyShareLink() {
if (user?.referralCode) {
const link = `${window.location.origin}/register?ref=${user.referralCode}`;
navigator.clipboard.writeText(link);
toast.success(t("settings.referral.linkCopied"));
}
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<h2 className="text-2xl font-bold">{t("settings.title")}</h2>
<Tabs defaultValue="profile">
<TabsList className="w-full flex-wrap">
<TabsTrigger value="profile" className="gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.profile")}</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.security")}</span>
</TabsTrigger>
<TabsTrigger value="connections" className="gap-2">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.connections")}</span>
</TabsTrigger>
<TabsTrigger value="referral" className="gap-2">
<Gift className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.referral")}</span>
</TabsTrigger>
<TabsTrigger value="account" className="gap-2">
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("settings.tabs.account")}</span>
</TabsTrigger>
</TabsList>
{/* Profile Tab */}
<TabsContent value="profile">
<Card>
<CardHeader>
<CardTitle>{t("settings.profile.title")}</CardTitle>
<CardDescription>{t("settings.profile.description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("settings.profile.name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("settings.profile.email")}</Label>
<Input value={user?.email || ""} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t("settings.profile.phone")}</Label>
<Input
id="phone"
type="tel"
placeholder={t("settings.profile.phonePlaceholder")}
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</div>
<Button type="submit" disabled={savingProfile}>
{savingProfile ? t("common.saving") : t("common.save")}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
{/* Security Tab */}
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>{t("settings.security.title")}</CardTitle>
<CardDescription>{t("settings.security.description")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">{t("settings.security.currentPassword")}</Label>
<Input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">{t("settings.security.newPassword")}</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("settings.security.confirmPassword")}</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
/>
</div>
<Button type="submit" disabled={changingPassword}>
{changingPassword
? t("settings.security.changing")
: t("settings.security.changePassword")}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
{/* Connections Tab */}
<TabsContent value="connections">
<Card>
<CardHeader>
<CardTitle>{t("settings.connections.title")}</CardTitle>
<CardDescription>{t("settings.connections.description")}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<svg className="h-5 w-5" viewBox="0 0 24 24" role="img" aria-label="Google">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
</div>
<div>
<p className="font-medium">{t("settings.connections.google")}</p>
<Badge variant={connections?.google ? "default" : "secondary"}>
{connections?.google
? t("settings.connections.linked")
: t("settings.connections.notLinked")}
</Badge>
</div>
</div>
<div>
{connections?.google ? (
<Button variant="outline" size="sm" onClick={handleUnlinkGoogle}>
{t("settings.connections.unlink")}
</Button>
) : (
<Button size="sm" onClick={handleLinkGoogle}>
{t("settings.connections.link")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Referral Tab */}
<TabsContent value="referral">
<Card>
<CardHeader>
<CardTitle>{t("settings.referral.title")}</CardTitle>
<CardDescription>{t("settings.referral.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{user?.referralCode ? (
<>
{/* Referral Code */}
<div className="space-y-2">
<Label>{t("settings.referral.yourCode")}</Label>
<div className="flex items-center gap-3">
<Input value={user.referralCode} readOnly className="font-mono text-lg" />
<Button variant="outline" size="icon" onClick={copyReferralCode}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
{/* Share Link */}
<div className="space-y-2">
<Label>{t("settings.referral.shareLink")}</Label>
<div className="flex items-center gap-3">
<Input
value={`${typeof window !== "undefined" ? window.location.origin : ""}/register?ref=${user.referralCode}`}
readOnly
className="text-sm"
/>
<Button variant="outline" size="icon" onClick={copyShareLink}>
<Share2 className="h-4 w-4" />
</Button>
</div>
</div>
<Separator />
{/* Referral Stats */}
<div>
<h4 className="mb-3 font-medium">{t("settings.referral.stats")}</h4>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent className="py-4 text-center">
<p className="text-3xl font-bold">{referralStats?.totalReferrals ?? 0}</p>
<p className="text-sm text-muted-foreground">
{t("settings.referral.totalReferrals")}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4 text-center">
<p className="text-3xl font-bold">{referralStats?.rewardDays ?? 0}</p>
<p className="text-sm text-muted-foreground">
{t("settings.referral.rewardDays")}
</p>
</CardContent>
</Card>
</div>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{t("settings.referral.noCode")}</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Account Tab */}
<TabsContent value="account">
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
{t("settings.account.title")}
</CardTitle>
<CardDescription>{t("settings.account.description")}</CardDescription>
</CardHeader>
<CardContent>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
{t("settings.account.deleteAccount")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("settings.account.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("settings.account.deleteConfirmDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>{t("settings.account.typeConfirm")}</Label>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder={t("settings.account.confirmWord")}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setDeleteConfirmText("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== t("settings.account.confirmWord") || deleting}
>
{deleting
? t("settings.account.deleting")
: t("settings.account.deleteAccount")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,141 +0,0 @@
"use client";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { useEffect, useState } from "react";
interface Brand {
id: string;
name: string;
slug: string;
logoUrl?: string;
}
interface BrandSelectorProps {
maxBrands: number;
selectedBrandIds: string[];
onSelectionChange: (brandIds: string[]) => void;
isFullPlan?: boolean;
}
export function BrandSelector({
maxBrands,
selectedBrandIds,
onSelectionChange,
isFullPlan = false,
}: BrandSelectorProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string[]>(selectedBrandIds);
const { data: brands, isLoading } = useQuery({
queryKey: ["brands"],
queryFn: () => api.get<Brand[]>("/brands"),
});
useEffect(() => {
setSelected(selectedBrandIds);
}, [selectedBrandIds]);
useEffect(() => {
if (isFullPlan && brands) {
const allIds = brands.map((b) => b.id);
setSelected(allIds);
onSelectionChange(allIds);
}
}, [isFullPlan, brands, onSelectionChange]);
function toggleBrand(brandId: string) {
if (isFullPlan) return;
let next: string[];
if (selected.includes(brandId)) {
next = selected.filter((id) => id !== brandId);
} else {
if (selected.length >= maxBrands) return;
next = [...selected, brandId];
}
setSelected(next);
onSelectionChange(next);
}
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"].map((id) => (
<Skeleton key={id} className="h-24 w-full rounded-lg" />
))}
</div>
);
}
if (!brands || brands.length === 0) {
return <p className="text-sm text-muted-foreground">{t("common.noData")}</p>;
}
const isMaxReached = !isFullPlan && selected.length >= maxBrands;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t("subscription.selectBrands")}</p>
<Badge variant={isFullPlan ? "default" : "secondary"}>
{isFullPlan
? t("subscription.allBrandsSelected")
: `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
</Badge>
</div>
{isMaxReached && (
<p className="text-xs text-amber-600">{t("subscription.maxBrandsReached")}</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{brands.map((brand) => {
const isSelected = selected.includes(brand.id) || isFullPlan;
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
return (
<Card
key={brand.id}
className={cn(
"cursor-pointer transition-all hover:shadow-md",
isSelected && "border-primary ring-2 ring-primary/20",
isDisabled && "cursor-not-allowed opacity-50",
isFullPlan && "cursor-default",
)}
onClick={() => !isDisabled && toggleBrand(brand.id)}
>
<CardContent className="flex flex-col items-center justify-center p-4">
<div className="relative">
{brand.logoUrl ? (
<img
src={brand.logoUrl}
alt={brand.name}
className="mb-2 h-12 w-12 object-contain"
/>
) : (
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-lg font-bold text-muted-foreground">
{brand.name.charAt(0)}
</div>
)}
{isSelected && (
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
<span className="text-center text-sm font-medium">{brand.name}</span>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
interface VehicleCardProps {
id: string;
vin: string;
brandName: string;
model: string;
year?: number | string | null;
href?: string;
}
export function VehicleCard({ id, vin, brandName, model, year, href }: VehicleCardProps) {
const linkHref = href || `/dashboard/vehicles/${id}`;
return (
<Link href={linkHref}>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{brandName} {model}
</CardTitle>
{year && <Badge variant="secondary">{year}</Badge>}
</div>
</CardHeader>
<CardContent>
<p className="font-mono text-sm text-muted-foreground">{vin}</p>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -1,55 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Search } from "lucide-react";
import { isValidVin } from "@sase/shared";
interface VinInputProps {
onSubmit: (vin: string) => void;
loading?: boolean;
error?: string | null;
}
export function VinInput({ onSubmit, loading, error }: VinInputProps) {
const [vin, setVin] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin)) return;
onSubmit(cleanVin);
}
const vinUpper = vin.toUpperCase().trim();
const isInvalid = vinUpper.length === 17 && !isValidVin(vinUpper);
return (
<div>
<form onSubmit={handleSubmit} className="flex gap-3">
<Input
placeholder="VIN numarasini girin (17 karakter)"
value={vin}
onChange={(e) => setVin(e.target.value.toUpperCase())}
maxLength={17}
className="font-mono text-lg tracking-wider"
/>
<Button type="submit" disabled={loading || vin.length !== 17 || isInvalid}>
{loading ? (
<span className="animate-spin">...</span>
) : (
<Search className="h-4 w-4" />
)}
Ara
</Button>
</form>
{isInvalid && (
<p className="mt-2 text-sm text-destructive">
Gecersiz VIN. 17 karakter olmali, I, O, Q harfleri kullanilamaz.
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
);
}

View File

@@ -1,29 +0,0 @@
"use client";
import { useSession, signIn, signUp, signOut } from "@/lib/auth-client";
import { useAuthStore } from "@/stores/auth.store";
import { useEffect } from "react";
export function useAuth() {
const session = useSession();
const { user, setUser, isLoading } = useAuthStore();
useEffect(() => {
if (session.data?.user) {
setUser(session.data.user as any);
} else if (!session.isPending) {
setUser(null);
}
}, [session.data, session.isPending, setUser]);
return {
user,
isLoading: session.isPending || isLoading,
isAuthenticated: !!user,
isAdmin: user?.role === "admin",
signIn,
signUp,
signOut,
session: session.data,
};
}

View File

@@ -1,62 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
export interface Part {
id: string;
name: string;
oemCode: string;
quantity: number;
position: string;
index: number;
price?: number;
note?: string;
}
export interface Hotspot {
id: string;
partId: string;
shape: "circle" | "polygon" | "rect";
coordinates: number[];
label: string;
}
export interface SchemaPic {
id: string;
url: string;
width: number;
height: number;
label: string;
}
export interface CategorySchema {
id: string;
name: string;
description: string;
parts: Part[];
schemaPics: SchemaPic[];
hotspots: Hotspot[];
}
export function useCategoryParts(vehicleId: string, categoryId: string) {
return useQuery<CategorySchema>({
queryKey: ["category-parts", vehicleId, categoryId],
queryFn: () =>
api.get<CategorySchema>(
`/vehicles/${vehicleId}/categories/${categoryId}`,
),
enabled: !!vehicleId && !!categoryId,
});
}
export function useSchemaPics(vehicleId: string, categoryId: string) {
return useQuery<SchemaPic[]>({
queryKey: ["schema-pics", vehicleId, categoryId],
queryFn: () =>
api.get<SchemaPic[]>(
`/vehicles/${vehicleId}/categories/${categoryId}/schema-pics`,
),
enabled: !!vehicleId && !!categoryId,
});
}

View File

@@ -1,120 +0,0 @@
"use client";
import { useCallback, useRef } from "react";
import { useSchemaStore } from "@/stores/schema.store";
function getDistance(t1: React.Touch, t2: React.Touch): number {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
export function useSchemaInteraction() {
const { zoom, panX, panY, setZoom, setPan } = useSchemaStore();
const isDragging = useRef(false);
const lastMousePos = useRef({ x: 0, y: 0 });
const lastTouchDistance = useRef<number | null>(null);
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);
}
},
[zoom, setZoom],
);
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
isDragging.current = true;
lastMousePos.current = { x: e.clientX, y: e.clientY };
},
[],
);
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging.current) return;
const dx = e.clientX - lastMousePos.current.x;
const dy = e.clientY - lastMousePos.current.y;
lastMousePos.current = { x: e.clientX, y: e.clientY };
setPan(panX + dx, panY + dy);
},
[panX, panY, setPan],
);
const onMouseUp = useCallback(() => {
isDragging.current = false;
}, []);
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 2) {
const dist = getDistance(e.touches[0], e.touches[1]);
lastTouchDistance.current = dist;
lastTouchCenter.current = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
} else if (e.touches.length === 1) {
isDragging.current = true;
lastMousePos.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
}
},
[],
);
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 2 && lastTouchDistance.current !== null) {
e.preventDefault();
const dist = getDistance(e.touches[0], e.touches[1]);
const scale = dist / lastTouchDistance.current;
setZoom(zoom * scale);
lastTouchDistance.current = dist;
const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
if (lastTouchCenter.current) {
const dx = centerX - lastTouchCenter.current.x;
const dy = centerY - lastTouchCenter.current.y;
setPan(panX + dx, panY + dy);
}
lastTouchCenter.current = { x: centerX, y: centerY };
} else if (e.touches.length === 1 && isDragging.current) {
const dx = e.touches[0].clientX - lastMousePos.current.x;
const dy = e.touches[0].clientY - lastMousePos.current.y;
lastMousePos.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
setPan(panX + dx, panY + dy);
}
},
[zoom, panX, panY, setZoom, setPan],
);
const onTouchEnd = useCallback(() => {
isDragging.current = false;
lastTouchDistance.current = null;
lastTouchCenter.current = null;
}, []);
return {
onWheel,
onMouseDown,
onMouseMove,
onMouseUp,
onTouchStart,
onTouchMove,
onTouchEnd,
};
}

View File

@@ -1,91 +0,0 @@
const API_URL = "/api";
type RequestOptions = {
method?: string;
body?: unknown;
headers?: Record<string, string>;
};
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, headers = {} } = options;
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
throw new ApiError(
data?.error?.message || "Request failed",
data?.error?.code || "UNKNOWN",
res.status,
);
}
return data.data !== undefined ? data.data : data;
}
get<T>(path: string) {
return this.request<T>(path);
}
post<T>(path: string, body?: unknown) {
return this.request<T>(path, { method: "POST", body });
}
patch<T>(path: string, body?: unknown) {
return this.request<T>(path, { method: "PATCH", body });
}
delete<T>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
async upload<T>(path: string, formData: FormData): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
body: formData,
credentials: "include",
});
const data = await res.json();
if (!res.ok) {
throw new ApiError(
data?.error?.message || "Upload failed",
data?.error?.code || "UNKNOWN",
res.status,
);
}
return data.data !== undefined ? data.data : data;
}
}
export class ApiError extends Error {
code: string;
status: number;
constructor(message: string, code: string, status: number) {
super(message);
this.code = code;
this.status = status;
this.name = "ApiError";
}
}
export const api = new ApiClient(API_URL);

View File

@@ -1,13 +0,0 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"),
basePath: "/api/auth",
});
export const {
signIn,
signUp,
signOut,
useSession,
} = authClient;

View File

@@ -1,95 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { t, useI18nStore } from "./i18n";
describe("i18n", () => {
beforeEach(() => {
// Reset to default Turkish locale before each test
useI18nStore.setState({ locale: "tr" });
});
describe("t() with Turkish locale", () => {
it("should return Turkish text for known top-level keys", () => {
expect(t("common.save")).toBe("Kaydet");
});
it("should return Turkish text for nested keys", () => {
expect(t("auth.login")).toBe("Giri\u015f Yap");
});
it("should return Turkish text for deeply nested keys", () => {
expect(t("subscription.features.vinSearch")).toBe(
"S\u0131n\u0131rs\u0131z VIN arama",
);
});
it("should return Turkish text for nav keys", () => {
expect(t("nav.search")).toBe("Arama");
expect(t("nav.settings")).toBe("Ayarlar");
});
it("should return Turkish text for error messages", () => {
expect(t("errors.generic")).toBe(
"Bir hata olu\u015ftu. L\u00fctfen tekrar deneyin.",
);
});
});
describe("nested key resolution", () => {
it("should resolve two-level nesting", () => {
expect(t("common.cancel")).toBe("\u0130ptal");
});
it("should resolve three-level nesting", () => {
expect(t("subscription.plans.full.name")).toBe("Full Paket");
});
it("should resolve settings tabs", () => {
expect(t("settings.tabs.profile")).toBe("Profil");
expect(t("settings.tabs.security")).toBe("G\u00fcvenlik");
});
});
describe("unknown key returns key itself", () => {
it("should return the key string for completely unknown key", () => {
expect(t("nonexistent.key")).toBe("nonexistent.key");
});
it("should return the key string for partially valid path", () => {
expect(t("common.nonexistent")).toBe("common.nonexistent");
});
it("should return the key string for deeply nested unknown key", () => {
expect(t("a.b.c.d.e")).toBe("a.b.c.d.e");
});
});
describe("locale switch", () => {
it("should return English text after switching to en", () => {
useI18nStore.getState().setLocale("en");
expect(t("common.save")).toBe("Save");
expect(t("auth.login")).toBe("Log In");
});
it("should return Turkish text after switching back to tr", () => {
useI18nStore.getState().setLocale("en");
expect(t("common.save")).toBe("Save");
useI18nStore.getState().setLocale("tr");
expect(t("common.save")).toBe("Kaydet");
});
it("should handle switching locale and reading nested keys", () => {
useI18nStore.getState().setLocale("en");
expect(t("subscription.plans.full.name")).toBe("Full Package");
useI18nStore.getState().setLocale("tr");
expect(t("subscription.plans.full.name")).toBe("Full Paket");
});
it("should still return key for unknown keys after locale switch", () => {
useI18nStore.getState().setLocale("en");
expect(t("does.not.exist")).toBe("does.not.exist");
});
});
});

View File

@@ -1,69 +0,0 @@
import enMessages from "@/messages/en.json";
import trMessages from "@/messages/tr.json";
import { create } from "zustand";
export type Locale = "tr" | "en";
const messages: Record<Locale, Record<string, unknown>> = {
tr: trMessages,
en: enMessages,
};
interface I18nState {
locale: Locale;
setLocale: (locale: Locale) => void;
}
export const useI18nStore = create<I18nState>((set) => ({
locale: "tr",
setLocale: (locale) => {
set({ locale });
if (typeof window !== "undefined") {
localStorage.setItem("sase-locale", locale);
document.documentElement.lang = locale;
}
},
}));
export function initLocale(): void {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("sase-locale") as Locale | null;
if (saved && (saved === "tr" || saved === "en")) {
useI18nStore.getState().setLocale(saved);
}
}
}
function getNestedValue(obj: unknown, path: string): string {
const keys = path.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined || typeof current !== "object") {
return path;
}
current = (current as Record<string, unknown>)[key];
}
if (typeof current === "string") {
return current;
}
return path;
}
export function t(key: string): string {
const locale = useI18nStore.getState().locale;
return getNestedValue(messages[locale], key);
}
export function useTranslation() {
const locale = useI18nStore((state) => state.locale);
const setLocale = useI18nStore((state) => state.setLocale);
const translate = (key: string): string => {
return getNestedValue(messages[locale], key);
};
return { t: translate, locale, setLocale };
}

View File

@@ -1,25 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient() {
if (typeof window === "undefined") {
return makeQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,267 +0,0 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"copy": "Copy",
"copied": "Copied!",
"search": "Search",
"filter": "Filter",
"all": "All",
"noData": "No data found.",
"yes": "Yes",
"no": "No",
"or": "or",
"monthly": "Monthly",
"yearly": "Yearly",
"trySymbol": "₺",
"perMonth": "/mo",
"perYear": "/yr"
},
"nav": {
"search": "Search",
"history": "History",
"subscription": "Subscription",
"billing": "Billing",
"settings": "Settings",
"admin": "Admin Panel",
"logout": "Log Out"
},
"auth": {
"login": "Log In",
"register": "Sign Up",
"loginTitle": "Log In",
"loginDescription": "Sign in to your Sase.tr account",
"registerTitle": "Sign Up",
"registerDescription": "Create a new Sase.tr account",
"email": "Email",
"password": "Password",
"name": "Full Name",
"forgotPassword": "Forgot Password",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"loginWithGoogle": "Log in with Google",
"loggingIn": "Logging in...",
"registering": "Registering...",
"loginFailed": "Login failed. Invalid email or password.",
"registerFailed": "Registration failed. Please try again.",
"resetPassword": "Reset Password",
"resetPasswordDescription": "We'll send a password reset link to your email.",
"sendResetLink": "Send Reset Link",
"resetLinkSent": "Password reset link sent to your email.",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"currentPassword": "Current Password",
"passwordChanged": "Password changed successfully.",
"passwordChangeFailed": "Password change failed.",
"passwordsDoNotMatch": "Passwords do not match."
},
"subscription": {
"title": "Subscription",
"noSubscription": "You don't have an active subscription.",
"choosePlan": "Choose Plan",
"subscribe": "Subscribe",
"currentPlan": "Current Plan",
"cancelSubscription": "Cancel Subscription",
"cancelling": "Cancelling...",
"cancelConfirmTitle": "Cancel Subscription",
"cancelConfirmDescription": "Are you sure you want to cancel your subscription? You'll retain access until the end of the current period.",
"cancelled": "Subscription cancelled. It will end at the end of the current period.",
"resumeSubscription": "Resume Subscription",
"resuming": "Resuming...",
"resumed": "Subscription resumed.",
"accessibleBrands": "Accessible Brands",
"endDate": "End Date",
"startDate": "Start Date",
"status": "Status",
"billingPeriod": "Billing Period",
"planComparison": "Plan Comparison",
"selectBrands": "Select Brands",
"selectBrandsDescription": "Choose the brands you want to include in your plan.",
"brandsSelected": "brands selected",
"allBrandsSelected": "All selected",
"maxBrandsReached": "Maximum number of brands reached.",
"features": {
"vinSearch": "Unlimited VIN search",
"partsCatalog": "Parts catalog",
"schemaViewer": "Schema viewer",
"prioritySupport": "Priority support",
"oemSearch": "OEM parts search",
"allBrands": "All brands"
},
"plans": {
"brand1": {
"name": "1 Brand",
"description": "Spare parts access for a single brand"
},
"brand2": {
"name": "2 Brands",
"description": "Access for two different brands"
},
"brand3": {
"name": "3 Brands",
"description": "Comprehensive access for three brands"
},
"full": {
"name": "Full Package",
"description": "Unlimited access to all brands"
}
},
"statusLabels": {
"active": "Active",
"pending": "Pending",
"cancelled": "Cancelled",
"expired": "Expired"
},
"popular": "Popular"
},
"payment": {
"title": "Payment",
"summary": "Summary",
"selectedPlan": "Selected Plan",
"selectedBrands": "Selected Brands",
"totalAmount": "Total Amount",
"paymentMethod": "Payment Method",
"creditCard": "Credit Card (iyzico)",
"eftTransfer": "EFT/Wire Transfer",
"payWithCard": "Pay with Card",
"paying": "Processing payment...",
"bankDetails": "Bank Details",
"bankName": "Bank Name",
"accountHolder": "Account Holder",
"iban": "IBAN",
"description": "Description",
"paymentDescription": "Sase.tr Subscription Payment",
"uploadReceipt": "Upload Receipt",
"uploadReceiptDescription": "Upload your receipt after EFT/Wire transfer.",
"dragDrop": "Drag and drop a file or click to browse",
"supportedFormats": "PNG, JPG, or PDF (max 5MB)",
"uploading": "Uploading...",
"receiptUploaded": "Receipt uploaded successfully. Waiting for approval.",
"uploadFailed": "Receipt upload failed.",
"paymentStatus": "Payment Status",
"waitingApproval": "Waiting for Approval",
"approved": "Approved",
"step1": "Plan & Brands",
"step2": "Payment",
"step3": "Confirmation",
"confirmation": "Payment received!",
"confirmationDescription": "Your subscription has been activated successfully.",
"eftConfirmationDescription": "Your EFT/Wire receipt has been received. Your subscription will be activated after approval.",
"goToDashboard": "Go to Dashboard",
"initializeFailed": "Payment initialization failed. Please try again.",
"processingPayment": "Processing payment..."
},
"billing": {
"title": "Billing History",
"payments": "Payments",
"noPayments": "No payment records yet.",
"date": "Date",
"plan": "Plan",
"amount": "Amount",
"method": "Method",
"status": "Status",
"downloadReceipt": "Download Receipt",
"filterByStatus": "Filter by Status",
"filterByMethod": "Filter by Method",
"statusLabels": {
"completed": "Completed",
"pending": "Pending",
"failed": "Failed",
"refunded": "Refunded"
},
"methodLabels": {
"iyzico": "Credit Card",
"eft": "EFT/Wire"
}
},
"settings": {
"title": "Settings",
"tabs": {
"profile": "Profile",
"security": "Security",
"connections": "Connections",
"referral": "Referral",
"account": "Account"
},
"profile": {
"title": "Profile Information",
"description": "Update your personal information.",
"name": "Full Name",
"email": "Email",
"phone": "Phone",
"phonePlaceholder": "+90 5xx xxx xx xx",
"updated": "Profile updated.",
"updateFailed": "Failed to update profile."
},
"security": {
"title": "Change Password",
"description": "Change your password regularly for account security.",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "New Password (Confirm)",
"changePassword": "Change Password",
"changing": "Changing..."
},
"connections": {
"title": "Connected Accounts",
"description": "Manage your third-party account connections.",
"google": "Google Account",
"linked": "Linked",
"notLinked": "Not Linked",
"link": "Link",
"unlink": "Unlink",
"linkSuccess": "Google account linked successfully.",
"unlinkSuccess": "Google account unlinked.",
"linkFailed": "Account linking failed."
},
"referral": {
"title": "Referral Code",
"description": "Invite your friends and earn rewards.",
"yourCode": "Your Referral Code",
"noCode": "Your referral code has not been generated yet.",
"shareLink": "Invite Link",
"totalReferrals": "Total Referrals",
"rewardDays": "Days Earned",
"stats": "Referral Statistics",
"codeCopied": "Code copied!",
"linkCopied": "Link copied!"
},
"account": {
"title": "Account Management",
"description": "Permanently delete your account.",
"deleteAccount": "Delete Account",
"deleteConfirmTitle": "Are You Sure You Want to Delete Your Account?",
"deleteConfirmDescription": "This action cannot be undone. All your data, subscription, and history will be permanently deleted.",
"deleting": "Deleting...",
"deleted": "Your account has been deleted.",
"deleteFailed": "Account deletion failed.",
"typeConfirm": "Type 'DELETE' to confirm",
"confirmWord": "DELETE"
}
},
"errors": {
"generic": "An error occurred. Please try again.",
"network": "Connection error. Check your internet connection.",
"unauthorized": "Your session has expired. Please log in again.",
"forbidden": "You don't have permission for this action.",
"notFound": "The page you're looking for was not found.",
"validation": "Please fill in all fields correctly.",
"fileTooBig": "File size too large. Maximum 5MB allowed.",
"invalidFileType": "Invalid file type."
},
"language": {
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Switch Language"
}
}

View File

@@ -1,267 +0,0 @@
{
"common": {
"save": "Kaydet",
"saving": "Kaydediliyor...",
"cancel": "İptal",
"confirm": "Onayla",
"delete": "Sil",
"edit": "Düzenle",
"close": "Kapat",
"back": "Geri",
"next": "İleri",
"loading": "Yükleniyor...",
"error": "Hata",
"success": "Başarılı",
"copy": "Kopyala",
"copied": "Kopyalandı!",
"search": "Ara",
"filter": "Filtrele",
"all": "Tümü",
"noData": "Veri bulunamadı.",
"yes": "Evet",
"no": "Hayır",
"or": "veya",
"monthly": "Aylık",
"yearly": "Yıllık",
"trySymbol": "₺",
"perMonth": "/ay",
"perYear": "/yıl"
},
"nav": {
"search": "Arama",
"history": "Geçmiş",
"subscription": "Abonelik",
"billing": "Fatura",
"settings": "Ayarlar",
"admin": "Admin Panel",
"logout": ıkış Yap"
},
"auth": {
"login": "Giriş Yap",
"register": "Kayıt Ol",
"loginTitle": "Giriş Yap",
"loginDescription": "Sase.tr hesabınıza giriş yapın",
"registerTitle": "Kayıt Ol",
"registerDescription": "Yeni bir Sase.tr hesabı oluşturun",
"email": "E-posta",
"password": "Şifre",
"name": "Ad Soyad",
"forgotPassword": "Şifremi Unuttum",
"noAccount": "Hesabınız yok mu?",
"hasAccount": "Zaten hesabınız var mı?",
"loginWithGoogle": "Google ile Giriş Yap",
"loggingIn": "Giriş yapılıyor...",
"registering": "Kayıt yapılıyor...",
"loginFailed": "Giriş başarısız. E-posta veya şifre hatalı.",
"registerFailed": "Kayıt başarısız. Lütfen tekrar deneyin.",
"resetPassword": "Şifre Sıfırla",
"resetPasswordDescription": "E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz.",
"sendResetLink": "Sıfırlama Bağlantısı Gönder",
"resetLinkSent": "Şifre sıfırlama bağlantısı e-posta adresinize gönderildi.",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifre Tekrar",
"currentPassword": "Mevcut Şifre",
"passwordChanged": "Şifre başarıyla değiştirildi.",
"passwordChangeFailed": "Şifre değiştirme başarısız.",
"passwordsDoNotMatch": "Şifreler eşleşmiyor."
},
"subscription": {
"title": "Abonelik",
"noSubscription": "Aktif aboneliğiniz yok.",
"choosePlan": "Plan Seç",
"subscribe": "Abone Ol",
"currentPlan": "Mevcut Plan",
"cancelSubscription": "Aboneliği İptal Et",
"cancelling": "İptal ediliyor...",
"cancelConfirmTitle": "Aboneliği İptal Et",
"cancelConfirmDescription": "Aboneliğinizi iptal etmek istediğinizden emin misiniz? Dönem sonuna kadar erişiminiz devam edecek.",
"cancelled": "Abonelik iptal edildi. Dönem sonunda sona erecek.",
"resumeSubscription": "Aboneliği Devam Ettir",
"resuming": "Devam ettiriliyor...",
"resumed": "Abonelik devam ettirildi.",
"accessibleBrands": "Erişim Sağlanan Markalar",
"endDate": "Bitiş Tarihi",
"startDate": "Başlangıç Tarihi",
"status": "Durum",
"billingPeriod": "Fatura Dönemi",
"planComparison": "Plan Karşılaştırması",
"selectBrands": "Marka Seçin",
"selectBrandsDescription": "Planınıza dahil etmek istediğiniz markaları seçin.",
"brandsSelected": "marka seçildi",
"allBrandsSelected": "Tümü seçildi",
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
"features": {
"vinSearch": "Sınırsız VIN arama",
"partsCatalog": "Parça kataloğu",
"schemaViewer": "Şema görüntüleyici",
"prioritySupport": "Öncelikli destek",
"oemSearch": "OEM parça arama",
"allBrands": "Tüm markalar"
},
"plans": {
"brand1": {
"name": "1 Marka",
"description": "Tek marka için yedek parça erişimi"
},
"brand2": {
"name": "2 Marka",
"description": "İki farklı marka için erişim"
},
"brand3": {
"name": "3 Marka",
"description": "Üç marka için kapsamlı erişim"
},
"full": {
"name": "Full Paket",
"description": "Tüm markalara sınırsız erişim"
}
},
"statusLabels": {
"active": "Aktif",
"pending": "Bekliyor",
"cancelled": "İptal Edildi",
"expired": "Süresi Doldu"
},
"popular": "Popüler"
},
"payment": {
"title": "Ödeme",
"summary": "Özet",
"selectedPlan": "Seçilen Plan",
"selectedBrands": "Seçilen Markalar",
"totalAmount": "Toplam Tutar",
"paymentMethod": "Ödeme Yöntemi",
"creditCard": "Kredi Kartı (iyzico)",
"eftTransfer": "EFT/Havale",
"payWithCard": "Kartla Öde",
"paying": "Ödeme yapılıyor...",
"bankDetails": "Banka Bilgileri",
"bankName": "Banka Adı",
"accountHolder": "Hesap Sahibi",
"iban": "IBAN",
"description": "Açıklama",
"paymentDescription": "Sase.tr Abonelik Ödemesi",
"uploadReceipt": "Dekont Yükle",
"uploadReceiptDescription": "EFT/Havale sonrası dekontunuzu yükleyin.",
"dragDrop": "Dosyayı sürükleyip bırakın veya tıklayın",
"supportedFormats": "PNG, JPG veya PDF (maks. 5MB)",
"uploading": "Yükleniyor...",
"receiptUploaded": "Dekont başarıyla yüklendi. Onay bekleniyor.",
"uploadFailed": "Dekont yükleme başarısız.",
"paymentStatus": "Ödeme Durumu",
"waitingApproval": "Onay Bekleniyor",
"approved": "Onaylandı",
"step1": "Plan ve Markalar",
"step2": "Ödeme",
"step3": "Onay",
"confirmation": "Ödemeniz alındı!",
"confirmationDescription": "Aboneliğiniz başarıyla aktifleştirildi.",
"eftConfirmationDescription": "EFT/Havale dekontunuz alındı. Onay sonrası aboneliğiniz aktifleştirilecektir.",
"goToDashboard": "Panele Git",
"initializeFailed": "Ödeme başlatılamadı. Lütfen tekrar deneyin.",
"processingPayment": "Ödeme işleniyor..."
},
"billing": {
"title": "Fatura Geçmişi",
"payments": "Ödemeler",
"noPayments": "Henüz ödeme kaydı yok.",
"date": "Tarih",
"plan": "Plan",
"amount": "Tutar",
"method": "Yöntem",
"status": "Durum",
"downloadReceipt": "Dekontu İndir",
"filterByStatus": "Duruma Göre Filtrele",
"filterByMethod": "Yönteme Göre Filtrele",
"statusLabels": {
"completed": "Tamamlandı",
"pending": "Bekliyor",
"failed": "Başarısız",
"refunded": "İade"
},
"methodLabels": {
"iyzico": "Kredi Kartı",
"eft": "EFT/Havale"
}
},
"settings": {
"title": "Ayarlar",
"tabs": {
"profile": "Profil",
"security": "Güvenlik",
"connections": "Bağlantılar",
"referral": "Referans",
"account": "Hesap"
},
"profile": {
"title": "Profil Bilgileri",
"description": "Kişisel bilgilerinizi güncelleyin.",
"name": "Ad Soyad",
"email": "E-posta",
"phone": "Telefon",
"phonePlaceholder": "+90 5xx xxx xx xx",
"updated": "Profil güncellendi.",
"updateFailed": "Profil güncellenirken hata oluştu."
},
"security": {
"title": "Şifre Değiştir",
"description": "Hesap güvenliğiniz için şifrenizi düzenli olarak değiştirin.",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Yeni Şifre (Tekrar)",
"changePassword": "Şifre Değiştir",
"changing": "Değiştiriliyor..."
},
"connections": {
"title": "Bağlı Hesaplar",
"description": "Üçüncü parti hesap bağlantılarınızı yönetin.",
"google": "Google Hesabı",
"linked": "Bağlı",
"notLinked": "Bağlı Değil",
"link": "Bağla",
"unlink": "Bağlantıyı Kaldır",
"linkSuccess": "Google hesabı başarıyla bağlandı.",
"unlinkSuccess": "Google hesabı bağlantısı kaldırıldı.",
"linkFailed": "Hesap bağlama başarısız."
},
"referral": {
"title": "Referans Kodu",
"description": "Arkadaşlarınızı davet edin ve ödüller kazanın.",
"yourCode": "Referans Kodunuz",
"noCode": "Referans kodunuz henüz oluşturulmamış.",
"shareLink": "Davet Bağlantısı",
"totalReferrals": "Toplam Davetler",
"rewardDays": "Kazanılan Gün",
"stats": "Referans İstatistikleri",
"codeCopied": "Kod kopyalandı!",
"linkCopied": "Bağlantı kopyalandı!"
},
"account": {
"title": "Hesap Yönetimi",
"description": "Hesabınızı kalıcı olarak silin.",
"deleteAccount": "Hesabı Sil",
"deleteConfirmTitle": "Hesabınızı Silmek İstediğinize Emin Misiniz?",
"deleteConfirmDescription": "Bu işlem geri alınamaz. Tüm verileriniz, aboneliğiniz ve geçmişiniz kalıcı olarak silinecektir.",
"deleting": "Siliniyor...",
"deleted": "Hesabınız silindi.",
"deleteFailed": "Hesap silme başarısız.",
"typeConfirm": "Onaylamak için 'SİL' yazın",
"confirmWord": "SİL"
}
},
"errors": {
"generic": "Bir hata oluştu. Lütfen tekrar deneyin.",
"network": "Bağlantı hatası. İnternet bağlantınızı kontrol edin.",
"unauthorized": "Oturumunuz sona erdi. Lütfen tekrar giriş yapın.",
"forbidden": "Bu işlem için yetkiniz yok.",
"notFound": "Aradığınız sayfa bulunamadı.",
"validation": "Lütfen tüm alanları doğru şekilde doldurun.",
"fileTooBig": "Dosya boyutu çok büyük. Maksimum 5MB yükleyebilirsiniz.",
"invalidFileType": "Geçersiz dosya türü."
},
"language": {
"tr": "Türkçe",
"en": "English",
"switchLanguage": "Dil Değiştir"
}
}

View File

@@ -1,16 +0,0 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { Toaster } from "sonner";
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster richColors position="top-right" />
</QueryClientProvider>
);
}

View File

@@ -1,28 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const publicPaths = ["/", "/pricing", "/login", "/register", "/forgot-password", "/reset-password"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (publicPaths.some((p) => pathname === p || pathname.startsWith("/api"))) {
return NextResponse.next();
}
// Check for auth session cookie
const sessionCookie =
request.cookies.get("better-auth.session_token") ||
request.cookies.get("__Secure-better-auth.session_token");
if (!sessionCookie && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

View File

@@ -1,24 +0,0 @@
import { create } from "zustand";
interface User {
id: string;
name: string;
email: string;
image: string | null;
role: string;
referralCode: string | null;
}
interface AuthState {
user: User | null;
isLoading: boolean;
setUser: (user: User | null) => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user, isLoading: false }),
setLoading: (isLoading) => set({ isLoading }),
}));

View File

@@ -1,141 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useSchemaStore } from "./schema.store";
describe("useSchemaStore", () => {
beforeEach(() => {
// Reset store to initial state before each test
useSchemaStore.setState({
highlightedPartId: null,
selectedPartId: null,
zoom: 1,
panX: 0,
panY: 0,
isFullscreen: false,
});
});
describe("setHighlightedPart", () => {
it("should set highlighted part id", () => {
useSchemaStore.getState().setHighlightedPart("part-123");
expect(useSchemaStore.getState().highlightedPartId).toBe("part-123");
});
it("should clear highlighted part when set to null", () => {
useSchemaStore.getState().setHighlightedPart("part-123");
useSchemaStore.getState().setHighlightedPart(null);
expect(useSchemaStore.getState().highlightedPartId).toBeNull();
});
});
describe("setSelectedPart", () => {
it("should set selected part id", () => {
useSchemaStore.getState().setSelectedPart("part-456");
expect(useSchemaStore.getState().selectedPartId).toBe("part-456");
});
it("should clear selected part when set to null", () => {
useSchemaStore.getState().setSelectedPart("part-456");
useSchemaStore.getState().setSelectedPart(null);
expect(useSchemaStore.getState().selectedPartId).toBeNull();
});
it("should not affect highlighted part", () => {
useSchemaStore.getState().setHighlightedPart("part-A");
useSchemaStore.getState().setSelectedPart("part-B");
expect(useSchemaStore.getState().highlightedPartId).toBe("part-A");
expect(useSchemaStore.getState().selectedPartId).toBe("part-B");
});
});
describe("setZoom", () => {
it("should set zoom to a valid value", () => {
useSchemaStore.getState().setZoom(2);
expect(useSchemaStore.getState().zoom).toBe(2);
});
it("should clamp zoom to minimum of 0.5", () => {
useSchemaStore.getState().setZoom(0.1);
expect(useSchemaStore.getState().zoom).toBe(0.5);
});
it("should clamp zoom to maximum of 5", () => {
useSchemaStore.getState().setZoom(10);
expect(useSchemaStore.getState().zoom).toBe(5);
});
it("should allow zoom at exactly 0.5", () => {
useSchemaStore.getState().setZoom(0.5);
expect(useSchemaStore.getState().zoom).toBe(0.5);
});
it("should allow zoom at exactly 5", () => {
useSchemaStore.getState().setZoom(5);
expect(useSchemaStore.getState().zoom).toBe(5);
});
it("should clamp negative zoom to 0.5", () => {
useSchemaStore.getState().setZoom(-1);
expect(useSchemaStore.getState().zoom).toBe(0.5);
});
});
describe("resetView", () => {
it("should reset zoom to 1 and pan to 0,0", () => {
useSchemaStore.getState().setZoom(3);
useSchemaStore.getState().setPan(100, 200);
useSchemaStore.getState().resetView();
const state = useSchemaStore.getState();
expect(state.zoom).toBe(1);
expect(state.panX).toBe(0);
expect(state.panY).toBe(0);
});
it("should not affect highlighted or selected part", () => {
useSchemaStore.getState().setHighlightedPart("part-A");
useSchemaStore.getState().setSelectedPart("part-B");
useSchemaStore.getState().setZoom(3);
useSchemaStore.getState().resetView();
expect(useSchemaStore.getState().highlightedPartId).toBe("part-A");
expect(useSchemaStore.getState().selectedPartId).toBe("part-B");
});
});
describe("toggleFullscreen", () => {
it("should toggle fullscreen from false to true", () => {
useSchemaStore.getState().toggleFullscreen();
expect(useSchemaStore.getState().isFullscreen).toBe(true);
});
it("should toggle fullscreen from true to false", () => {
useSchemaStore.getState().toggleFullscreen();
useSchemaStore.getState().toggleFullscreen();
expect(useSchemaStore.getState().isFullscreen).toBe(false);
});
it("should toggle fullscreen multiple times correctly", () => {
useSchemaStore.getState().toggleFullscreen(); // true
useSchemaStore.getState().toggleFullscreen(); // false
useSchemaStore.getState().toggleFullscreen(); // true
expect(useSchemaStore.getState().isFullscreen).toBe(true);
});
});
describe("setPan", () => {
it("should set pan coordinates", () => {
useSchemaStore.getState().setPan(50, 75);
expect(useSchemaStore.getState().panX).toBe(50);
expect(useSchemaStore.getState().panY).toBe(75);
});
it("should allow negative pan values", () => {
useSchemaStore.getState().setPan(-100, -200);
expect(useSchemaStore.getState().panX).toBe(-100);
expect(useSchemaStore.getState().panY).toBe(-200);
});
});
});

View File

@@ -1,31 +0,0 @@
import { create } from "zustand";
interface SchemaState {
highlightedPartId: string | null;
selectedPartId: string | null;
zoom: number;
panX: number;
panY: number;
isFullscreen: boolean;
setHighlightedPart: (id: string | null) => void;
setSelectedPart: (id: string | null) => void;
setZoom: (zoom: number) => void;
setPan: (x: number, y: number) => void;
resetView: () => void;
toggleFullscreen: () => void;
}
export const useSchemaStore = create<SchemaState>((set) => ({
highlightedPartId: null,
selectedPartId: null,
zoom: 1,
panX: 0,
panY: 0,
isFullscreen: false,
setHighlightedPart: (id) => set({ highlightedPartId: id }),
setSelectedPart: (id) => set({ selectedPartId: id }),
setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(5, zoom)) }),
setPan: (panX, panY) => set({ panX, panY }),
resetView: () => set({ zoom: 1, panX: 0, panY: 0 }),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
}));

View File

@@ -1 +0,0 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +0,0 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test-setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@sase/ui": path.resolve(__dirname, "../../packages/ui/src"),
},
},
});

View File

@@ -15,6 +15,8 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@grafana/faro-web-sdk": "^2.2.4",
"@grafana/faro-web-tracing": "^2.2.4",
"@remotion/player": "^4.0.422",
"@sase/shared": "workspace:*",
"@sase/ui": "workspace:*",

View File

@@ -34,7 +34,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
const [loading, setLoading] = useState(false);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
// Prefetch schema images for leaf categories without images (sequential to avoid PL24 session conflicts)
// Prefetch schema images for leaf categories in batches of 2
const prefetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const leafsWithoutImage = currentCategories.filter(
@@ -47,34 +47,40 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
if (leafsWithoutImage.length === 0) return;
for (const c of leafsWithoutImage) prefetchedRef.current.add(c.id);
setPrefetchingIds(new Set(leafsWithoutImage.map((c) => c.id)));
const parentId = currentCategories[0]?.parentId;
let didCancel = false;
// Sequential fetch to avoid PL24 session conflicts
const BATCH_SIZE = 2;
(async () => {
for (const c of leafsWithoutImage) {
for (let i = 0; i < leafsWithoutImage.length; i += BATCH_SIZE) {
if (didCancel) break;
try {
await api.get(`/vehicles/${vehicleId}/categories/${c.id}`);
} catch {}
const batch = leafsWithoutImage.slice(i, i + BATCH_SIZE);
setPrefetchingIds(new Set(batch.map((c) => c.id)));
await Promise.allSettled(
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
);
// Refresh after each batch to show images progressively
if (!didCancel && parentId) {
try {
const refreshed = await api.get<Category[]>(
`/categories/${parentId}/children`,
);
if (!didCancel && refreshed?.length) {
setCurrentCategories((prev) =>
prev.map((c) => {
const updated = refreshed.find((r) => r.id === c.id);
return updated?.schemaImageUrl
? { ...c, schemaImageUrl: updated.schemaImageUrl }
: c;
}),
);
}
} catch {}
}
}
if (didCancel || !parentId) return;
try {
const refreshed = await api.get<Category[]>(
`/categories/${parentId}/children`,
);
if (didCancel || !refreshed?.length) return;
setCurrentCategories((prev) =>
prev.map((c) => {
const updated = refreshed.find((r) => r.id === c.id);
return updated?.schemaImageUrl
? { ...c, schemaImageUrl: updated.schemaImageUrl }
: c;
}),
);
} catch {}
if (!didCancel) setPrefetchingIds(new Set());
})();

View File

@@ -62,7 +62,7 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
}
}, [expanded, fetched, category.id, queryClient]);
// Prefetch schema images for leaf children when expanded
// Prefetch schema images for leaf children in batches of 2 when expanded
useEffect(() => {
if (!expanded || prefetchedRef.current) return;
const leafs = children.filter(
@@ -73,21 +73,29 @@ function CategoryNode({ category, vehicleId, level, parentPrefetching }: {
setPrefetching(true);
const parentId = children[0]?.parentId;
let cancelled = false;
const BATCH_SIZE = 2;
(async () => {
for (const c of leafs) {
for (let i = 0; i < leafs.length; i += BATCH_SIZE) {
if (cancelled) break;
try { await api.get(`/vehicles/${vehicleId}/categories/${c.id}`); } catch {}
}
if (cancelled || !parentId) { if (!cancelled) setPrefetching(false); return; }
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
if (!cancelled && refreshed?.length) {
setChildren((prev) => prev.map((c) => {
const u = refreshed.find((r) => r.id === c.id);
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
}));
const batch = leafs.slice(i, i + BATCH_SIZE);
await Promise.allSettled(
batch.map((c) => api.get(`/vehicles/${vehicleId}/categories/${c.id}`)),
);
// Refresh after each batch to show images progressively
if (!cancelled && parentId) {
try {
const refreshed = await api.get<Category[]>(`/categories/${parentId}/children`);
if (!cancelled && refreshed?.length) {
setChildren((prev) => prev.map((c) => {
const u = refreshed.find((r) => r.id === c.id);
return u?.schemaImageUrl ? { ...c, schemaImageUrl: u.schemaImageUrl } : c;
}));
}
} catch {}
}
} catch {}
}
if (!cancelled) setPrefetching(false);
})();
return () => { cancelled = true; };

View File

@@ -1,4 +1,5 @@
import { api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
@@ -177,15 +178,18 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
}
function handlePayWithCard() {
startAction("payment-iyzico", { plan: planKey, period, amount: String(totalAmount) });
iyzicoMutation.mutate();
}
function handleEftProceed() {
startAction("payment-eft", { plan: planKey, period, amount: String(totalAmount) });
eftMutation.mutate();
}
function handleUploadReceipt() {
if (uploadedFile) {
startAction("receipt-upload", { paymentId: eftPaymentId || "" });
uploadMutation.mutate(uploadedFile);
}
}
@@ -334,11 +338,11 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
onValueChange={(v) => setPaymentMethod(v as "iyzico" | "eft")}
>
<TabsList className="w-full">
<TabsTrigger value="iyzico" className="flex-1">
<TabsTrigger value="iyzico" data-faro-user-action-name="payment-tab-card" className="flex-1">
<CreditCard className="mr-2 h-4 w-4" />
{t("payment.creditCard")}
</TabsTrigger>
<TabsTrigger value="eft" className="flex-1">
<TabsTrigger value="eft" data-faro-user-action-name="payment-tab-eft" className="flex-1">
<Building2 className="mr-2 h-4 w-4" />
{t("payment.eftTransfer")}
</TabsTrigger>

View File

@@ -119,7 +119,7 @@ export function HotspotOverlay({
: hotspot.coordinates[1] - 4;
return (
<g key={hotspot.id} style={{ pointerEvents: "auto" }}>
<g key={hotspot.id} style={{ pointerEvents: "auto" }} data-faro-user-action-name="hotspot-click">
<HotspotShape
hotspot={hotspot}
isHighlighted={isHighlighted}

View File

@@ -50,6 +50,7 @@ export function PartsPanel({ parts }: PartsPanelProps) {
return (
<tr
key={part.id}
data-faro-user-action-name="select-part"
ref={(el) => {
// Store ref for the first part in each group (for scroll-to)
if (group != null && el && !rowRefs.current.has(group)) {

View File

@@ -11,6 +11,7 @@ export function SchemaToolbar() {
<Button
variant="ghost"
size="icon"
data-faro-user-action-name="schema-zoom-out"
onClick={() => setZoom(zoom - 0.25)}
disabled={zoom <= 0.5}
title="Uzaklaştır"
@@ -25,6 +26,7 @@ export function SchemaToolbar() {
<Button
variant="ghost"
size="icon"
data-faro-user-action-name="schema-zoom-in"
onClick={() => setZoom(zoom + 0.25)}
disabled={zoom >= 5}
title="Yakınlaştır"
@@ -37,6 +39,7 @@ export function SchemaToolbar() {
<Button
variant="ghost"
size="icon"
data-faro-user-action-name="schema-reset"
onClick={resetView}
title="Görünümü sıfırla"
>
@@ -46,6 +49,7 @@ export function SchemaToolbar() {
<Button
variant="ghost"
size="icon"
data-faro-user-action-name="schema-fullscreen"
onClick={toggleFullscreen}
title={isFullscreen ? "Tam ekrandan çık" : "Tam ekran"}
>

View File

@@ -57,11 +57,11 @@ export function SchemaViewer({
if (isLoading) {
return (
<div className="flex h-[600px] gap-4 rounded-lg border border-border">
<div className="flex w-[60%] items-center justify-center">
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
<Skeleton className="h-[80%] w-[80%]" />
</div>
<div className="w-[40%] space-y-3 p-4">
<div className="w-full space-y-3 p-4 md:w-[40%]">
<Skeleton className="h-6 w-1/2" />
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
@@ -75,12 +75,12 @@ export function SchemaViewer({
<div
ref={containerRef}
className={cn(
"flex rounded-lg border border-border bg-background",
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "h-[700px]",
"flex flex-col rounded-lg border border-border bg-background md:flex-row",
isFullscreen ? "fixed inset-0 z-50 rounded-none" : "md:h-[700px]",
)}
>
{/* Left side: Schema image + hotspot overlay (60%) */}
<div className="relative flex w-[60%] flex-col border-r border-border">
<div className="relative flex h-[400px] flex-col border-b border-border md:h-auto md:w-[60%] md:border-b-0 md:border-r">
{/* Toolbar */}
<div className="absolute left-3 top-3 z-20">
<SchemaToolbar />
@@ -134,7 +134,7 @@ export function SchemaViewer({
</div>
{/* Right side: Parts panel (40%) */}
<div className="w-[40%]">
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
<PartsPanel parts={parts} />
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { getFaro } from "./faro";
const API_URL = "/api";
type RequestOptions = {
@@ -29,11 +31,15 @@ class ApiClient {
const data = await res.json();
if (!res.ok) {
throw new ApiError(
const error = new ApiError(
data?.error?.message || "İstek başarısız",
data?.error?.code || "UNKNOWN",
res.status,
);
getFaro()?.api.pushError(error, {
context: { method, path, statusCode: String(res.status) },
});
throw error;
}
return data.data !== undefined ? data.data : data;
@@ -65,11 +71,15 @@ class ApiClient {
const data = await res.json();
if (!res.ok) {
throw new ApiError(
const error = new ApiError(
data?.error?.message || "Yükleme başarısız",
data?.error?.code || "UNKNOWN",
res.status,
);
getFaro()?.api.pushError(error, {
context: { method: "POST", path, statusCode: String(res.status) },
});
throw error;
}
return data.data !== undefined ? data.data : data;

59
apps/web/src/lib/faro.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { Faro } from "@grafana/faro-web-sdk";
let faro: Faro | null = null;
export async function initFaro() {
if (import.meta.env.VITE_FARO_ENABLED !== "true") return;
const collectorUrl = import.meta.env.VITE_FARO_COLLECTOR_URL;
if (!collectorUrl) {
console.warn("[faro] VITE_FARO_COLLECTOR_URL is required when Faro is enabled");
return;
}
try {
const { initializeFaro, getWebInstrumentations } = await import("@grafana/faro-web-sdk");
const { TracingInstrumentation } = await import("@grafana/faro-web-tracing");
faro = initializeFaro({
url: collectorUrl,
app: {
name: "saseweb",
version: "2.0.0",
environment: import.meta.env.MODE,
},
instrumentations: [
...getWebInstrumentations({ captureConsole: false }),
new TracingInstrumentation({
instrumentationOptions: {
propagateTraceHeaderCorsUrls: [/\/api\//],
},
}),
],
});
console.log("[faro] Frontend observability initialized");
} catch (err) {
console.warn("[faro] Initialization failed:", (err as Error).message);
}
}
export function getFaro() {
return faro;
}
/** Start a programmatic user action (auto-completes 100ms after last linked event) */
export function startAction(
name: string,
attributes?: Record<string, string>,
) {
faro?.api.startUserAction(name, attributes);
}
/** Push a Faro event (for non-action tracking like page-level events) */
export function pushEvent(
name: string,
attributes?: Record<string, string>,
) {
faro?.api.pushEvent(name, attributes);
}

View File

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

View File

@@ -4,6 +4,7 @@ import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { toast } from "@/lib/toast";
export const Route = createFileRoute("/_auth/login")({
@@ -18,6 +19,7 @@ function LoginPage() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startAction("login", { method: "email" });
setLoading(true);
try {
@@ -104,7 +106,10 @@ function LoginPage() {
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/search" })}
onClick={() => {
startAction("login", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/search" });
}}
>
Google ile Giriş Yap
</Button>

View File

@@ -4,6 +4,7 @@ import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { toast } from "@/lib/toast";
import { ShieldCheck } from "lucide-react";
@@ -19,6 +20,7 @@ function RegisterPage() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startAction("register", { method: "email" });
setLoading(true);
try {
@@ -107,7 +109,10 @@ function RegisterPage() {
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" })}
onClick={() => {
startAction("register", { method: "google" });
signIn.social({ provider: "google", callbackURL: "/dashboard/subscription?welcome=1" });
}}
>
Google ile Kayıt Ol
</Button>

Some files were not shown because too many files have changed in this diff Show More