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:
23
apps/api/drizzle/0014_proxy_logs.sql
Normal file
23
apps/api/drizzle/0014_proxy_logs.sql
Normal 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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
157
apps/web/src/components/blog-content.tsx
Normal file
157
apps/web/src/components/blog-content.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
225
apps/web/src/components/contact-content.tsx
Normal file
225
apps/web/src/components/contact-content.tsx
Normal 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 iş 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 iş 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>
|
||||
);
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
14
apps/web/src/routes/dashboard/blog.tsx
Normal file
14
apps/web/src/routes/dashboard/blog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal file
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/routes/dashboard/contact.tsx
Normal file
14
apps/web/src/routes/dashboard/contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -791,8 +791,6 @@ function SearchPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user