feat: sase.tr v2 full application implementation
Complete rewrite of sase.tr VIN lookup platform with modern stack: Backend (NestJS 10 + Drizzle ORM + PostgreSQL + Redis + BullMQ): - 34 DB models (core + PL24 + EMEX schemas) - Auth via Better Auth (email/password + social) - Brands, Plans, Subscriptions, Payments (iyzico + EFT) - VIN decode orchestration (Corgi + PL24 + EMEX + NHTSA) - Interactive schema viewer backend (MinIO storage) - EMEX scraping integration (Puppeteer + BullMQ workers) - Translation module (EN→TR automotive dictionary) - Admin dashboard API (stats, user mgmt, payment approval) - Rate limiting, Helmet security, file upload validation Frontend (Next.js 15 + Tailwind v4 + shadcn/ui + TanStack Query + Zustand): - 20 routes: auth, dashboard, VIN search, schema viewer, admin - Interactive schema viewer with zoom/pan/hotspot highlighting - Subscription management with brand selector - Payment flow (iyzico 3D Secure + EFT with receipt upload) - i18n support (TR/EN) - Error boundaries, loading skeletons, 404 page Infrastructure: - 85 tests (52 backend + 33 frontend, Vitest) - CI/CD (GitHub Actions: lint, typecheck, test, build, deploy) - Zero-downtime deploy script (PM2) - Env validation script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
91
apps/web/src/lib/api-client.ts
Normal file
91
apps/web/src/lib/api-client.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
|
||||
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = "GET", body, headers = {} } = options;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
data?.error?.message || "Request failed",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
return data.data !== undefined ? data.data : data;
|
||||
}
|
||||
|
||||
get<T>(path: string) {
|
||||
return this.request<T>(path);
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown) {
|
||||
return this.request<T>(path, { method: "POST", body });
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown) {
|
||||
return this.request<T>(path, { method: "PATCH", body });
|
||||
}
|
||||
|
||||
delete<T>(path: string) {
|
||||
return this.request<T>(path, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
data?.error?.message || "Upload failed",
|
||||
data?.error?.code || "UNKNOWN",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
return data.data !== undefined ? data.data : data;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
|
||||
constructor(message: string, code: string, status: number) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_URL);
|
||||
13
apps/web/src/lib/auth-client.ts
Normal file
13
apps/web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL?.replace("/api", "") || "http://localhost:4000",
|
||||
basePath: "/api/auth",
|
||||
});
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
useSession,
|
||||
} = authClient;
|
||||
95
apps/web/src/lib/i18n.test.ts
Normal file
95
apps/web/src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { t, useI18nStore } from "./i18n";
|
||||
|
||||
describe("i18n", () => {
|
||||
beforeEach(() => {
|
||||
// Reset to default Turkish locale before each test
|
||||
useI18nStore.setState({ locale: "tr" });
|
||||
});
|
||||
|
||||
describe("t() with Turkish locale", () => {
|
||||
it("should return Turkish text for known top-level keys", () => {
|
||||
expect(t("common.save")).toBe("Kaydet");
|
||||
});
|
||||
|
||||
it("should return Turkish text for nested keys", () => {
|
||||
expect(t("auth.login")).toBe("Giri\u015f Yap");
|
||||
});
|
||||
|
||||
it("should return Turkish text for deeply nested keys", () => {
|
||||
expect(t("subscription.features.vinSearch")).toBe(
|
||||
"S\u0131n\u0131rs\u0131z VIN arama",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return Turkish text for nav keys", () => {
|
||||
expect(t("nav.search")).toBe("Arama");
|
||||
expect(t("nav.settings")).toBe("Ayarlar");
|
||||
});
|
||||
|
||||
it("should return Turkish text for error messages", () => {
|
||||
expect(t("errors.generic")).toBe(
|
||||
"Bir hata olu\u015ftu. L\u00fctfen tekrar deneyin.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nested key resolution", () => {
|
||||
it("should resolve two-level nesting", () => {
|
||||
expect(t("common.cancel")).toBe("\u0130ptal");
|
||||
});
|
||||
|
||||
it("should resolve three-level nesting", () => {
|
||||
expect(t("subscription.plans.full.name")).toBe("Full Paket");
|
||||
});
|
||||
|
||||
it("should resolve settings tabs", () => {
|
||||
expect(t("settings.tabs.profile")).toBe("Profil");
|
||||
expect(t("settings.tabs.security")).toBe("G\u00fcvenlik");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown key returns key itself", () => {
|
||||
it("should return the key string for completely unknown key", () => {
|
||||
expect(t("nonexistent.key")).toBe("nonexistent.key");
|
||||
});
|
||||
|
||||
it("should return the key string for partially valid path", () => {
|
||||
expect(t("common.nonexistent")).toBe("common.nonexistent");
|
||||
});
|
||||
|
||||
it("should return the key string for deeply nested unknown key", () => {
|
||||
expect(t("a.b.c.d.e")).toBe("a.b.c.d.e");
|
||||
});
|
||||
});
|
||||
|
||||
describe("locale switch", () => {
|
||||
it("should return English text after switching to en", () => {
|
||||
useI18nStore.getState().setLocale("en");
|
||||
|
||||
expect(t("common.save")).toBe("Save");
|
||||
expect(t("auth.login")).toBe("Log In");
|
||||
});
|
||||
|
||||
it("should return Turkish text after switching back to tr", () => {
|
||||
useI18nStore.getState().setLocale("en");
|
||||
expect(t("common.save")).toBe("Save");
|
||||
|
||||
useI18nStore.getState().setLocale("tr");
|
||||
expect(t("common.save")).toBe("Kaydet");
|
||||
});
|
||||
|
||||
it("should handle switching locale and reading nested keys", () => {
|
||||
useI18nStore.getState().setLocale("en");
|
||||
expect(t("subscription.plans.full.name")).toBe("Full Package");
|
||||
|
||||
useI18nStore.getState().setLocale("tr");
|
||||
expect(t("subscription.plans.full.name")).toBe("Full Paket");
|
||||
});
|
||||
|
||||
it("should still return key for unknown keys after locale switch", () => {
|
||||
useI18nStore.getState().setLocale("en");
|
||||
expect(t("does.not.exist")).toBe("does.not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
69
apps/web/src/lib/i18n.ts
Normal file
69
apps/web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import enMessages from "@/messages/en.json";
|
||||
import trMessages from "@/messages/tr.json";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Locale = "tr" | "en";
|
||||
|
||||
const messages: Record<Locale, Record<string, unknown>> = {
|
||||
tr: trMessages,
|
||||
en: enMessages,
|
||||
};
|
||||
|
||||
interface I18nState {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
export const useI18nStore = create<I18nState>((set) => ({
|
||||
locale: "tr",
|
||||
setLocale: (locale) => {
|
||||
set({ locale });
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("sase-locale", locale);
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export function initLocale(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem("sase-locale") as Locale | null;
|
||||
if (saved && (saved === "tr" || saved === "en")) {
|
||||
useI18nStore.getState().setLocale(saved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(obj: unknown, path: string): string {
|
||||
const keys = path.split(".");
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined || typeof current !== "object") {
|
||||
return path;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
if (typeof current === "string") {
|
||||
return current;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function t(key: string): string {
|
||||
const locale = useI18nStore.getState().locale;
|
||||
return getNestedValue(messages[locale], key);
|
||||
}
|
||||
|
||||
export function useTranslation() {
|
||||
const locale = useI18nStore((state) => state.locale);
|
||||
const setLocale = useI18nStore((state) => state.setLocale);
|
||||
|
||||
const translate = (key: string): string => {
|
||||
return getNestedValue(messages[locale], key);
|
||||
};
|
||||
|
||||
return { t: translate, locale, setLocale };
|
||||
}
|
||||
25
apps/web/src/lib/query-client.ts
Normal file
25
apps/web/src/lib/query-client.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined;
|
||||
|
||||
export function getQueryClient() {
|
||||
if (typeof window === "undefined") {
|
||||
return makeQueryClient();
|
||||
}
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient();
|
||||
}
|
||||
return browserQueryClient;
|
||||
}
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user