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:
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
97
apps/api/src/telemetry/__tests__/telemetry.spec.ts
Normal file
97
apps/api/src/telemetry/__tests__/telemetry.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/api/src/telemetry/index.ts
Normal file
28
apps/api/src/telemetry/index.ts
Normal 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 };
|
||||
33
apps/api/src/telemetry/metrics.ts
Normal file
33
apps/api/src/telemetry/metrics.ts
Normal 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;
|
||||
}
|
||||
81
apps/api/src/telemetry/sdk-factory.ts
Normal file
81
apps/api/src/telemetry/sdk-factory.ts
Normal 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;
|
||||
}
|
||||
42
apps/api/src/telemetry/tracing.ts
Normal file
42
apps/api/src/telemetry/tracing.ts
Normal 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')");
|
||||
}
|
||||
20
apps/api/src/telemetry/worker-tracing.ts
Normal file
20
apps/api/src/telemetry/worker-tracing.ts
Normal 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 };
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
||||
NODE_ENV=development
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
6
apps/web-nj/next-env.d.ts
vendored
6
apps/web-nj/next-env.d.ts
vendored
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
"{searchTerm}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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} —{" "}
|
||||
{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")} →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>© {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">✓</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">✓</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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")} →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
}));
|
||||
@@ -1 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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());
|
||||
})();
|
||||
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
59
apps/web/src/lib/faro.ts
Normal 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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user