Merge pull request 'dev' (#126) from dev into main

Reviewed-on: #126
This commit was merged in pull request #126.
This commit is contained in:
2026-06-11 14:34:37 +00:00
26 changed files with 881 additions and 366 deletions

View File

@@ -0,0 +1,23 @@
-- Proxy telemetry: one row per proxied upstream attempt (pcat call/capture/
-- validate, emex http). Lets the Süper Panel grade proxy providers per service
-- (success rate, ban rate, latency) and track banned residential exit IPs /
-- sticky sessions. Written fire-and-forget by ProxyTelemetryService; 30-day
-- retention enforced by the query-cleanup job.
CREATE TABLE "proxy_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"service" varchar(30) NOT NULL,
"provider" varchar(20) NOT NULL,
"session_key" varchar(60),
"exit_ip" varchar(45),
"target_host" varchar(120),
"status_code" integer,
"error_kind" varchar(30),
"success" boolean DEFAULT false NOT NULL,
"banned" boolean DEFAULT false NOT NULL,
"duration_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at");
--> statement-breakpoint
CREATE INDEX "proxy_logs_banned_created_at_idx" ON "proxy_logs" ("banned","created_at");

View File

@@ -99,6 +99,13 @@
"when": 1780600000000,
"tag": "0013_fix_brand_casing",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781136000000,
"tag": "0014_proxy_logs",
"breakpoints": true
}
]
}

View File

@@ -617,3 +617,41 @@ export const emexCategoryTranslations = pgTable(
},
(table) => [uniqueIndex("emex_translations_original_name_idx").on(table.originalName)],
);
// ─── Proxy Logs ──────────────────────────────────────
// One row per proxied upstream attempt (pcat call/capture/validate, emex http).
// Written fire-and-forget by ProxyTelemetryService so the Süper Panel can grade
// proxy providers (success/ban/latency per service) and track banned exit IPs.
// `banned` = HTTP 403/429 from the upstream (IP-level block signal); `session_key`
// identifies a sticky Floxy session or a DataImpulse port so bans are attributable
// even when the exact exit IP is unknown (rotating call legs).
export const proxyLogs = pgTable(
"proxy_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
// Which integration leg made the request: pcat_call | pcat_capture |
// pcat_validate | emex_http
service: varchar("service", { length: 30 }).notNull(),
// floxy | dataimpulse | none (direct)
provider: varchar("provider", { length: 20 }).notNull(),
// Sticky Floxy session id ("s-ab12cd34") or DataImpulse port ("di:10042");
// null on per-request rotating legs (exit IP changes every call).
sessionKey: varchar("session_key", { length: 60 }),
// Resolved residential exit IP (sticky sessions only — one cheap ipify probe
// per session). Null when rotation makes the IP unknowable.
exitIp: varchar("exit_ip", { length: 45 }),
targetHost: varchar("target_host", { length: 120 }),
statusCode: integer("status_code"),
// Transport failure class when no HTTP answer arrived:
// timeout | connect | reset | dns | aborted | transport
errorKind: varchar("error_kind", { length: 30 }),
success: boolean("success").default(false).notNull(),
banned: boolean("banned").default(false).notNull(),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("proxy_logs_created_at_idx").on(table.createdAt),
index("proxy_logs_banned_created_at_idx").on(table.banned, table.createdAt),
],
);

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { EmexBrowserService } from "./emex.browser";
import { EmexService } from "./emex.service";
@Module({
imports: [ProxyTelemetryModule],
providers: [EmexBrowserService, EmexService],
exports: [EmexService],
})

View File

@@ -18,6 +18,7 @@ function makeConfig(overrides: Record<string, string>): ConfigService {
const browser = {} as never;
const redis = { set: vi.fn() } as never;
const posthog = {} as never;
const proxyTelemetry = { record: vi.fn() } as never;
describe("EmexService proxy-port coercion (regression)", () => {
it("does not throw 'Invalid URL' when ports arrive as strings over a real range", () => {
@@ -28,7 +29,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_END: "10099",
});
// Pre-fix: threw "Invalid URL" here (port "4510001" > 65535).
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
});
it("constructs with a single-port range (string env)", () => {
@@ -37,7 +38,7 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "823",
EMEX_PROXY_PORT_END: "823",
});
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
});
it("falls back to a valid default when the port env is garbage", () => {
@@ -46,11 +47,11 @@ describe("EmexService proxy-port coercion (regression)", () => {
EMEX_PROXY_PORT_START: "not-a-number",
EMEX_PROXY_PORT_END: "999999",
});
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
});
it("constructs cleanly with the proxy disabled", () => {
const config = makeConfig({ EMEX_USE_PROXY: "false" });
expect(() => new EmexService(config, browser, redis, posthog)).not.toThrow();
expect(() => new EmexService(config, browser, redis, posthog, proxyTelemetry)).not.toThrow();
});
});

