feat(web): keep sidebar on blog & contact for logged-in users
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Sidebar linked /blog and /contact pointed at public marketing pages, so
clicking them dropped the user out of the dashboard shell. Extract page
content into shared components (blog-content, contact-content) and add
dashboard-wrapped routes /dashboard/blog, /dashboard/blog/$slug and
/dashboard/contact; sidebar now links to those. Public SEO pages stay
unchanged and remain the canonical URLs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:25:14 +03:00
parent 4a6e673af1
commit 3c9245bd8b
10 changed files with 499 additions and 353 deletions

View File

@@ -0,0 +1,157 @@
import { useBlogPost, useBlogPosts } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Blog content is rendered in two shells: the public marketing pages (/blog)
// and the dashboard-wrapped pages (/dashboard/blog) so logged-in users keep
// the sidebar. Only the internal link targets differ; the canonical URL always
// points at the public page.
type BlogListPath = "/blog" | "/dashboard/blog";
type BlogPostPath = "/blog/$slug" | "/dashboard/blog/$slug";
// ─── LIST ─────────────────────────────────────────────────────────────────────
export function BlogListContent({ postLinkTo }: { postLinkTo: BlogPostPath }) {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
// All posts come from the central Directus CMS through the API.
const { data: apiPosts, isLoading } = useBlogPosts();
const posts = (apiPosts ?? [])
.map((p) => ({
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
}))
.sort((a, b) => b.date.localeCompare(a.date));
return (
<>
<h1 className="text-4xl font-bold">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
{isLoading && posts.length === 0 && (
<p className="mt-12 text-muted-foreground">Yükleniyor</p>
)}
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to={postLinkTo} params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</>
);
}
// ─── POST ─────────────────────────────────────────────────────────────────────
// Posts live in the central Directus CMS and arrive as markdown via the API.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
export function BlogPostContent({ slug, listLinkTo }: { slug: string; listLinkTo: BlogListPath }) {
const { data: post, isLoading, isError } = useBlogPost(slug);
const title = post?.title ?? "Blog";
const description = post?.metaDescription ?? "";
const date = post?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return (
<>
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to={listLinkTo} className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
{post?.coverImage && (
<img
src={post.coverImage}
alt={title}
className="mt-8 w-full rounded-lg border object-cover"
/>
)}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to={listLinkTo}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,225 @@
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
// Contact content is rendered in two shells: the public marketing page
// (/contact) and the dashboard-wrapped page (/dashboard/contact) so logged-in
// users keep the sidebar.
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
export function ContactContent() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return (
<>
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@@ -25,7 +25,9 @@ import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settin
import { Route as DashboardServiceTestRouteImport } from './routes/dashboard/service-test'
import { Route as DashboardSearchRouteImport } from './routes/dashboard/search'
import { Route as DashboardHistoryRouteImport } from './routes/dashboard/history'
import { Route as DashboardContactRouteImport } from './routes/dashboard/contact'
import { Route as DashboardChangelogRouteImport } from './routes/dashboard/changelog'
import { Route as DashboardBlogRouteImport } from './routes/dashboard/blog'
import { Route as DashboardBillingRouteImport } from './routes/dashboard/billing'
import { Route as BlogSlugRouteImport } from './routes/blog_/$slug'
import { Route as AuthResetPasswordRouteImport } from './routes/_auth/reset-password'
@@ -38,6 +40,7 @@ import { Route as DashboardCatalogIndexRouteImport } from './routes/dashboard/ca
import { Route as DashboardAdminIndexRouteImport } from './routes/dashboard/admin/index'
import { Route as DemoCategoriesCategoryIdRouteImport } from './routes/demo_/categories_/$categoryId'
import { Route as DashboardOemCodeRouteImport } from './routes/dashboard/oem.$code'
import { Route as DashboardBlogSlugRouteImport } from './routes/dashboard/blog_/$slug'
import { Route as DashboardAdminUsersRouteImport } from './routes/dashboard/admin/users'
import { Route as DashboardAdminReferralsRouteImport } from './routes/dashboard/admin/referrals'
import { Route as DashboardAdminCopyLogsRouteImport } from './routes/dashboard/admin/copy-logs'
@@ -134,11 +137,21 @@ const DashboardHistoryRoute = DashboardHistoryRouteImport.update({
path: '/history',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardContactRoute = DashboardContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardChangelogRoute = DashboardChangelogRouteImport.update({
id: '/changelog',
path: '/changelog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogRoute = DashboardBlogRouteImport.update({
id: '/blog',
path: '/blog',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBillingRoute = DashboardBillingRouteImport.update({
id: '/billing',
path: '/billing',
@@ -201,6 +214,11 @@ const DashboardOemCodeRoute = DashboardOemCodeRouteImport.update({
path: '/oem/$code',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardBlogSlugRoute = DashboardBlogSlugRouteImport.update({
id: '/blog_/$slug',
path: '/blog/$slug',
getParentRoute: () => DashboardRoute,
} as any)
const DashboardAdminUsersRoute = DashboardAdminUsersRouteImport.update({
id: '/admin/users',
path: '/admin/users',
@@ -312,7 +330,9 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof AuthResetPasswordRoute
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -322,6 +342,7 @@ export interface FileRoutesByFullPath {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin/': typeof DashboardAdminIndexRoute
@@ -357,7 +378,9 @@ export interface FileRoutesByTo {
'/reset-password': typeof AuthResetPasswordRoute
'/blog/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -367,6 +390,7 @@ export interface FileRoutesByTo {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo/categories/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin': typeof DashboardAdminIndexRoute
@@ -405,7 +429,9 @@ export interface FileRoutesById {
'/_auth/reset-password': typeof AuthResetPasswordRoute
'/blog_/$slug': typeof BlogSlugRoute
'/dashboard/billing': typeof DashboardBillingRoute
'/dashboard/blog': typeof DashboardBlogRoute
'/dashboard/changelog': typeof DashboardChangelogRoute
'/dashboard/contact': typeof DashboardContactRoute
'/dashboard/history': typeof DashboardHistoryRoute
'/dashboard/search': typeof DashboardSearchRoute
'/dashboard/service-test': typeof DashboardServiceTestRoute
@@ -415,6 +441,7 @@ export interface FileRoutesById {
'/dashboard/admin/copy-logs': typeof DashboardAdminCopyLogsRoute
'/dashboard/admin/referrals': typeof DashboardAdminReferralsRoute
'/dashboard/admin/users': typeof DashboardAdminUsersRoute
'/dashboard/blog_/$slug': typeof DashboardBlogSlugRoute
'/dashboard/oem/$code': typeof DashboardOemCodeRoute
'/demo_/categories_/$categoryId': typeof DemoCategoriesCategoryIdRoute
'/dashboard/admin/': typeof DashboardAdminIndexRoute
@@ -453,7 +480,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -463,6 +492,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog/$slug'
| '/dashboard/oem/$code'
| '/demo/categories/$categoryId'
| '/dashboard/admin/'
@@ -498,7 +528,9 @@ export interface FileRouteTypes {
| '/reset-password'
| '/blog/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -508,6 +540,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog/$slug'
| '/dashboard/oem/$code'
| '/demo/categories/$categoryId'
| '/dashboard/admin'
@@ -545,7 +578,9 @@ export interface FileRouteTypes {
| '/_auth/reset-password'
| '/blog_/$slug'
| '/dashboard/billing'
| '/dashboard/blog'
| '/dashboard/changelog'
| '/dashboard/contact'
| '/dashboard/history'
| '/dashboard/search'
| '/dashboard/service-test'
@@ -555,6 +590,7 @@ export interface FileRouteTypes {
| '/dashboard/admin/copy-logs'
| '/dashboard/admin/referrals'
| '/dashboard/admin/users'
| '/dashboard/blog_/$slug'
| '/dashboard/oem/$code'
| '/demo_/categories_/$categoryId'
| '/dashboard/admin/'
@@ -704,6 +740,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardHistoryRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/contact': {
id: '/dashboard/contact'
path: '/contact'
fullPath: '/dashboard/contact'
preLoaderRoute: typeof DashboardContactRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/changelog': {
id: '/dashboard/changelog'
path: '/changelog'
@@ -711,6 +754,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardChangelogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog': {
id: '/dashboard/blog'
path: '/blog'
fullPath: '/dashboard/blog'
preLoaderRoute: typeof DashboardBlogRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/billing': {
id: '/dashboard/billing'
path: '/billing'
@@ -795,6 +845,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardOemCodeRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/blog_/$slug': {
id: '/dashboard/blog_/$slug'
path: '/blog/$slug'
fullPath: '/dashboard/blog/$slug'
preLoaderRoute: typeof DashboardBlogSlugRouteImport
parentRoute: typeof DashboardRoute
}
'/dashboard/admin/users': {
id: '/dashboard/admin/users'
path: '/admin/users'
@@ -930,7 +987,9 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface DashboardRouteChildren {
DashboardBillingRoute: typeof DashboardBillingRoute
DashboardBlogRoute: typeof DashboardBlogRoute
DashboardChangelogRoute: typeof DashboardChangelogRoute
DashboardContactRoute: typeof DashboardContactRoute
DashboardHistoryRoute: typeof DashboardHistoryRoute
DashboardSearchRoute: typeof DashboardSearchRoute
DashboardServiceTestRoute: typeof DashboardServiceTestRoute
@@ -940,6 +999,7 @@ interface DashboardRouteChildren {
DashboardAdminCopyLogsRoute: typeof DashboardAdminCopyLogsRoute
DashboardAdminReferralsRoute: typeof DashboardAdminReferralsRoute
DashboardAdminUsersRoute: typeof DashboardAdminUsersRoute
DashboardBlogSlugRoute: typeof DashboardBlogSlugRoute
DashboardOemCodeRoute: typeof DashboardOemCodeRoute
DashboardAdminIndexRoute: typeof DashboardAdminIndexRoute
DashboardCatalogIndexRoute: typeof DashboardCatalogIndexRoute
@@ -960,7 +1020,9 @@ interface DashboardRouteChildren {
const DashboardRouteChildren: DashboardRouteChildren = {
DashboardBillingRoute: DashboardBillingRoute,
DashboardBlogRoute: DashboardBlogRoute,
DashboardChangelogRoute: DashboardChangelogRoute,
DashboardContactRoute: DashboardContactRoute,
DashboardHistoryRoute: DashboardHistoryRoute,
DashboardSearchRoute: DashboardSearchRoute,
DashboardServiceTestRoute: DashboardServiceTestRoute,
@@ -970,6 +1032,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
DashboardAdminCopyLogsRoute: DashboardAdminCopyLogsRoute,
DashboardAdminReferralsRoute: DashboardAdminReferralsRoute,
DashboardAdminUsersRoute: DashboardAdminUsersRoute,
DashboardBlogSlugRoute: DashboardBlogSlugRoute,
DashboardOemCodeRoute: DashboardOemCodeRoute,
DashboardAdminIndexRoute: DashboardAdminIndexRoute,
DashboardCatalogIndexRoute: DashboardCatalogIndexRoute,

View File

@@ -1,61 +1,18 @@
import { BlogListContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header";
import { useBlogPosts } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog")({
component: BlogPage,
});
function BlogPage() {
usePageMeta({
title: "Blog — Sase.tr | Şase & Yedek Parça Rehberi",
description: "Şase numarası okuma, OEM vs muadil parça, dijital dönüşüm ve daha fazlası.",
canonical: "https://sase.tr/blog",
});
// All posts come from the central Directus CMS through the API.
const { data: apiPosts, isLoading } = useBlogPosts();
const posts = (apiPosts ?? [])
.map((p) => ({
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
}))
.sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold">Blog</h1>
<p className="mt-4 text-lg text-muted-foreground">
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
</p>
{isLoading && posts.length === 0 && (
<p className="mt-12 text-muted-foreground">Yükleniyor</p>
)}
<div className="mt-12 grid gap-6 sm:grid-cols-2">
{posts.map((post) => (
<Link key={post.slug} to="/blog/$slug" params={{ slug: post.slug }}>
<Card className="h-full transition hover:border-foreground/20">
<CardHeader>
<CardDescription>{post.date}</CardDescription>
<CardTitle className="text-lg">{post.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{post.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
<BlogListContent postLinkTo="/blog/$slug" />
</main>
</div>
);

View File

@@ -1,42 +1,6 @@
import { BlogPostContent } from "@/components/blog-content";
import { SiteHeader } from "@/components/site-header";
import { useBlogPost } from "@/hooks/use-blog";
import { usePageMeta } from "@/hooks/use-page-meta";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── MARKDOWN RENDERING ───────────────────────────────────────────────────────
// Posts live in the central Directus CMS and arrive as markdown via the API.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => <ul className="list-disc space-y-2 pl-6" {...props} />,
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => <a className="text-foreground underline" {...props} />,
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
// ─── ROUTE ───────────────────────────────────────────────────────────────────
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage,
@@ -44,65 +8,13 @@ export const Route = createFileRoute("/blog_/$slug")({
function BlogPostPage() {
const { slug } = Route.useParams();
const { data: post, isLoading, isError } = useBlogPost(slug);
const title = post?.title ?? "Blog";
const description = post?.metaDescription ?? "";
const date = post?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (post) body = <MarkdownBody markdown={post.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}</p>
);
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link to="/blog" className="transition hover:text-foreground">
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{title}</span>
</nav>
<article>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
{post?.coverImage && (
<img
src={post.coverImage}
alt={title}
className="mt-8 w-full rounded-lg border object-cover"
/>
)}
<div className="mt-10">{body}</div>
</article>
{/* Back link */}
<div className="mt-16 border-t pt-8">
<Link
to="/blog"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition hover:text-foreground"
>
Tüm yazılar
</Link>
</div>
<BlogPostContent slug={slug} listLinkTo="/blog" />
</main>
</div>
);

View File

@@ -1,230 +1,18 @@
import { ContactContent } from "@/components/contact-content";
import { SiteHeader } from "@/components/site-header";
import { Turnstile, type TurnstileHandle } from "@/components/turnstile";
import { usePageMeta } from "@/hooks/use-page-meta";
import { ApiError, api } from "@/lib/api-client";
import { toast } from "@/lib/toast";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@sase/ui";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { z } from "zod";
export const Route = createFileRoute("/contact")({
component: ContactPage,
});
// Backend (POST /contact) ile aynı kurallar
const contactSchema = z.object({
name: z.string().trim().min(2, "Ad en az 2 karakter olmalı").max(100, "Ad çok uzun"),
email: z.string().trim().email("Geçerli bir e-posta adresi girin").max(200),
subject: z.string().trim().max(200, "Konu çok uzun").optional(),
message: z.string().trim().min(10, "Mesaj en az 10 karakter olmalı").max(5000, "Mesaj çok uzun"),
});
type FieldErrors = Partial<Record<"name" | "email" | "subject" | "message", string>>;
const textareaClass =
"flex min-h-[140px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function ContactForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [captchaToken, setCaptchaToken] = useState("");
const [loading, setLoading] = useState(false);
const turnstileRef = useRef<TurnstileHandle>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const parsed = contactSchema.safeParse({ name, email, subject, message });
if (!parsed.success) {
const next: FieldErrors = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof FieldErrors;
if (key && !next[key]) next[key] = issue.message;
}
setErrors(next);
return;
}
setErrors({});
setLoading(true);
try {
await api.post("/contact", { ...parsed.data, turnstileToken: captchaToken });
toast.success("Mesajınız gönderildi", {
description: "En kısa sürede size dönüş yapacağız.",
});
setName("");
setEmail("");
setSubject("");
setMessage("");
turnstileRef.current?.reset();
setCaptchaToken("");
} catch (err) {
// Token tek kullanımlık — başarısız denemeden sonra widget'ı sıfırla
turnstileRef.current?.reset();
setCaptchaToken("");
const msg =
err instanceof ApiError ? err.message : "Mesaj gönderilemedi. Lütfen tekrar deneyin.";
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Bize yazın</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="contact-name">Ad Soyad</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Adınız"
aria-invalid={!!errors.name}
disabled={loading}
/>
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-email">E-posta</Label>
<Input
id="contact-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ornek@eposta.com"
aria-invalid={!!errors.email}
disabled={loading}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email}</p>}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Konu (opsiyonel)</Label>
<Input
id="contact-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajınızın konusu"
aria-invalid={!!errors.subject}
disabled={loading}
/>
{errors.subject && <p className="text-sm text-destructive">{errors.subject}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-message">Mesaj</Label>
<textarea
id="contact-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Size nasıl yardımcı olabiliriz?"
aria-invalid={!!errors.message}
disabled={loading}
className={textareaClass}
/>
{errors.message && <p className="text-sm text-destructive">{errors.message}</p>}
</div>
<Turnstile
ref={turnstileRef}
onVerify={setCaptchaToken}
onExpire={() => setCaptchaToken("")}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Gönderiliyor
</>
) : (
"Gönder"
)}
</Button>
</form>
</CardContent>
</Card>
);
}
function ContactPage() {
usePageMeta({
title: "İletişim — Sase.tr",
description: "Sase.tr destek ve iletişim — sorularınız için bize ulaşın.",
canonical: "https://sase.tr/contact",
});
return (
<div className="min-h-screen">
<SiteHeader />
<main className="container mx-auto max-w-5xl px-4 py-16">
<h1 className="text-4xl font-bold">İletişim</h1>
<p className="mt-4 text-lg text-muted-foreground">
Sorularınız, önerileriniz veya birliği talepleriniz için bize ulaşın.
</p>
<div className="mt-12 grid gap-6 lg:grid-cols-5">
{/* İletişim formu */}
<div className="lg:col-span-3">
<ContactForm />
</div>
{/* İletişim bilgileri */}
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Destek</CardTitle>
</CardHeader>
<CardContent>
<a href="mailto:destek@sase.tr" className="text-primary underline">
destek@sase.tr
</a>
<p className="mt-2 text-sm text-muted-foreground">
Teknik sorunlar ve hesap işlemleri için.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
<span className="text-foreground">Firma Adı:</span> THINXTRA LLC
</p>
<p>
<span className="text-foreground">Adres:</span> 1209 Mountain Road PL NE #11131
</p>
<p>
<span className="text-foreground">Şehir:</span> Albuquerque, NM 87110
</p>
<p>
<span className="text-foreground">Ülke:</span> United States
</p>
<p>
<span className="text-foreground">Vergi No:</span> 36-5177177
</p>
</div>
<p className="mt-3 text-sm text-muted-foreground">
Çalışma saatleri: Pazartesi Cuma, 09:00 18:00
</p>
</CardContent>
</Card>
</div>
</div>
<ContactContent />
</main>
</div>
);

View File

@@ -82,8 +82,8 @@ const accountItems: readonly NavItem[] = [
const supportItems: readonly NavItem[] = [
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
{ to: "/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
{ to: "/dashboard/contact", labelKey: "nav.contact", icon: Mail },
{ to: "/dashboard/blog", labelKey: "nav.blog", icon: BookOpen },
];
const adminItems: readonly NavItem[] = [

View File

@@ -0,0 +1,14 @@
import { BlogListContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog")({
component: DashboardBlogPage,
});
function DashboardBlogPage() {
return (
<div className="mx-auto max-w-4xl">
<BlogListContent postLinkTo="/dashboard/blog/$slug" />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { BlogPostContent } from "@/components/blog-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/blog_/$slug")({
component: DashboardBlogPostPage,
});
function DashboardBlogPostPage() {
const { slug } = Route.useParams();
return (
<div className="mx-auto max-w-3xl">
<BlogPostContent slug={slug} listLinkTo="/dashboard/blog" />
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { ContactContent } from "@/components/contact-content";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/contact")({
component: DashboardContactPage,
});
function DashboardContactPage() {
return (
<div className="mx-auto max-w-5xl">
<ContactContent />
</div>
);
}