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
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 iş 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>
|
||||
);
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
14
apps/web/src/routes/dashboard/blog.tsx
Normal file
14
apps/web/src/routes/dashboard/blog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal file
16
apps/web/src/routes/dashboard/blog_/$slug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/routes/dashboard/contact.tsx
Normal file
14
apps/web/src/routes/dashboard/contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user