feat: SEO altyapısı — pre-render, meta tags, blog posts, robots/sitemap

- vite-plugin-prerender yerine Playwright tabanlı post-build pre-render scripti
  (apps/web/scripts/prerender.mjs): 10 public route statik HTML'e dönüştürülüyor
- usePageMeta hook: her public route'da özgün title/description/canonical/OG tags
- index.html: favicon.svg, canonical, OG/Twitter Card, geliştirilmiş title+desc
- apps/web/public/: robots.txt, sitemap.xml (10 URL), favicon.svg
- nginx HSTS header eklendi (Strict-Transport-Security)
- Blog post sayfaları: blog_/$slug.tsx (4 tam yazı, 600-800 kelime)
- blog.tsx: post kartlarına Link eklendi
- Homepage: Schema.org JSON-LD (Organization + SoftwareApplication)
- Footer: placeholder href="#" sosyal linkler kaldırıldı
- deploy.sh + deploy.yml: playwright install chromium + prerender adımı

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-22 14:15:35 +00:00
parent f1a27810db
commit 7194393e6d
32 changed files with 1022 additions and 539 deletions

View File

@@ -17,6 +17,7 @@ import {
referrals,
} from "../database/schema/core";
import { hashPassword } from "better-auth/crypto";
import { generateReferralCode } from "@sase/shared";
import { AnalyticsService } from "../analytics/analytics.service";
@Injectable()
@@ -55,6 +56,7 @@ export class AdminService {
email: data.email,
emailVerified: true,
role: data.role || "user",
referralCode: generateReferralCode(),
})
.returning({
id: users.id,

View File

@@ -3,9 +3,9 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { randomUUID } from "crypto";
import * as schema from "../database/schema/core";
import type { EmailService } from "../email/email.service";
import { generateReferralCode } from "@sase/shared";
let authInstance: ReturnType<typeof betterAuth> | null = null;
@@ -69,6 +69,20 @@ export function createAuth(databaseUrl: string, secret: string, baseUrl: string,
},
}
: {}),
databaseHooks: {
user: {
create: {
before: async (userData) => {
return {
data: {
...userData,
referralCode: generateReferralCode(),
},
};
},
},
},
},
session: {
cookieCache: {
enabled: true,

View File

@@ -392,6 +392,7 @@ export class CategoriesService {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
@@ -533,7 +534,16 @@ export class CategoriesService {
}
}
} catch (err) {
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${(err as Error).message}`);
const msg = (err as Error).message;
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`);
// HTTP 400 = upstream API has no parts for this group; mark unavailable to prevent infinite retries
if (msg.includes("HTTP 400")) {
await this.db
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`);
}
}
} else if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
@@ -736,6 +746,7 @@ export class CategoriesService {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: dbParts,
schemaPics: mappedPics,
hotspots: mappedHotspots,

View File

@@ -35,8 +35,7 @@ export class ReferralsService {
}
async getMyReferrals(userId: string) {
const user = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (user.length === 0) throw new NotFoundException("Kullanıcı bulunamadı");
const referralCode = await this.ensureReferralCode(userId);
const myReferrals = await this.db
.select()
@@ -44,7 +43,7 @@ export class ReferralsService {
.where(eq(referrals.referrerId, userId));
return {
referralCode: user[0].referralCode,
referralCode,
totalReferrals: myReferrals.length,
referrals: myReferrals,
};

View File

@@ -561,7 +561,7 @@ export class VehiclesService {
"prefetch-init",
{ vehicleId, source },
{
jobId: `prefetch:${vehicleId}`,
jobId: `prefetch-${vehicleId}`,
delay: 5 * 60 * 1000, // 5 min initial delay
},
);

View File

@@ -3,11 +3,29 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sase.tr — Yedek Parça Arama</title>
<title>Şase Numarası Sorgulama &amp; OEM Parça Kataloğu | Sase.tr</title>
<meta
name="description"
content="Araç VIN numaranızı girin, orijinal yedek parça kataloglarına anında erişin."
content="Araç şase numarasını girin, saniyeler içinde doğru OEM parça kodlarına ulaşın. 27+ marka, 243.000+ parça. Yanlış parça iadesine son."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href="https://sase.tr" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://sase.tr" />
<meta property="og:title" content="Şase Numarası Sorgulama &amp; OEM Parça Kataloğu | Sase.tr" />
<meta property="og:description" content="Araç şase numarasını girin, saniyeler içinde doğru OEM parça kodlarına ulaşın. 27+ marka, 243.000+ parça." />
<meta property="og:image" content="https://sase.tr/og-image.png" />
<meta property="og:locale" content="tr_TR" />
<meta property="og:site_name" content="Sase.tr" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Şase Numarası Sorgulama &amp; OEM Parça Kataloğu | Sase.tr" />
<meta name="twitter:description" content="Araç şase numarasını girin, saniyeler içinde doğru OEM parça kodlarına ulaşın. 27+ marka, 243.000+ parça." />
<meta name="twitter:image" content="https://sase.tr/og-image.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link

View File

@@ -7,6 +7,7 @@
"dev": "vite --port 3000",
"generate-routes": "tsr generate",
"build": "tsr generate && tsc -b && vite build",
"prerender": "node scripts/prerender.mjs",
"preview": "vite preview --port 3000",
"lint": "biome check src/",
"typecheck": "tsc --noEmit",

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#10b981"/>
<text x="16" y="23" text-anchor="middle" font-family="Inter,system-ui,sans-serif"
font-weight="700" font-size="20" fill="white">S</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /dashboard/
Disallow: /api/
Sitemap: https://sase.tr/sitemap.xml

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://sase.tr/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://sase.tr/pricing</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://sase.tr/about</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://sase.tr/blog</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://sase.tr/blog/vin-nedir-nasil-okunur</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://sase.tr/blog/orijinal-mi-muadil-mi</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://sase.tr/blog/dijital-donusum</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://sase.tr/blog/dogru-parcayi-bulun</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://sase.tr/contact</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://sase.tr/demo</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

View File

@@ -0,0 +1,100 @@
/**
* Post-build pre-renderer for sase.tr public pages.
*
* Starts a local vite preview server, visits each public route with
* headless Chromium, and saves the fully-rendered HTML to dist/.
* Crawlers that don't execute JavaScript receive complete HTML.
*
* Usage: node scripts/prerender.mjs
* Requires: dist/ to already exist (run `vite build` first)
*/
import { chromium } from "playwright";
import { preview } from "vite";
import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
const DIST = join(ROOT, "dist");
const PORT = 4174;
/** Public routes to pre-render. Dashboard/auth routes are excluded — they require login. */
const ROUTES = [
"/",
"/pricing",
"/about",
"/blog",
"/blog/vin-nedir-nasil-okunur",
"/blog/orijinal-mi-muadil-mi",
"/blog/dijital-donusum",
"/blog/dogru-parcayi-bulun",
"/contact",
"/demo",
];
async function prerender() {
if (!existsSync(DIST)) {
console.error("❌ dist/ not found — run `vite build` first.");
process.exit(1);
}
console.log("🔍 Starting pre-render...\n");
// Start vite preview server (serves from dist/)
const server = await preview({
root: ROOT,
preview: { port: PORT, strictPort: false },
logLevel: "silent",
});
const browser = await chromium.launch({ args: ["--no-sandbox"] });
try {
for (const route of ROUTES) {
const page = await browser.newPage();
// Block all /api/ requests so auth resolves to unauthenticated immediately.
// Without this the page hangs waiting for session checks.
await page.route("**/api/**", (r) =>
r.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ success: false, error: { code: "UNAUTHORIZED" } }),
}),
);
await page.goto(`http://localhost:${PORT}${route}`, {
waitUntil: "networkidle",
timeout: 30_000,
});
// Extra settle time for any deferred renders (animations, etc.)
await page.waitForTimeout(300);
const html = await page.content();
if (route === "/") {
writeFileSync(join(DIST, "index.html"), html, "utf-8");
} else {
const dir = join(DIST, route);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "index.html"), html, "utf-8");
}
await page.close();
console.log(`${route}`);
}
} finally {
await browser.close();
await server.close();
}
console.log("\n✅ Pre-render complete — all routes saved to dist/\n");
}
prerender().catch((err) => {
console.error("❌ Pre-render failed:", err.message);
process.exit(1);
});

