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:
@@ -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 & 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 & 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 & 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
apps/web/public/favicon.svg
Normal file
5
apps/web/public/favicon.svg
Normal 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 |
6
apps/web/public/robots.txt
Normal file
6
apps/web/public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /dashboard/
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://sase.tr/sitemap.xml
|
||||
53
apps/web/public/sitemap.xml
Normal file
53
apps/web/public/sitemap.xml
Normal 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>
|
||||
100
apps/web/scripts/prerender.mjs
Normal file
100
apps/web/scripts/prerender.mjs
Normal 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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
64
apps/web/src/hooks/use-page-meta.ts
Normal file
64
apps/web/src/hooks/use-page-meta.ts
Normal 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]);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
406
apps/web/src/routes/blog_/$slug.tsx
Normal file
406
apps/web/src/routes/blog_/$slug.tsx
Normal 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 açı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, iç 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 iş 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 açılır.
|
||||
Şema üzerindeki parçalara tıklayarak OEM kodunu, açı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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user