View File

@@ -23,6 +23,10 @@ import { ProxyAgent } from "undici";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
} from "../proxy-telemetry/proxy-telemetry.service";
import { parseUnitLeaves, parseVehicleTree } from "./emex-tree.parser";
import { EmexBrowserService } from "./emex.browser";
import { createEmptyDecodedVehicle, mapEmexResponse } from "./emex.mapper";
@@ -142,6 +146,7 @@ export class EmexService {
private browserService: EmexBrowserService,
private redis: RedisService,
private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
) {
// __dirname is apps/api/src/integrations/emex/ or dist/integrations/emex/
// Scraper lives at <monorepo-root>/scripts/emex-vin-scraper.js
@@ -389,12 +394,25 @@ export class EmexService {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const provider = schedule[attempt - 1]; // undefined when no proxy → direct
const agent = provider ? this.newProxyAgent(provider) : null;
const attemptStart = Date.now();
// Sticky Floxy sessions are the attributable identity here; DataImpulse
// rotates a random port per agent, so there is nothing stable to pin.
const sessionKey = provider === "floxy" ? `s-${this.floxySessionId}` : null;
try {
const res = await fetch(url, {
headers: { "User-Agent": EMEX_UA, Accept: "text/html,application/xhtml+xml" },
signal: AbortSignal.timeout(this.timeout),
...(agent ? { dispatcher: agent } : {}),
} as RequestInit);
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
statusCode: res.status,
success: res.ok,
durationMs: Date.now() - attemptStart,
});
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}
@@ -407,6 +425,19 @@ export class EmexService {
const e = err as Error & { cause?: unknown };
// A real HTTP response ("EMEX HTTP 404") is a definitive answer — never retry.
const httpAnswer = /^EMEX HTTP \d/.test(e.message);
// HTTP answers were already recorded right after fetch; only transport
// failures (no answer at all) still need a row.
if (!httpAnswer) {
this.proxyTelemetry.record({
service: "emex_http",
provider: provider ?? "none",
sessionKey,
targetHost: new URL(url).hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
}
const transient =
!httpAnswer &&
(e.name === "TimeoutError" ||

View File

@@ -23,6 +23,11 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@ne
import { ConfigService } from "@nestjs/config";
import { Browser, BrowserContext } from "playwright";
import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
resolveExitIp,
} from "../proxy-telemetry/proxy-telemetry.service";
import { JwtSlot, PcatJwtToken, PcatSession } from "./parts-catalogs.types";
// Shared single-slot cache so dev + prod (and any restarted container) can
@@ -184,6 +189,7 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
constructor(
private configService: ConfigService,
private readonly redis: RedisService,
private readonly proxyTelemetry: ProxyTelemetryService,
) {
const cfg = this.configService;
@@ -245,8 +251,12 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
private buildProxy(leg: ProxyLeg): {
proxyUrl: string | null;
proxyConfig: { server: string; username: string; password: string } | null;
// Telemetry identity: sticky Floxy session id or DataImpulse port. Null on
// rotating legs (exit IP changes per request — nothing stable to attribute).
sessionKey: string | null;
} {
if (this.proxyProvider === "none") return { proxyUrl: null, proxyConfig: null };
if (this.proxyProvider === "none")
return { proxyUrl: null, proxyConfig: null, sessionKey: null };
if (this.proxyProvider === "dataimpulse") {
const port = this.allocatePort();
@@ -254,19 +264,23 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
return {
proxyUrl: `http://${this.diUser}:${this.diPass}@${this.diHost}:${port}`,
proxyConfig: { server, username: this.diUser, password: this.diPass },
sessionKey: `di:${port}`,
};
}
// floxy
let password = this.floxyPass;
let sessionKey: string | null = null;
if (leg === "capture" && this.floxyCaptureLifetime > 0) {
const sid = Math.random().toString(36).slice(2, 10);
password = `${this.floxyPass}_session-${sid}_lifetime-${this.floxyCaptureLifetime}`;
sessionKey = `s-${sid}`;
}
const server = `http://${this.floxyHost}:${this.floxyPort}`;
return {
proxyUrl: `http://${this.floxyUser}:${password}@${this.floxyHost}:${this.floxyPort}`,
proxyConfig: { server, username: this.floxyUser, password },
sessionKey,
};
}
@@ -509,10 +523,27 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
connect: { timeout: 6_000 },
});
}
const start = Date.now();
try {
const r = await fetch(url, fetchOptions);
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
statusCode: r.status,
success: r.ok,
durationMs: Date.now() - start,
});
return r.ok;
} catch (err) {
this.proxyTelemetry.record({
service: "pcat_validate",
provider: this.proxyProvider,
targetHost: "gui.parts-catalogs.com",
errorKind: classifyTransportError(err),
success: false,
durationMs: Date.now() - start,
});
this.logger.debug(`Slot validation threw: ${(err as Error).message}`);
return false;
}
@@ -704,11 +735,31 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
let context: BrowserContext | null = null;
const startTime = Date.now();
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
// this capture so the partner site + widget see a coherent session.
const { proxyConfig, proxyUrl, sessionKey } = this.buildProxy("capture");
// The session is sticky, so a parallel ipify probe exits from the SAME IP
// the capture will use — that's what makes banned-IP tracking concrete.
// Resolves in ~1-2s while the capture itself takes 7s+; never throws.
const exitIpPromise = proxyUrl ? resolveExitIp(proxyUrl) : Promise.resolve(null);
const logCapture = (ok: boolean, errorKind?: string): void => {
void exitIpPromise.then((exitIp) =>
this.proxyTelemetry.record({
service: "pcat_capture",
provider: this.proxyProvider,
sessionKey,
exitIp,
targetHost: new URL(siteUrl).hostname,
errorKind: errorKind ?? null,
success: ok,
durationMs: Date.now() - startTime,
}),
);
};
try {
// Sticky capture proxy: one residential exit IP pinned for the lifetime of
// this capture so the partner site + widget see a coherent session.
const contextOptions: Record<string, unknown> = {};
const { proxyConfig } = this.buildProxy("capture");
if (proxyConfig) {
contextOptions.proxy = proxyConfig;
}
@@ -795,13 +846,18 @@ export class PartsCatalogsAuthService implements OnModuleInit, OnModuleDestroy {
const elapsed = Date.now() - startTime;
if (capturedToken) {
logCapture(true);
this.logger.log(`Token captured in ${elapsed}ms from ${new URL(siteUrl).hostname}`);
return capturedToken;
}
// Widget never fired its token call — on a sticky residential IP this is
// the classic "partner site blocked/ratelimited this exit IP" signature.
logCapture(false, "capture_timeout");
this.logger.debug(`No token after ${elapsed}ms from ${siteUrl}`);
return null;
} catch (err) {
logCapture(false, classifyTransportError(err));
this.logger.warn(`Token capture error: ${(err as Error).message}`);
return null;
} finally {

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryModule } from "../proxy-telemetry/proxy-telemetry.module";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import { PartsCatalogsService } from "./parts-catalogs.service";
@Module({
imports: [ProxyTelemetryModule],
providers: [PartsCatalogsAuthService, PartsCatalogsService],
exports: [PartsCatalogsService],
})

View File

@@ -11,6 +11,11 @@ import { Injectable, Logger } from "@nestjs/common";
import { isBackfillContext } from "../../jobs/prefetch-context";
import { PostHogService } from "../../posthog/posthog.service";
import { RedisService } from "../../redis/redis.service";
import {
ProxyTelemetryService,
classifyTransportError,
describeProxyUrl,
} from "../proxy-telemetry/proxy-telemetry.service";
import { PartsCatalogsAuthService } from "./parts-catalogs-auth.service";
import {
PcatCar,
@@ -62,6 +67,7 @@ export class PartsCatalogsService {
private authService: PartsCatalogsAuthService,
private redis: RedisService,
private posthog: PostHogService,
private proxyTelemetry: ProxyTelemetryService,
) {}
/** Mark parts-catalogs as actively used (5min TTL) to defer prefetch worker */
@@ -273,6 +279,7 @@ export class PartsCatalogsService {
}
}
const attemptStart = Date.now();
try {
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
@@ -303,6 +310,17 @@ export class PartsCatalogsService {
const response = await fetch(url.toString(), fetchOptions);
// An HTTP answer arrived — the proxy leg worked at transport level;
// log the upstream's verdict (403/429 are the IP-ban signals).
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
statusCode: response.status,
success: response.ok,
durationMs: Date.now() - attemptStart,
});
if (response.ok) {
// Token proven good on this IP — clear any accrued failure streak.
session._slot.failCount = 0;
@@ -323,6 +341,18 @@ export class PartsCatalogsService {
throw new Error(`HTTP ${response.status} from ${endpoint}: ${text.slice(0, 200)}`);
} catch (err) {
const e = err as Error & { cause?: unknown };
// Transport failure (no HTTP answer) — the try block already logged any
// real HTTP response before rethrowing it as `Error: HTTP <code> …`.
if (!/^HTTP \d/.test(e.message ?? "")) {
this.proxyTelemetry.record({
service: "pcat_call",
...describeProxyUrl(session.proxyUrl),
targetHost: url.hostname,
errorKind: classifyTransportError(e),
success: false,
durationMs: Date.now() - attemptStart,
});
}
// Retry transient TRANSPORT failures only: request timeouts and undici
// network errors ("TypeError: fetch failed" — a dropped/reset DataImpulse
// proxy connection). A definitive HTTP response (e.g. 400 "list of parts

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ProxyTelemetryService } from "./proxy-telemetry.service";
// Imported by PartsCatalogsModule and EmexModule; Nest module caching makes the
// service a singleton (one shared flush buffer) across both.
@Module({
providers: [ProxyTelemetryService],
exports: [ProxyTelemetryService],
})
export class ProxyTelemetryModule {}

View File

@@ -0,0 +1,151 @@
import { Inject, Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { DATABASE, type Database } from "../../database/database.provider";
import { proxyLogs } from "../../database/schema/core";
/**
* Proxy telemetry — fire-and-forget logging of every proxied upstream attempt
* (pcat call/capture/validate, emex http) into `proxy_logs`, so the Süper Panel
* can grade proxy providers per service and track banned exit IPs / sessions.
*
* Design constraints:
* - MUST never affect the request path: record() only pushes to an in-memory
* buffer; a timer flushes batches, and every DB error is swallowed.
* - Bounded memory: the buffer is capped; when full, oldest rows are dropped.
*/
export type ProxyServiceLeg = "pcat_call" | "pcat_capture" | "pcat_validate" | "emex_http";
export type ProxyProviderName = "floxy" | "dataimpulse" | "none";
export interface ProxyLogEvent {
service: ProxyServiceLeg;
provider: ProxyProviderName;
sessionKey?: string | null;
exitIp?: string | null;
targetHost?: string | null;
statusCode?: number | null;
errorKind?: string | null;
success: boolean;
durationMs?: number | null;
}
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_BATCH_SIZE = 50;
const BUFFER_CAP = 500;
// Upstream statuses that signal an IP-level block (not an auth/data problem):
// 403 = forbidden (WAF/geo/IP ban), 429 = rate limited. 402 is the proxy
// provider itself rejecting (balance exhausted — Floxy did this 2026-06-10);
// counted as banned=false but worth surfacing via status_code.
export function isBanStatus(status: number | null | undefined): boolean {
return status === 403 || status === 429;
}
/** Classify a transport error (no HTTP answer) into a coarse error_kind. */
export function classifyTransportError(err: unknown): string {
const e = err as Error & { cause?: unknown };
const text = `${e?.name ?? ""} ${e?.message ?? ""} ${String(e?.cause ?? "")}`;
if (/TimeoutError|UND_ERR_CONNECT_TIMEOUT|UND_ERR_HEADERS_TIMEOUT/i.test(text)) return "timeout";
if (/AbortError|aborted/i.test(text)) return "aborted";
if (/ECONNREFUSED|UND_ERR_SOCKET|connect/i.test(text)) return "connect";
if (/ECONNRESET|socket hang up|other side closed|terminated/i.test(text)) return "reset";
if (/EAI_AGAIN|ENOTFOUND/i.test(text)) return "dns";
return "transport";
}
/**
* Derive provider + session key from a proxy URL the way the integrations
* build them: Floxy passwords may embed `_session-<id>_lifetime-<n>` (sticky);
* DataImpulse rotates by port, so the port IS the session identity.
*/
export function describeProxyUrl(proxyUrl: string | null | undefined): {
provider: ProxyProviderName;
sessionKey: string | null;
} {
if (!proxyUrl) return { provider: "none", sessionKey: null };
try {
const u = new URL(proxyUrl);
if (/floxy/i.test(u.hostname)) {
const m = decodeURIComponent(u.password).match(/_session-([A-Za-z0-9]+)/);
return { provider: "floxy", sessionKey: m ? `s-${m[1]}` : null };
}
return { provider: "dataimpulse", sessionKey: `di:${u.port}` };
} catch {
return { provider: "none", sessionKey: null };
}
}
/**
* Resolve the residential exit IP of a (sticky) proxy session with one cheap
* probe through the same proxy. Only meaningful for sticky sessions — on a
* rotating leg the probe and the real request exit from different IPs.
*/
export async function resolveExitIp(
proxyUrl: string,
timeoutMs = 5_000,
): Promise<string | null> {
try {
const { ProxyAgent } = await import("undici");
const res = await fetch("https://api.ipify.org?format=text", {
dispatcher: new ProxyAgent({ uri: proxyUrl, connect: { timeout: timeoutMs } }),
signal: AbortSignal.timeout(timeoutMs),
} as unknown as RequestInit);
if (!res.ok) return null;
const ip = (await res.text()).trim();
return /^[0-9a-fA-F.:]{3,45}$/.test(ip) ? ip : null;
} catch {
return null;
}
}
@Injectable()
export class ProxyTelemetryService implements OnModuleDestroy {
private readonly logger = new Logger(ProxyTelemetryService.name);
private buffer: ProxyLogEvent[] = [];
private flushTimer: NodeJS.Timeout | null = null;
private flushing = false;
constructor(@Inject(DATABASE) private readonly db: Database) {
this.flushTimer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
// Never keep the process alive just to flush telemetry.
this.flushTimer.unref?.();
}
/** Queue one proxied-attempt record. Never throws, never blocks. */
record(event: ProxyLogEvent): void {
if (this.buffer.length >= BUFFER_CAP) this.buffer.shift();
this.buffer.push(event);
if (this.buffer.length >= FLUSH_BATCH_SIZE) void this.flush();
}
private async flush(): Promise<void> {
if (this.flushing || this.buffer.length === 0) return;
this.flushing = true;
const batch = this.buffer.splice(0, FLUSH_BATCH_SIZE * 2);
try {
await this.db.insert(proxyLogs).values(
batch.map((e) => ({
service: e.service,
provider: e.provider,
sessionKey: e.sessionKey ?? null,
exitIp: e.exitIp ?? null,
targetHost: e.targetHost?.slice(0, 120) ?? null,
statusCode: e.statusCode ?? null,
errorKind: e.errorKind ?? null,
success: e.success,
banned: isBanStatus(e.statusCode),
durationMs: e.durationMs != null ? Math.round(e.durationMs) : null,
})),
);
} catch (err) {
// Telemetry must never matter more than the product. Drop the batch.
this.logger.debug(`proxy_logs flush failed (${batch.length} rows): ${(err as Error).message}`);
} finally {
this.flushing = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.flushTimer) clearInterval(this.flushTimer);
await this.flush();
}
}

View File

@@ -1,11 +1,14 @@
import { Job } from "bullmq";
import { lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { queryLogs } from "../../database/schema/core";
import { proxyLogs, queryLogs } from "../../database/schema/core";
type Database = PostgresJsDatabase<Record<string, unknown>>;
const RETENTION_DAYS = 90;
// proxy_logs is high-volume per-attempt telemetry; 30 days is plenty for
// grading providers and spotting ban waves.
const PROXY_LOG_RETENTION_DAYS = 30;
export async function processQueryCleanup(
job: Job,
@@ -27,5 +30,15 @@ export async function processQueryCleanup(
`[query-cleanup] Deleted ${deletedCount} query log(s) older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
);
const proxyCutoff = new Date();
proxyCutoff.setDate(proxyCutoff.getDate() - PROXY_LOG_RETENTION_DAYS);
const proxyDeleted = await db
.delete(proxyLogs)
.where(lt(proxyLogs.createdAt, proxyCutoff))
.returning({ id: proxyLogs.id });
console.log(
`[query-cleanup] Deleted ${proxyDeleted.length} proxy log(s) older than ${PROXY_LOG_RETENTION_DAYS} days`,
);
return { deletedCount };
}

View File

@@ -0,0 +1,157 @@
import { useBlogPost, useBlogPosts } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Blog content is rendered in two shells: the public marketing pages (/blog)
// and the dashboard-wrapped pages (/dashboard/blog) so logged-in users keep
// the sidebar. Only the internal link targets differ; the canonical URL always
// points at the public page.
type BlogListPath = "/blog" | "/dashboard/blog";
type BlogPostPath = "/blog/$slug" | "/dashboard/blog/$slug";
// ─── LIST ─────────────────────────────────────────────────────────────────────
export function BlogListContent({ postLinkTo }: { postLinkTo: BlogPostPath }) {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
// All posts come from the central Directus CMS through the API.
const { data: apiPosts, isLoading } = useBlogPosts();
const posts = (apiPosts ?? [])
.map((p) => ({
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
}))
.sort((a, b) => b.date.localeCompare(a.date));
return (
<>
<h1 className="text-4xl font-bold">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
{isLoading && posts.length === 0 && (
<p className="mt-12 text-muted-foreground">Yükleniyor</p>
)}
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to={postLinkTo} params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</>
);
}
// ─── POST ─────────────────────────────────────────────────────────────────────
// Posts live in the central Directus CMS and arrive as markdown via the API.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
export function BlogPostContent({ slug, listLinkTo }: { slug: string; listLinkTo: BlogListPath }) {
const { data: post, isLoading, isError } = useBlogPost(slug);
const title = post?.title ?? "Blog";
const description = post?.metaDescription ?? "";
const date = post?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return (
<>
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to={listLinkTo} className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
{post?.coverImage && (
<img
src={post.coverImage}
alt={title}
className="mt-8 w-full rounded-lg border object-cover"
/>
)}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to={listLinkTo}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,225 @@
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
// Contact content is rendered in two shells: the public marketing page
// (/contact) and the dashboard-wrapped page (/dashboard/contact) so logged-in
// users keep the sidebar.
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
export function ContactContent() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return (
<>
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -68,6 +68,15 @@ export function initChatwoot(): void {
_pending = [];
});
// Hide the floating launcher bubble on mobile — it covers content on small
// screens. The widget itself stays functional: openChatwoot() (subscription
// page, onboarding modal) still opens it fullscreen with its own close button.
const style = document.createElement("style");
style.id = "chatwoot-mobile-hide";
style.textContent =
"@media (max-width: 767px) { .woot--bubble-holder { display: none !important; } }";
document.head.appendChild(style);
const script = document.createElement("script");
script.id = "chatwoot-sdk";
script.src = `${BASE_URL}/packs/js/sdk.js`;

View File

@@ -717,7 +717,6 @@
"previewHint": "Click Decode VIN to access the full parts catalog.",
"recent": "Recent searches",
"seeAll": "See all →",
"openHint": "Click to reopen the vehicle with its parts catalog",
"historyAria": "{brand} {model} — VIN {vin}, open from history",
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
"errorGeneric": "An error occurred. Please try again.",

View File

@@ -717,7 +717,6 @@
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
"recent": "Son Aramalar",
"seeAll": "Tümünü Gör →",
"openHint": "Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın",
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",

View File

@@ -25,7 +25,9 @@ import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settin
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
import { Route as DashboardContactRouteImport } from './routes/dashboard/contact'
import { Route as DashboardChangelogRouteImport } from './routes/dashboard/changelog'
import { Route as DashboardBlogRouteImport } from './routes/dashboard/blog'
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
import { Route as AuthResetPasswordRouteImport } from './routes/_auth/reset-password'
@@ -38,6 +40,7 @@ import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/ca
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
import { Route as DemoCategoriesCategoryIdRouteImport } from './routes/demo_/categories_/$categoryId'
import { Route as DashboardOemCodeRouteImport } from './routes/dashboard/oem.$code'
import { Route as DashboardBlogSlugRouteImport } from './routes/dashboard/blog_/$slug'
import { Route as DashboardAdminUsersRouteImport } from './routes/dashboard/admin/users'
import { Route as DashboardAdminReferralsRouteImport } from './routes/dashboard/admin/referrals'
import { Route as DashboardAdminCopyLogsRouteImport } from './routes/dashboard/admin/copy-logs'
@@ -134,11 +137,21 @@ const DashboardHistoryRoute = DashboardHistoryRouteImport.update({
path: '/history',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardContactRoute = DashboardContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardChangelogRoute = DashboardChangelogRouteImport.update({
id: '/changelog',
path: '/changelog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogRoute = DashboardBlogRouteImport.update({
id: '/blog',
path: '/blog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBillingRoute = DashboardBillingRouteImport.update({
id: '/billing',
path: '/billing',
@@ -201,6 +214,11 @@ const DashboardOemCodeRoute = DashboardOemCodeRouteImport.update({
path: '/oem/$code',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogSlugRoute = DashboardBlogSlugRouteImport.update({
id: '/blog_/$slug',
path: '/blog/$slug',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardAdminUsersRoute = DashboardAdminUsersRouteImport.update({
id: '/admin/users',
path: '/admin/users',
@@ -312,7 +330,9 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof AuthResetPasswordRoute
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -322,6 +342,7 @@ export interface FileRoutesByFullPath {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin/': typeof DashboardAdminIndexRoute
@@ -357,7 +378,9 @@ export interface FileRoutesByTo {
'/reset-password': typeof AuthResetPasswordRoute
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -367,6 +390,7 @@ export interface FileRoutesByTo {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin': typeof DashboardAdminIndexRoute
@@ -405,7 +429,9 @@ export interface FileRoutesById {
'/_auth/reset-password': typeof AuthResetPasswordRoute
'/blog_/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -415,6 +441,7 @@ export interface FileRoutesById {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog_/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo_/categories_/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin/': typeof DashboardAdminIndexRoute
@@ -453,7 +480,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -463,6 +492,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog/$slug'
| '/dashboard/oem/$code'
| '/demo/categories/$categoryId'
| '/dashboard/admin/'
@@ -498,7 +528,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -508,6 +540,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog/$slug'
| '/dashboard/oem/$code'
| '/demo/categories/$categoryId'
| '/dashboard/admin'
@@ -545,7 +578,9 @@ export interface FileRouteTypes {
| '/_auth/reset-password'
| '/blog_/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -555,6 +590,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog_/$slug'
| '/dashboard/oem/$code'
| '/demo_/categories_/$categoryId'
| '/dashboard/admin/'
@@ -704,6 +740,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardHistoryRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/contact': {
id: '/dashboard/contact'
path: '/contact'
fullPath: '/dashboard/contact'
preLoaderRoute: typeof DashboardContactRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/changelog': {
id: '/dashboard/changelog'
path: '/changelog'
@@ -711,6 +754,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardChangelogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog': {
id: '/dashboard/blog'
path: '/blog'
fullPath: '/dashboard/blog'
preLoaderRoute: typeof DashboardBlogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/billing': {
id: '/dashboard/billing'
path: '/billing'
@@ -795,6 +845,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardOemCodeRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog_/$slug': {
id: '/dashboard/blog_/$slug'
path: '/blog/$slug'
fullPath: '/dashboard/blog/$slug'
preLoaderRoute: typeof DashboardBlogSlugRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/admin/users': {
id: '/dashboard/admin/users'
path: '/admin/users'
@@ -930,7 +987,9 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface DashboardRouteChildren {
DashboardBillingRoute: typeof DashboardBillingRoute
DashboardBlogRoute: typeof DashboardBlogRoute
DashboardChangelogRoute: typeof DashboardChangelogRoute
DashboardContactRoute: typeof DashboardContactRoute
DashboardHistoryRoute: typeof DashboardHistoryRoute
DashboardSearchRoute: typeof DashboardSearchRoute
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
@@ -940,6 +999,7 @@ interface DashboardRouteChildren {
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
DashboardAdminReferralsRoute: typeof DashboardAdminReferralsRoute
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
DashboardBlogSlugRoute: typeof DashboardBlogSlugRoute
DashboardOemCodeRoute: typeof DashboardOemCodeRoute
DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute
DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute
@@ -960,7 +1020,9 @@ interface DashboardRouteChildren {
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardBillingRoute: DashboardBillingRoute,
DashboardBlogRoute: DashboardBlogRoute,
DashboardChangelogRoute: DashboardChangelogRoute,
DashboardContactRoute: DashboardContactRoute,
DashboardHistoryRoute: DashboardHistoryRoute,
DashboardSearchRoute: DashboardSearchRoute,
DashboardServiceTestRoute: DashboardServiceTestRoute,
@@ -970,6 +1032,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,
DashboardAdminReferralsRoute: DashboardAdminReferralsRoute,
DashboardAdminUsersRoute: DashboardAdminUsersRoute,
DashboardBlogSlugRoute: DashboardBlogSlugRoute,
DashboardOemCodeRoute: DashboardOemCodeRoute,
DashboardAdminIndexRoute: DashboardAdminIndexRoute,
DashboardCatalogIndexRoute: DashboardCatalogIndexRoute,

View File

@@ -1,61 +1,18 @@
import { BlogListContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header";
import { useBlogPosts } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog")({
component: BlogPage,
});
function BlogPage() {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
// All posts come from the central Directus CMS through the API.
const { data: apiPosts, isLoading } = useBlogPosts();
const posts = (apiPosts ?? [])
.map((p) => ({
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
}))
.sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
{isLoading && posts.length === 0 && (
<p className="mt-12 text-muted-foreground">Yükleniyor</p>
)}
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to="/blog/$slug" params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
<BlogListContent postLinkTo="/blog/$slug" />
</main>
</div>
);

View File

@@ -1,42 +1,6 @@
import { BlogPostContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header";
import { useBlogPost } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── MARKDOWN RENDERING ───────────────────────────────────────────────────────
// Posts live in the central Directus CMS and arrive as markdown via the API.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
// ─── ROUTE ───────────────────────────────────────────────────────────────────
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage,
@@ -44,65 +8,13 @@ export const Route = createFileRoute("/blog_/$slug")({
function BlogPostPage() {
const { slug } = Route.useParams();
const { data: post, isLoading, isError } = useBlogPost(slug);
const title = post?.title ?? "Blog";
const description = post?.metaDescription ?? "";
const date = post?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to="/blog" className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
{post?.coverImage && (
<img
src={post.coverImage}
alt={title}
className="mt-8 w-full rounded-lg border object-cover"
/>
)}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to="/blog"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
<BlogPostContent slug={slug} listLinkTo="/blog" />
</main>
</div>
);

View File

@@ -1,230 +1,18 @@
import { ContactContent } from "@/components/contact-content";
import { SiteHeader } from "@/components/site-header";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
export const Route = createFileRoute("/contact")({
component: ContactPage,
});
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
function ContactPage() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-5xl px-4 py-16">
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
<ContactContent />
</main>
</div>
);

View File

@@ -82,8 +82,8 @@ const accountItems: readonly NavItem[] = [
const supportItems: readonly NavItem[] = [
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
{ to: "/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
];
const adminItems: readonly NavItem[] = [

View File

@@ -0,0 +1,14 @@
import { BlogListContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog")({
component: DashboardBlogPage,
});
function DashboardBlogPage() {
return (
<div className="mx-auto max-w-4xl">
<BlogListContent postLinkTo="/dashboard/blog/$slug" />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { BlogPostContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog_/$slug")({
component: DashboardBlogPostPage,
});
function DashboardBlogPostPage() {
const { slug } = Route.useParams();
return (
<div className="mx-auto max-w-3xl">
<BlogPostContent slug={slug} listLinkTo="/dashboard/blog" />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { ContactContent } from "@/components/contact-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/contact")({
component: DashboardContactPage,
});
function DashboardContactPage() {
return (
<div className="mx-auto max-w-5xl">
<ContactContent />
</div>
);
}

View File

@@ -791,8 +791,6 @@ function SearchPage() {
</button>
))}
</div>
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
</div>
)}