View File

@@ -1,7 +1,6 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Link, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronRight, ArrowLeft, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { Card, CardContent } from "@sase/ui";
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
@@ -22,28 +21,17 @@ interface CategoryGridProps {
vehicleId: string;
}
interface BreadcrumbItem {
id: string;
name: string;
categories: Category[];
}
export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [breadcrumbs, setBreadcrumbs] = useState<BreadcrumbItem[]>([]);
const [currentCategories, setCurrentCategories] = useState(categories);
const [loading, setLoading] = useState(false);
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
const [navKey, setNavKey] = useState(0);
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
const prefetchedRef = useRef<Set<string>>(new Set());
// Prefetch schema images for leaf categories in batches of 2
const prefetchedRef = useRef<Set<string>>(new Set());
useEffect(() => {
prefetchedRef.current.clear();
setImageOverrides(new Map());
const cats = currentCategories;
const leafsWithoutImage = cats.filter(
const leafsWithoutImage = categories.filter(
(c) =>
c.children !== undefined &&
c.children.length === 0 &&
@@ -56,7 +44,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
return;
}
const parentId = cats[0]?.parentId;
const parentId = categories[0]?.parentId;
let didCancel = false;
const BATCH_SIZE = 2;
@@ -78,14 +66,13 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
`/categories/${parentId}/children`,
);
if (!didCancel && refreshed?.length) {
setCurrentCategories((prev) =>
prev.map((c) => {
const updated = refreshed.find((r) => r.id === c.id);
return updated?.schemaImageUrl
? { ...c, schemaImageUrl: updated.schemaImageUrl }
: c;
}),
);
setImageOverrides((prev) => {
const next = new Map(prev);
for (const r of refreshed) {
if (r.schemaImageUrl) next.set(r.id, r.schemaImageUrl);
}
return next;
});
}
} catch {}
}
@@ -96,100 +83,7 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
return () => {
didCancel = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navKey, vehicleId]);
const handleDrillDown = useCallback(
async (category: Category) => {
// Show cached children immediately if available, then enrich from API
const cachedChildren = category.children && category.children.length > 0 ? category.children : null;
if (cachedChildren) {
setBreadcrumbs((prev) => [
...prev,
{ id: category.id, name: category.name, categories: currentCategories },
]);
setCurrentCategories(cachedChildren);
setNavKey((k) => k + 1);
// Enrich with schema images from API in background
queryClient
.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () =>
api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
})
.then((enriched) => {
if (enriched?.length) setCurrentCategories(enriched);
})
.catch(() => {});
return;
}
// Fetch children from API via TanStack Query cache
setLoading(true);
try {
const data = await queryClient.fetchQuery({
queryKey: ["category-children", category.id],
queryFn: () =>
api.get<Category[]>(`/categories/${category.id}/children`),
staleTime: 5 * 60 * 1000,
});
if (data && data.length > 0) {
setBreadcrumbs((prev) => [
...prev,
{ id: category.id, name: category.name, categories: currentCategories },
]);
setCurrentCategories(data);
setNavKey((k) => k + 1);
} else {
// Leaf node — navigate to parts page
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id: vehicleId, categoryId: category.id },
});
}
} catch {
// Fetch failed — treat as leaf
} finally {
setLoading(false);
}
},
[currentCategories, navigate, vehicleId, queryClient],
);
const handleBack = useCallback(() => {
if (breadcrumbs.length === 0) return;
const prev = breadcrumbs[breadcrumbs.length - 1];
setCurrentCategories(prev.categories);
setBreadcrumbs((b) => b.slice(0, -1));
setNavKey((k) => k + 1);
}, [breadcrumbs]);
const handleBreadcrumbClick = useCallback(
(index: number) => {
if (index === -1) {
// Root
setCurrentCategories(categories);
setBreadcrumbs([]);
setNavKey((k) => k + 1);
return;
}
const target = breadcrumbs[index];
// Navigate to the children that were shown when this breadcrumb was created
// We need to re-fetch or use stored data from the next breadcrumb
if (index < breadcrumbs.length - 1) {
const next = breadcrumbs[index + 1];
setCurrentCategories(next.categories);
} else {
setCurrentCategories(target.categories);
}
setBreadcrumbs((b) => b.slice(0, index + 1));
setNavKey((k) => k + 1);
},
[breadcrumbs, categories],
);
}, [categories, vehicleId]);
if (!categories || categories.length === 0) {
return (
@@ -200,93 +94,31 @@ export function CategoryGrid({ categories, vehicleId }: CategoryGridProps) {
}
return (
<div className="space-y-3">
{/* Breadcrumb */}
{breadcrumbs.length > 0 && (
<div className="flex items-center gap-1.5 text-sm">
<button
type="button"
onClick={handleBack}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf =
category.children !== undefined && category.children.length === 0;
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
return (
<Link
key={category.id}
to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
className={category.unavailable ? "opacity-40" : undefined}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => handleBreadcrumbClick(-1)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
Kategoriler
</button>
{breadcrumbs.map((bc, i) => (
<span key={bc.id} className="flex items-center gap-1.5">
<ChevronRight className="h-3 w-3 text-muted-foreground" />
{i === breadcrumbs.length - 1 ? (
<span className="font-medium text-foreground">{bc.name}</span>
) : (
<button
type="button"
onClick={() => handleBreadcrumbClick(i)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{bc.name}
</button>
)}
</span>
))}
</div>
)}
{/* Loading overlay */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{currentCategories.map((category) => {
const Icon = getCategoryIcon(category.name);
const isLeaf =
category.children !== undefined && category.children.length === 0;
if (isLeaf) {
return (
<Link
key={category.id}
to="/dashboard/vehicles/$id/categories/$categoryId"
params={{ id: vehicleId, categoryId: category.id }}
className={category.unavailable ? "opacity-40" : undefined}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
isLoading={prefetchingIds.has(category.id)}
/>
</Link>
);
}
return (
<button
key={category.id}
type="button"
onClick={() => handleDrillDown(category)}
className={category.unavailable ? "text-left opacity-40" : "text-left"}
>
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={category.schemaImageUrl}
isLoading={prefetchingIds.has(category.id)}
/>
</button>
);
})}
</div>
)}
<CategoryCard
name={category.name}
partCount={category.partCount}
Icon={Icon}
schemaImageUrl={schemaImageUrl}
isLeaf={isLeaf}
isLoading={prefetchingIds.has(category.id)}
/>
</Link>
);
})}
</div>
);
}
@@ -296,17 +128,19 @@ function CategoryCard({
partCount,
Icon,
schemaImageUrl,
isLeaf,
isLoading,
}: {
name: string;
partCount?: number;
Icon: React.ComponentType<{ className?: string }>;
schemaImageUrl?: string | null;
isLeaf?: boolean;
isLoading?: boolean;
}) {
const [imgLoaded, setImgLoaded] = useState(false);
if (schemaImageUrl || isLoading) {
if (schemaImageUrl || (isLeaf && isLoading)) {
return (
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
<CardContent className="p-0">

View File

@@ -28,6 +28,16 @@ interface VehicleSelectModalProps {
loading?: boolean;
}
function getDifferingKeys(candidates: PcatCandidate[]): Set<string> {
const diffKeys = new Set<string>();
const allKeys = new Set(candidates.flatMap((c) => (c.parameters ?? []).map((p) => p.key)));
for (const key of allKeys) {
const values = new Set(candidates.map((c) => c.parameters?.find((p) => p.key === key)?.value ?? null));
if (values.size > 1) diffKeys.add(key);
}
return diffKeys;
}
export function VehicleSelectModal({
open,
onClose,
@@ -37,17 +47,7 @@ export function VehicleSelectModal({
loading,
}: VehicleSelectModalProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
function getParamValue(
params: PcatCandidate["parameters"],
keyword: string,
): string | null {
if (!params) return null;
const p = params.find((param) =>
param.key.toLowerCase().includes(keyword),
);
return p?.value || null;
}
const differingKeys = getDifferingKeys(candidates);
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@@ -66,10 +66,12 @@ export function VehicleSelectModal({
<div className="space-y-2">
{candidates.map((car) => {
const year = getParamValue(car.parameters, "year");
const engine = getParamValue(car.parameters, "engine");
const body = getParamValue(car.parameters, "body");
const isSelected = selectedId === car.id;
const sortedParams = [...(car.parameters ?? [])].sort((a, b) => {
const aDiff = differingKeys.has(a.key) ? 0 : 1;
const bDiff = differingKeys.has(b.key) ? 0 : 1;
return aDiff - bDiff || a.key.localeCompare(b.key);
});
return (
<button
@@ -99,24 +101,18 @@ export function VehicleSelectModal({
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1.5">
{year && (
<Badge variant="secondary" className="text-[10px]">
{year}
</Badge>
)}
{engine && (
<Badge variant="secondary" className="text-[10px]">
{engine}
</Badge>
)}
{body && (
<Badge variant="secondary" className="text-[10px]">
{body}
</Badge>
)}
<Badge variant="outline" className="text-[10px]">
{car.catalogId}
</Badge>
{sortedParams.map((p) => {
const isDiff = differingKeys.has(p.key);
return (
<Badge
key={p.key}
variant={isDiff ? "default" : "secondary"}
className={`text-[10px] ${isDiff ? "font-semibold" : "font-normal opacity-70"}`}
>
{p.key.charAt(0).toUpperCase() + p.key.slice(1)}: {p.value}
</Badge>
);
})}
</div>
</div>
<ChevronRight

View File

@@ -0,0 +1,64 @@
import { useEffect } from "react";
const DEFAULT_TITLE = "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr";
const DEFAULT_DESCRIPTION =
"Araç şase numarasını girin, saniyeler içinde doğru OEM parça kodlarına ulaşın. 27+ marka, 243.000+ parça. Yanlış parça iadesine son.";
const DEFAULT_CANONICAL = "https://sase.tr";
interface PageMetaOptions {
title: string;
description: string;
canonical: string;
ogImage?: string;
}
function setMeta(name: string, content: string, attr: "name" | "property" = "name") {
let el = document.querySelector<HTMLMetaElement>(`meta[${attr}="${name}"]`);
if (!el) {
el = document.createElement("meta");
el.setAttribute(attr, name);
document.head.appendChild(el);
}
el.setAttribute("content", content);
}
function setCanonical(href: string) {
let el = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!el) {
el = document.createElement("link");
el.setAttribute("rel", "canonical");
document.head.appendChild(el);
}
el.setAttribute("href", href);
}
export function usePageMeta({ title, description, canonical, ogImage }: PageMetaOptions) {
useEffect(() => {
document.title = title;
setMeta("description", description);
setCanonical(canonical);
const image = ogImage ?? "https://sase.tr/og-image.png";
setMeta("og:title", title, "property");
setMeta("og:description", description, "property");
setMeta("og:url", canonical, "property");
setMeta("og:image", image, "property");
setMeta("twitter:title", title, "name");
setMeta("twitter:description", description, "name");
setMeta("twitter:image", image, "name");
return () => {
document.title = DEFAULT_TITLE;
setMeta("description", DEFAULT_DESCRIPTION);
setCanonical(DEFAULT_CANONICAL);
setMeta("og:title", DEFAULT_TITLE, "property");
setMeta("og:description", DEFAULT_DESCRIPTION, "property");
setMeta("og:url", DEFAULT_CANONICAL, "property");
setMeta("og:image", "https://sase.tr/og-image.png", "property");
setMeta("twitter:title", DEFAULT_TITLE, "name");
setMeta("twitter:description", DEFAULT_DESCRIPTION, "name");
setMeta("twitter:image", "https://sase.tr/og-image.png", "name");
};
}, [title, description, canonical, ogImage]);
}

View File

@@ -33,10 +33,23 @@ export interface SchemaPic {
label: string;
}
export interface CategoryChild {
id: string;
name: string;
children?: CategoryChild[];
partCount?: number;
schemaImageUrl?: string | null;
parentId?: string | null;
unavailable?: boolean;
source?: string;
}
export interface CategorySchema {
id: string;
name: string;
description: string;
parentId?: string | null;
children?: CategoryChild[];
parts: Part[];
schemaPics: SchemaPic[];
hotspots: Hotspot[];

View File

@@ -25,6 +25,7 @@ import { Route as DashboardSettingsRouteImport } from "./routes/dashboard/settin
import { Route as DashboardSearchRouteImport } from "./routes/dashboard/search"
import { Route as DashboardHistoryRouteImport } from "./routes/dashboard/history"
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"
import { Route as AuthRegisterRouteImport } from "./routes/_auth/register"
import { Route as AuthLoginRouteImport } from "./routes/_auth/login"
@@ -119,6 +120,11 @@ const DashboardBillingRoute = DashboardBillingRouteImport.update({
path: "/billing",
getParentRoute: () => DashboardRoute,
} as any)
const BlogSlugRoute = BlogSlugRouteImport.update({
id: "/blog_/$slug",
path: "/blog/$slug",
getParentRoute: () => rootRouteImport,
} as any)
const AuthResetPasswordRoute = AuthResetPasswordRouteImport.update({
id: "/reset-password",
path: "/reset-password",
@@ -209,6 +215,7 @@ export interface FileRoutesByFullPath {
"/login": typeof AuthLoginRoute
"/register": typeof AuthRegisterRoute
"/reset-password": typeof AuthResetPasswordRoute
"/blog/$slug": typeof BlogSlugRoute
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
@@ -239,6 +246,7 @@ export interface FileRoutesByTo {
"/login": typeof AuthLoginRoute
"/register": typeof AuthRegisterRoute
"/reset-password": typeof AuthResetPasswordRoute
"/blog/$slug": typeof BlogSlugRoute
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
@@ -272,6 +280,7 @@ export interface FileRoutesById {
"/_auth/login": typeof AuthLoginRoute
"/_auth/register": typeof AuthRegisterRoute
"/_auth/reset-password": typeof AuthResetPasswordRoute
"/blog_/$slug": typeof BlogSlugRoute
"/dashboard/billing": typeof DashboardBillingRoute
"/dashboard/history": typeof DashboardHistoryRoute
"/dashboard/search": typeof DashboardSearchRoute
@@ -305,6 +314,7 @@ export interface FileRouteTypes {
| "/login"
| "/register"
| "/reset-password"
| "/blog/$slug"
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
@@ -335,6 +345,7 @@ export interface FileRouteTypes {
| "/login"
| "/register"
| "/reset-password"
| "/blog/$slug"
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
@@ -367,6 +378,7 @@ export interface FileRouteTypes {
| "/_auth/login"
| "/_auth/register"
| "/_auth/reset-password"
| "/blog_/$slug"
| "/dashboard/billing"
| "/dashboard/history"
| "/dashboard/search"
@@ -396,6 +408,7 @@ export interface RootRouteChildren {
PricingRoute: typeof PricingRoute
PrivacyRoute: typeof PrivacyRoute
TermsRoute: typeof TermsRoute
BlogSlugRoute: typeof BlogSlugRoute
}
declare module "@tanstack/react-router" {
@@ -512,6 +525,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardBillingRouteImport
parentRoute: typeof DashboardRoute
}
"/blog_/$slug": {
id: "/blog_/$slug"
path: "/blog/$slug"
fullPath: "/blog/$slug"
preLoaderRoute: typeof BlogSlugRouteImport
parentRoute: typeof rootRouteImport
}
"/_auth/reset-password": {
id: "/_auth/reset-password"
path: "/reset-password"
@@ -682,6 +702,7 @@ const rootRouteChildren: RootRouteChildren = {
PricingRoute: PricingRoute,
PrivacyRoute: PrivacyRoute,
TermsRoute: TermsRoute,
BlogSlugRoute: BlogSlugRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -7,24 +7,29 @@ import { signIn, signUp } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { api } from "@/lib/api-client";
import { ShieldCheck } from "lucide-react";
export const Route = createFileRoute("/_auth/register")({
component: RegisterPage,
validateSearch: (search: Record<string, unknown>): { vin?: string } => ({
validateSearch: (search: Record<string, unknown>): { vin?: string; ref?: string } => ({
vin: (search.vin as string) || undefined,
ref: (search.ref as string) || undefined,
}),
});
function RegisterPage() {
const { vin } = Route.useSearch();
const { vin, ref } = Route.useSearch();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [refCode, setRefCode] = useState(ref || "");
const [loading, setLoading] = useState(false);
const redirectUrl = vin
? `/dashboard/subscription?welcome=1&vin=${encodeURIComponent(vin)}`
: "/dashboard/subscription?welcome=1";
const redirectUrl = [
"/dashboard/subscription?welcome=1",
vin ? `&vin=${encodeURIComponent(vin)}` : "",
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
].join("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -32,8 +37,15 @@ function RegisterPage() {
setLoading(true);
try {
await signUp.email({ name, email, password });
await signUp.email({ name, email, password, callbackURL: redirectUrl });
capture("user_signed_up", { method: "email" });
if (refCode.trim()) {
try {
await api.post("/referrals/apply", { code: refCode.trim().toUpperCase() });
} catch {
// Geçersiz/kullanılmış kod — sessizce geç
}
}
toast.success("Hesap oluşturuldu!");
window.location.href = redirectUrl;
} catch {
@@ -128,6 +140,21 @@ function RegisterPage() {
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="refCode">
Referans Kodu <span className="text-muted-foreground">(opsiyonel)</span>
</Label>
<Input
id="refCode"
type="text"
placeholder="örn. MEU8EHF7"
value={refCode}
onChange={(e) => setRefCode(e.target.value.toUpperCase())}
maxLength={20}
autoComplete="off"
className="font-mono uppercase"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : "Kayıt Ol"}
</Button>

View File

@@ -1,11 +1,19 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
export const Route = createFileRoute("/about")({
component: AboutPage,
});
function AboutPage() {
usePageMeta({
title: "Hakkımızda — Sase.tr | Türkiye'nin Şase Sorgulama Platformu",
description:
"Sase.tr, Türkiye yedek parça sektörüne yönelik VIN/şase numarası sorgulama ve OEM parça kataloğu platformudur.",
canonical: "https://sase.tr/about",
});
return (
<div className="min-h-screen">
<header className="border-b">

View File

@@ -1,6 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
export const Route = createFileRoute("/blog")({
component: BlogPage,
@@ -38,6 +39,12 @@ const posts = [
];
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",
});
return (
<div className="min-h-screen">
<header className="border-b">
@@ -64,17 +71,19 @@ function BlogPage() {
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Card key={post.slug}>
<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 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>
</main>

View File

@@ -0,0 +1,406 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { ChevronRight } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
interface BlogPost {
slug: string;
title: string;
description: string;
date: string;
content: React.ReactNode;
}
const POSTS: Record<string, BlogPost> = {
"vin-nedir-nasil-okunur": {
slug: "vin-nedir-nasil-okunur",
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
"17 haneli VIN kodunun her bir bölümünün ne anlama geldiğini, araç geçmişini nasıl ortaya çıkarabileceğinizi adım adım anlatıyoruz.",
date: "2026-02-10",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
VIN (Vehicle Identification Number), yani Araç Tanımlama Numarası, her motorlu taşıta üretim
aşamasında atanan 17 karakterlik benzersiz bir koddur. Türkiye'de "şase numarası" olarak da
bilinen bu kod, aracın üretiminden imhasına kadar tüm yaşam döngüsünü takip etmeye yarar.
</p>
<h2 className="text-xl font-semibold text-foreground">VIN Nereden Okunur?</h2>
<p>
VIN numarasına birçok yerden ulaşabilirsiniz: ön camın sol alt köşesindeki plaka, sürücü
kapısının iç kısmındaki etiket, motor bölmesi veya araç ruhsatı ve fatura bunların
başında gelir. Bazı araçlarda bagaj kapısı iç kısmında da bulunur.
</p>
<h2 className="text-xl font-semibold text-foreground">17 Karakterin Anlamı</h2>
<p>VIN üç ana bölüme ayrılır:</p>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">WMI (1-3. karakterler) — Dünya Üretici Kodu:</strong>{" "}
Aracın hangi ülkede ve hangi fabrikada üretildiğini gösterir. Örneğin "WVW" Volkswagen
Almanya, "ZFA" Fiat İtalya demektir.
</li>
<li>
<strong className="text-foreground">VDS (4-9. karakterler) — Araç Tanımlayıcı Bölüm:</strong>{" "}
Model, kasa tipi, motor hacmi, yakıt türü ve güvenlik donanımları hakkında bilgi içerir.
9. karakter her zaman kontrol karakteridir ve matematiksel bir doğrulama amacı taşır.
</li>
<li>
<strong className="text-foreground">VIS (10-17. karakterler) — Araç Tanımlama Bölümü:</strong>{" "}
Model yılı (10. karakter), üretim fabrikası (11. karakter) ve üretim sıra numarası
(12-17. karakterler) bilgilerini içerir.
</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Örnek: WVWZZZ1JZ3W597935</h2>
<p>
Bu VIN'i inceleyelim: <strong className="font-mono text-foreground">WVW</strong> Volkswagen
Almanya, <strong className="font-mono text-foreground">ZZZ</strong> pazar tanımlayıcı,{" "}
<strong className="font-mono text-foreground">1J</strong> Golf modeli,{" "}
<strong className="font-mono text-foreground">Z</strong> motor tipi,{" "}
<strong className="font-mono text-foreground">3</strong> model yılı 2003,{" "}
<strong className="font-mono text-foreground">W</strong> Wolfsburg fabrikası,{" "}
<strong className="font-mono text-foreground">597935</strong> sıra numarası.
</p>
<h2 className="text-xl font-semibold text-foreground">VIN ile Ne Öğrenebilirsiniz?</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Aracın üretim yılı ve fabrikası</li>
<li>Motor hacmi, yakıt türü ve şanzıman tipi</li>
<li>Kasa tipi ve donanım paketi</li>
<li>Kaza ve hasar geçmişi (resmi kayıtlarda)</li>
<li>Kilometre manipülasyonu şüphesi</li>
<li>Orijinal parça uyumluluğu</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">VIN ile Doğru Parça Bulma</h2>
<p>
VIN'in en kritik kullanım alanlarından biri yedek parça aramadır. Aynı model arabada bile
üretim yılı, motor tipi veya donanım paketine göre farklı parçalar kullanılmış olabilir.
VIN numarasıyla sorgulama yaparak yalnızca aracınıza tam uyumlu OEM parçalara ulaşabilir,
yanlış parça sipariş etme riskini sıfıra indirebilirsiniz.
</p>
<p>
Sase.tr platformunda VIN numaranızı girerek saniyeler içinde aracınızın tam teknik
bilgilerine ve orijinal parça kataloğuna erişebilirsiniz. 27'den fazla markayı destekleyen
platformumuzla yanlış parça siparişlerine son verin.
</p>
</div>
),
},
"orijinal-mi-muadil-mi": {
slug: "orijinal-mi-muadil-mi",
title: "Orijinal Parça mı, Muadil Parça mı?",
description:
"OEM ve aftermarket parçalar arasındaki farkları, avantaj ve dezavantajlarını karşılaştırmalı olarak inceliyoruz.",
date: "2026-01-28",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Araç bakımı ve onarımında en sık sorulan sorulardan biri şudur: "Orijinal mi alsam, muadil
mi?" Bu soru, hem maliyet hem de güvenlik ısından kritik öneme sahiptir. İki seçeneği
tüm boyutlarıyla inceleyelim.
</p>
<h2 className="text-xl font-semibold text-foreground">OEM Parça Nedir?</h2>
<p>
OEM (Original Equipment Manufacturer), aracın üretiminde kullanılan ya da araç üreticisinin
onayladığı orijinal parçalardır. Bu parçalar araç fabrikasında kullanılan parçalarla aynı
spesifikasyonlara sahiptir ve genellikle aynı tedarikçilerden gelir. Üzerinde araç markasının
logosu bulunabilir ya da yalnızca parça numarasıyla satılabilir.
</p>
<h2 className="text-xl font-semibold text-foreground">Aftermarket (Muadil) Parça Nedir?</h2>
<p>
Muadil parçalar, araç üreticisinden bağımsız üçüncü taraf firmalar tarafından üretilir.
Kalite seviyeleri geniş bir yelpazede değişir: premium muadil parçalar OEM kalitesine
yaklaşırken, düşük kaliteli kopya ürünler araç güvenliğini tehlikeye atabilir.
</p>
<h2 className="text-xl font-semibold text-foreground">OEM Parçaların Avantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Araca tam uyum garantisi montaj sorunu yaşanmaz</li>
<li>Fabrika spesifikasyonlarına uygunluk</li>
<li>Araç garantisini etkilemez</li>
<li>Standart kalite tutarlılığı</li>
<li>Orijinal parça numarasıyla kolay temin</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">OEM Parçaların Dezavantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Genellikle muadile göre %20-50 daha pahalı</li>
<li>Her markada ve her bölgede stok bulunmayabilir</li>
<li>Bazı modeller için yüksek bekleme süresi</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Muadil Parçaların Avantajları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Genellikle daha düşük fiyat</li>
<li>Daha kolay erişilebilirlik</li>
<li>Bazı premium muadil markalar üstün performans sunar</li>
<li>Üretimi durdurulmuş eski araçlarda tek seçenek olabilir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Muadil Parçaların Riskleri</h2>
<ul className="list-disc space-y-2 pl-6">
<li>Kalite tutarsızlığı marka ve fiyata göre büyük farklar</li>
<li>Uyumsuzluk riski görünürde aynı ama teknik farklılıklar olabilir</li>
<li>Araç garantisini geçersiz kılabilir</li>
<li>Güvenlik sistemlerini olumsuz etkileyebilir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Hangi Durumlarda Orijinal, Hangisinde Muadil?</h2>
<p>
Fren sistemi, hava yastığı, direksiyon ve motor parçaları gibi güvenlik kritik
bileşenlerde kesinlikle OEM tercih edilmelidir. Kaporta, döşeme veya aksesuar
niteliğindeki parçalarda güvenilir bir muadil marka değerlendirilebilir.
</p>
<h2 className="text-xl font-semibold text-foreground">Doğru Parçayı Nasıl Bulursunuz?</h2>
<p>
İster OEM ister muadil tercih edin, en önemli adım doğru OEM parça numarasını bilmektir.
Sase.tr ile araç VIN numaranızdan yola çıkarak orijinal parça kodlarına ulaşın. Bu kodu
elinizde bulundurmak, hem servisle hem de tedarikçiyle iletişimi kolaylaştırır, yanlış
parça siparişini önler.
</p>
</div>
),
},
"dijital-donusum": {
slug: "dijital-donusum",
title: "Yedek Parça Aramada Dijital Dönüşüm",
description:
"Geleneksel katalog yöntemlerinden dijital platformlara geçiş sürecini ve sektöre etkilerini ele alıyoruz.",
date: "2026-01-15",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Türkiye'nin otomotiv yedek parça sektörü onlarca yıldır basılı kataloglar, telefon
görüşmeleri ve tecrübeye dayalı bilgiyle ayakta durdu. Ancak 2020'lerin ortasına
gelindiğinde bu yöntemler artık yetersiz kalmaktadır. Dijital dönüşüm, sektörde kaçınılmaz
bir zorunluluk hâline gelmiştir.
</p>
<h2 className="text-xl font-semibold text-foreground">Geleneksel Yöntemlerin Sorunları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
<strong className="text-foreground">Zaman kaybı:</strong> Tek bir parça için birden fazla
katalog taramak ortalama 15-20 dakika sürebilir.
</li>
<li>
<strong className="text-foreground">Hata oranı:</strong> Elle yapılan arama ve karşılaştırma
işlemlerinde yanlış parça sipariş riski yüksektir.
</li>
<li>
<strong className="text-foreground">Güncellik sorunu:</strong> Basılı kataloglar yeni
model güncellemelerini yavaş takip eder.
</li>
<li>
<strong className="text-foreground">Ölçeklenemezlik:</strong> Büyüyen filo veya artan
müşteri sayısıyla geleneksel yöntemler çökmektedir.
</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Dijital Dönüşümün Faydaları</h2>
<p>
Dijital platformlar, yedek parça arama sürecini kökten değiştirmektedir:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>VIN bazlı anlık araç tanımlama saniyeler içinde doğru araç tespiti</li>
<li>Çoklu katalog çapraz sorgulama tek arayüzde birden fazla kaynak</li>
<li>Otomatik parça eşleştirme insan hatasını minimuma indirir</li>
<li>Gerçek zamanlı fiyat karşılaştırma</li>
<li>Sipariş geçmişi ve parça takibi</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Sektörde Sayısal Dönüşüm</h2>
<p>
Dijital platforma geçiş yapan işletmelerin deneyimlerine göre:
</p>
<ul className="list-disc space-y-2 pl-6">
<li>Parça arama süresi ortalama %85 kısalmaktadır</li>
<li>Yanlış parça iade oranları %60-80 düşmektedir</li>
<li>Müşteri memnuniyeti belirgin biçimde artmaktadır</li>
<li>Personel kapasitesi daha katma değerli işlere yönlendirilebilmektedir</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Küçük ve Orta Ölçekli İşletmeler İçin Fırsatlar</h2>
<p>
Dijital dönüşüm artık yalnızca büyük zincirlerin ayrıcalığı değildir. Aylık sabit maliyetli
abonelik modelleri sayesinde küçük oto yedek parçacılar ve servisler de kurumsal araçlara
erişebilmektedir. Bu durum, rekabet eşitliğini kısmen sağlamaktadır.
</p>
<h2 className="text-xl font-semibold text-foreground">Sase.tr'nin Rolü</h2>
<p>
Sase.tr, Türkiye'nin yedek parça sektörüne özgü geliştirilen bu dijital dönüşümün
öncüsüdür. VIN/şase numarası sorgulama, çoklu katalog entegrasyonu ve interaktif şema
görüntüleme özellikleriyle geleneksel yapış biçimlerini dönüştürmektedir.
Platform, 27+ marka ve 243.000'den fazla OEM parça numarasıyla sektörün en kapsamlı
dijital kataloğunu sunmaktadır.
</p>
</div>
),
},
"dogru-parcayi-bulun": {
slug: "dogru-parcayi-bulun",
title: "Sase.tr ile Doğru Parçayı İlk Seferde Bulun",
description:
"Platform özelliklerini kullanarak VIN bazlı arama, şema görüntüleme ve parça eşleştirme rehberi.",
date: "2026-01-05",
content: (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<p>
Yanlış parça sipariş etmek hem zaman hem para kaybettirdiği gibi, müşteri memnuniyetini
de olumsuz etkiler. Sase.tr, bu sorunu kökten çözmek için tasarlanmıştır. Bu rehberde
platformu nasıl en verimli şekilde kullanacağınızı adım adım anlatıyoruz.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 1: VIN Numarasını Girin</h2>
<p>
Ana sayfadaki arama kutusuna aracın 17 haneli şase numarasını (VIN) girin. Numara
girilirken anlık doğrulama çubuğu dolmaya başlar 17. karaktere ulaştığınızda sistem
otomatik olarak araç bilgilerini çeker.
</p>
<p>
Sistem saniyeler içinde araç markasını, modelini, yılını ve motor bilgilerini gösterir.
Bilgileri doğrulayın ve "Parça Kataloğuna Devam Et" butonuna tıklayın.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 2: Kategori Seçin</h2>
<p>
Araç tanımlandıktan sonra karşınıza o araca özgü parça kategorileri çıkar. Motor,
Şasi & Süspansiyon, Elektrik, Karoseri, Klima & Isıtma gibi ana kategorilerden
ihtiyacınız olan bölümü seçin.
</p>
<p>
Her ana kategorinin altında detaylı alt kategoriler bulunur. Örneğin "Motor" altında
Silindir Kapağı, Krank Mili, Piston, Yağ Pompası gibi bölümler yer alır.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 3: İnteraktif Şemayı Kullanın</h2>
<p>
Kategori seçildikten sonra o bölgеnin teknik çizimi interaktif şema ekranda ılır.
Şema üzerindeki parçalara tıklayarak OEM kodunu, ıklamasını ve gerekli miktarını
görebilirsiniz.
</p>
<p>
Şemalar zoom ve pan desteğiyle büyütülebilir, yatay veya dikey kaydırılabilir.
Karmaşık motor veya şasi bölgelerinde parça konumunu görsel olarak tespit etmek
iade oranlarını ciddi ölçüde düşürmektedir.
</p>
<h2 className="text-xl font-semibold text-foreground">Adım 4: OEM Kodunu Kopyalayın</h2>
<p>
Doğru parçayı bulduktan sonra OEM kodunu panoya kopyalayın. Bu kodu tedarikçinize,
servis faturanıza veya online sipariş formunuza yapıştırarak yanlış parça riskini
tamamen ortadan kaldırın.
</p>
<h2 className="text-xl font-semibold text-foreground">İpuçları</h2>
<ul className="list-disc space-y-2 pl-6">
<li>
Birden fazla araç için sorgu yapıyorsanız, her birini VIN ile kaydedin; geçmiş
aramalarınıza hızla dönebilirsiniz.
</li>
<li>
Aynı OEM kodu birden fazla katalogda farklı fiyatlarla listelenebilir fiyat
karşılaştırma özelliğini kullanın.
</li>
<li>
Parça bulamadığınızda kategori ağacında bir seviye yukarı çıkarak daha geniş
bir arama yapabilirsiniz.
</li>
<li>
27+ markanın tamamına erişmek için Full Paket aboneliği en avantajlı seçenektir.
</li>
</ul>
<h2 className="text-xl font-semibold text-foreground">Sonuç</h2>
<p>
Sase.tr ile tek bir yanlış parça iadesinden tasarruf ettiğiniz para, aylık abonelik
ücretini karşılar. 7 günlük ücretsiz deneme süresiyle platformu bugün deneyin
kredi kartı gerektirmez.
</p>
</div>
),
},
};
// ─── ROUTE ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage,
beforeLoad: ({ params }) => {
if (!POSTS[params.slug]) {
throw notFound();
}
},
});
function BlogPostPage() {
const { slug } = Route.useParams();
const post = POSTS[slug];
usePageMeta({
title: `${post.title} | Sase.tr Blog`,
description: post.description,
canonical: `https://sase.tr/blog/${post.slug}`,
});
return (
<div className="min-h-screen">
<header className="border-b">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Link to="/" className="text-xl font-bold">
Sase.tr
</Link>
<div className="flex items-center gap-4">
<Link to="/login">
<Button variant="ghost">Giriş Yap</Button>
</Link>
<Link to="/register">
<Button>Kayıt Ol</Button>
</Link>
</div>
</div>
</header>
<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">{post.title}</span>
</nav>
<article>
<time className="text-sm text-muted-foreground">{post.date}</time>
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{post.title}</h1>
<p className="mt-4 text-lg text-muted-foreground">{post.description}</p>
<div className="mt-10">{post.content}</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>
</main>
</div>
);
}

View File

@@ -1,12 +1,19 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
export const Route = createFileRoute("/contact")({
component: ContactPage,
});
function ContactPage() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim. info@sase.tr",
canonical: "https://sase.tr/contact",
});
return (
<div className="min-h-screen">
<header className="border-b">

View File

@@ -123,6 +123,11 @@ function SubscriptionPage() {
const params = new URLSearchParams(window.location.search);
return params.get("welcome") === "1";
});
const [initialRef] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get("ref") || null;
});
const hasAppliedRefRef = useRef(false);
const [selectedPlanKey, setSelectedPlanKey] = useState<string | null>(null);
const [selectedBrandIds, setSelectedBrandIds] = useState<string[]>([]);
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "yearly">("monthly");
@@ -160,6 +165,13 @@ function SubscriptionPage() {
}
}, [subscription?.status, subscription?.plan?.key]);
// Apply referral code from Google OAuth callback
useEffect(() => {
if (!initialRef || !subData || hasAppliedRefRef.current) return;
hasAppliedRefRef.current = true;
api.post("/referrals/apply", { code: initialRef.toUpperCase().trim() }).catch(() => {});
}, [initialRef, subData]);
const cancelMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/cancel"),
onSuccess: () => {

View File

@@ -1,6 +1,7 @@
import { lazy, Suspense } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCategoryParts } from "@/hooks/use-parts";
import { CategoryGrid } from "@/components/categories/category-grid";
import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { ArrowLeft } from "lucide-react";
@@ -27,14 +28,41 @@ function SchemaViewerFallback() {
);
}
function CategoryGridFallback() {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`cat-grid-skel-${i}`} className="h-24 w-full rounded-lg" />
))}
</div>
);
}
export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$categoryId")({
component: VehicleCategoryPage,
});
function VehicleCategoryPage() {
const { id, categoryId } = Route.useParams();
const navigate = useNavigate();
const { data, isLoading, error } = useCategoryParts(id, categoryId);
const hasChildren = data?.children && data.children.length > 0;
const handleBack = () => {
if (data?.parentId) {
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id, categoryId: data.parentId },
});
} else {
navigate({
to: "/dashboard/vehicles/$id",
params: { id },
});
}
};
return (
<div className="space-y-4">
{/* Header */}
@@ -42,7 +70,7 @@ function VehicleCategoryPage() {
<Button
variant="ghost"
size="icon"
onClick={() => window.history.back()}
onClick={handleBack}
title="Geri don"
>
<ArrowLeft className="h-4 w-4" />
@@ -66,17 +94,32 @@ function VehicleCategoryPage() {
</div>
)}
{/* Schema Viewer */}
<Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer
schemaPic={data?.schemaPics?.[0] ?? null}
hotspots={data?.hotspots ?? []}
parts={data?.parts ?? []}
isLoading={isLoading}
{/* Loading state */}
{isLoading && !data && (
<CategoryGridFallback />
)}
{/* Parent category — show children grid */}
{hasChildren && (
<CategoryGrid
categories={data.children!}
vehicleId={id}
categoryId={categoryId}
/>
</Suspense>
)}
{/* Leaf category — show schema viewer */}
{data && !hasChildren && (
<Suspense fallback={<SchemaViewerFallback />}>
<SchemaViewer
schemaPic={data.schemaPics?.[0] ?? null}
hotspots={data.hotspots ?? []}
parts={data.parts ?? []}
isLoading={isLoading}
vehicleId={id}
categoryId={categoryId}
/>
</Suspense>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { Button, Input } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
import {
Search,
Car,
@@ -34,6 +35,12 @@ const EXAMPLE_SCHEMA_PARTS = [
];
function DemoPage() {
usePageMeta({
title: "Demo — Sase.tr | Şase Sorgulamayı Deneyin",
description: "Ücretsiz demo ile şase numarası sorgulama ve OEM parça kataloğunu keşfedin.",
canonical: "https://sase.tr/demo",
});
const [vin, setVin] = useState("");
const [vinPreview, setVinPreview] = useState<{
make: string;

View File

@@ -31,6 +31,7 @@ import { DashboardDemo } from "@/remotion/DashboardDemo";
import { EcommerceDemo } from "@/remotion/EcommerceDemo";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { useAuth } from "@/hooks/use-auth";
import { usePageMeta } from "@/hooks/use-page-meta";
import { useAuthStore } from "@/stores/auth.store";
import { api, ApiError } from "@/lib/api-client";
import { toast } from "@/lib/toast";
@@ -334,6 +335,53 @@ export const Route = createFileRoute("/")({
});
function HomePage() {
usePageMeta({
title: "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
description:
"Araç şase numarasını girin, saniyeler içinde doğru OEM parça kodlarına ulaşın. 27+ marka, 243.000+ parça.",
canonical: "https://sase.tr",
});
useEffect(() => {
const schemas = [
{
"@context": "https://schema.org",
"@type": "Organization",
name: "Sase.tr",
url: "https://sase.tr",
email: "info@sase.tr",
description:
"Türkiye'nin VIN/şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
},
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Sase.tr",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "200",
priceCurrency: "TRY",
},
description: "Araç şase numarası ile OEM yedek parça sorgulama platformu.",
},
];
const scripts = schemas.map((schema) => {
const el = document.createElement("script");
el.type = "application/ld+json";
el.textContent = JSON.stringify(schema);
el.dataset.seoSchema = "true";
document.head.appendChild(el);
return el;
});
return () => {
scripts.forEach((el) => el.remove());
};
}, []);
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [vin, setVin] = useState("");
@@ -1519,16 +1567,11 @@ function HomePage() {
</div>
</div>
<div className="flex items-center gap-4 text-muted-foreground/70">
{/* Social links - minimal */}
<a href="#" className="transition hover:text-foreground">
<svg className="size-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<a href="#" className="transition hover:text-foreground">
<svg className="size-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
<a
href="mailto:info@sase.tr"
className="text-sm transition hover:text-foreground"
>
info@sase.tr
</a>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
import { Badge } from "@sase/ui";
import { usePageMeta } from "@/hooks/use-page-meta";
export const Route = createFileRoute("/pricing")({
component: PricingPage,
@@ -59,6 +60,13 @@ const plans = [
];
function PricingPage() {
usePageMeta({
title: "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
description:
"200 TL/ay'dan başlayan şase numarası ve OEM parça sorgulama planları. 7 gün ücretsiz deneyin.",
canonical: "https://sase.tr/pricing",
});
return (
<div className="min-h-screen">
<header className="border-b">

File diff suppressed because one or more lines are too long