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:
Sase Dev
2026-02-12 02:03:56 +00:00
parent 7fc47ce9cc
commit 56a3c8bfaa
215 changed files with 25043 additions and 0 deletions

View 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);

View 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;

View 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
View 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 };
}

View 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;
}

View 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));
}