Merge pull request 'dev' (#42) from dev into main

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-05-24 17:44:02 +00:00
22 changed files with 7076 additions and 210 deletions

View File

@@ -0,0 +1,18 @@
CREATE TABLE "blog_posts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(180) NOT NULL,
"title" varchar(255) NOT NULL,
"meta_description" varchar(320),
"body_markdown" text NOT NULL,
"tags" jsonb DEFAULT '[]'::jsonb NOT NULL,
"cover_image" varchar(500),
"status" varchar(20) DEFAULT 'published' NOT NULL,
"source" varchar(20) DEFAULT 'panel' NOT NULL,
"published_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "blog_posts_slug_idx" ON "blog_posts" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "blog_posts_published_at_idx" ON "blog_posts" USING btree ("published_at");--> statement-breakpoint
CREATE INDEX "blog_posts_status_idx" ON "blog_posts" USING btree ("status");

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,13 @@
"when": 1779573406942,
"tag": "0007_natural_red_wolf",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1779633572753,
"tag": "0008_common_sumo",
"breakpoints": true
}
]
}

View File

@@ -12,6 +12,7 @@ import { BrandsModule } from "./brands/brands.module";
import { CatalogModule } from "./catalog/catalog.module";
import { CategoriesModule } from "./categories/categories.module";
import { ChangelogModule } from "./changelog/changelog.module";
import { BlogModule } from "./blog/blog.module";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
import { AuthGuard } from "./common/guards/auth.guard";
import { ImpersonationReadonlyGuard } from "./common/guards/impersonation-readonly.guard";
@@ -85,6 +86,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
AnalyticsModule,
CatalogModule,
ChangelogModule,
BlogModule,
PostHogModule,
TelemetryModule,
InternalAdminModule,

View File

@@ -0,0 +1,59 @@
import { timingSafeEqual } from "node:crypto";
import {
Body,
Controller,
Get,
Headers,
Param,
Post,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { Public } from "../common/decorators/public.decorator";
import type { CreateBlogPost } from "./blog.dto";
import { BlogService } from "./blog.service";
@Controller("blog")
export class BlogController {
constructor(private readonly blogService: BlogService) {}
@Get("posts")
@Public()
async list() {
return this.blogService.findAllPublished();
}
@Get("posts/:slug")
@Public()
async bySlug(@Param("slug") slug: string) {
return this.blogService.findBySlug(slug);
}
// Automation endpoint — published by the Süper Panel content pipeline via n8n.
// Mirrors the changelog/internal Bearer-token pattern.
@Post("posts/internal")
@Public()
async createInternal(
@Headers("authorization") authHeader: string,
@Body() body: CreateBlogPost,
) {
const token = process.env.BLOG_AUTOMATION_TOKEN;
if (!token) {
throw new ServiceUnavailableException("Blog automation endpoint is not configured");
}
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing or invalid Authorization header");
}
const provided = authHeader.slice(7);
if (
provided.length !== token.length ||
!timingSafeEqual(Buffer.from(provided), Buffer.from(token))
) {
throw new UnauthorizedException("Invalid token");
}
if (!body?.title || !body?.body_markdown) {
throw new UnauthorizedException("title and body_markdown are required");
}
return this.blogService.create(body);
}
}

View File

@@ -0,0 +1,38 @@
export type BlogPostStatus = "draft" | "published";
export interface BlogPost {
id: string;
slug: string;
title: string;
metaDescription: string | null;
bodyMarkdown: string;
tags: string[];
coverImage: string | null;
status: BlogPostStatus;
source: string;
publishedAt: string;
createdAt: string;
updatedAt: string;
}
export interface BlogPostListItem {
slug: string;
title: string;
metaDescription: string | null;
tags: string[];
coverImage: string | null;
publishedAt: string;
}
// Wire shape from the Süper Panel content pipeline (via n8n). Field names match
// the content_blog generation schema (snake_case), mapped to columns in the service.
export interface CreateBlogPost {
title: string;
slug?: string;
meta_description?: string;
body_markdown: string;
tags?: string[];
cta?: string;
coverImage?: string;
status?: BlogPostStatus;
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { BlogController } from "./blog.controller";
import { BlogService } from "./blog.service";
@Module({
controllers: [BlogController],
providers: [BlogService],
})
export class BlogModule {}

View File

@@ -0,0 +1,133 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { blogPosts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
import type { BlogPost, BlogPostListItem, CreateBlogPost } from "./blog.dto";
const LIST_CACHE_KEY = "blog:posts:list";
const CACHE_TTL = 1800; // 30 minutes — matches the web staleTime
const PUBLIC_WEB_URL = process.env.PUBLIC_WEB_URL ?? "https://sase.tr";
// URL-safe slug, Turkish-aware. The content_blog prompt already emits a clean
// slug, but we slugify defensively (and to derive one if it's missing).
function slugify(input: string): string {
const map: Record<string, string> = {
ç: "c", ğ: "g", ı: "i", İ: "i", ö: "o", ş: "s", ü: "u",
Ç: "c", Ğ: "g", Ö: "o", Ş: "s", Ü: "u",
};
return input
.split("")
.map((c) => map[c] ?? c)
.join("")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 160) || "yazi";
}
@Injectable()
export class BlogService {
constructor(
@Inject(DATABASE) private db: Database,
private readonly redis: RedisService,
) {}
private toPost(row: typeof blogPosts.$inferSelect): BlogPost {
return {
...row,
status: row.status as BlogPost["status"],
publishedAt: row.publishedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async findAllPublished(): Promise<BlogPostListItem[]> {
const cached = await this.redis.getJson<BlogPostListItem[]>(LIST_CACHE_KEY);
if (cached) return cached;
const rows = await this.db
.select({
slug: blogPosts.slug,
title: blogPosts.title,
metaDescription: blogPosts.metaDescription,
tags: blogPosts.tags,
coverImage: blogPosts.coverImage,
publishedAt: blogPosts.publishedAt,
status: blogPosts.status,
})
.from(blogPosts)
.orderBy(desc(blogPosts.publishedAt));
const items: BlogPostListItem[] = rows
.filter((r) => r.status === "published")
.map((r) => ({
slug: r.slug,
title: r.title,
metaDescription: r.metaDescription,
tags: r.tags,
coverImage: r.coverImage,
publishedAt: r.publishedAt.toISOString(),
}));
await this.redis.setJson(LIST_CACHE_KEY, items, CACHE_TTL);
return items;
}
async findBySlug(slug: string): Promise<BlogPost> {
const result = await this.db
.select()
.from(blogPosts)
.where(eq(blogPosts.slug, slug))
.limit(1);
if (result.length === 0 || result[0].status !== "published") {
throw new NotFoundException("Blog post not found");
}
return this.toPost(result[0]);
}
// Ensures a unique slug by appending a numeric suffix on collision.
private async uniqueSlug(base: string): Promise<string> {
let slug = base;
for (let i = 2; i < 50; i++) {
const existing = await this.db
.select({ id: blogPosts.id })
.from(blogPosts)
.where(eq(blogPosts.slug, slug))
.limit(1);
if (existing.length === 0) return slug;
slug = `${base}-${i}`;
}
return `${base}-${Date.now().toString(36)}`;
}
async create(dto: CreateBlogPost): Promise<BlogPost & { url: string }> {
const baseSlug = slugify(dto.slug?.trim() || dto.title);
const slug = await this.uniqueSlug(baseSlug);
let body = dto.body_markdown ?? "";
if (dto.cta && !body.includes(dto.cta)) body = `${body}\n\n${dto.cta}`;
const [row] = await this.db
.insert(blogPosts)
.values({
slug,
title: dto.title,
metaDescription: dto.meta_description ?? null,
bodyMarkdown: body,
tags: Array.isArray(dto.tags) ? dto.tags.slice(0, 16) : [],
coverImage: dto.coverImage ?? null,
status: dto.status ?? "published",
source: "panel",
})
.returning();
await this.redis.del(LIST_CACHE_KEY);
return { ...this.toPost(row), url: `${PUBLIC_WEB_URL}/blog/${slug}` };
}
}

View File

@@ -500,6 +500,30 @@ export const changelogEntries = pgTable(
(table) => [index("changelog_entries_published_at_idx").on(table.publishedAt)],
);
// ─── Blog posts (auto-published from Süper Panel content pipeline) ──────
export const blogPosts = pgTable(
"blog_posts",
{
id: uuid("id").primaryKey().defaultRandom(),
slug: varchar("slug", { length: 180 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
metaDescription: varchar("meta_description", { length: 320 }),
bodyMarkdown: text("body_markdown").notNull(),
tags: jsonb("tags").$type<string[]>().default([]).notNull(),
coverImage: varchar("cover_image", { length: 500 }),
status: varchar("status", { length: 20 }).default("published").notNull(),
source: varchar("source", { length: 20 }).default("panel").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("blog_posts_slug_idx").on(table.slug),
index("blog_posts_published_at_idx").on(table.publishedAt),
index("blog_posts_status_idx").on(table.status),
],
);
// ─── EMEX Category Translations ─────────────────────
export const emexCategoryTranslations = pgTable(
"emex_category_translations",

View File

@@ -31,6 +31,8 @@
"posthog-js": "^1.347.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.1.0",
"remark-gfm": "^4.0.1",
"remotion": "^4.0.422",
"sileo": "^0.0.7",
"tailwind-merge": "^2.6.0",

View File

@@ -0,0 +1,178 @@
import { useAuth } from "@/hooks/use-auth";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { Button, Separator } from "@sase/ui";
import { Link } from "@tanstack/react-router";
import { Menu, Moon, Sun, X } from "lucide-react";
import { useState } from "react";
// Shared public/marketing header. Mirrors the landing page header so every
// public page (blog, contact, about, pricing, legal, …) has the same nav,
// theme toggle, auth-aware actions, and mobile menu. Anchor links use
// `/#…` so they work from any route (navigate home + scroll).
export function SiteHeader() {
const { isAuthenticated } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isDark, setIsDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
if (theme === "system") return window.matchMedia("(prefers-color-scheme: dark)").matches;
return theme === "dark";
});
const toggleTheme = () => {
const next = isDark ? "light" : "dark";
document.documentElement.classList.toggle("dark", next === "dark");
setUserSetting("theme", next);
setIsDark(next === "dark");
};
return (
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
<Link to="/" className="text-xl font-bold tracking-wider">
SASE
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-8 text-sm text-muted-foreground md:flex">
<a href="/#features" className="transition hover:text-foreground">
Özellikler
</a>
<a href="/#how-it-works" className="transition hover:text-foreground">
Nasıl Çalışır
</a>
<Link to="/pricing" className="transition hover:text-foreground">
Fiyatlar
</Link>
<Link to="/blog" className="transition hover:text-foreground">
Blog
</Link>
<Link to="/contact" className="transition hover:text-foreground">
İletişim
</Link>
</nav>
<div className="hidden items-center gap-3 md:flex">
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
{isAuthenticated ? (
<Link to="/dashboard/search">
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
Panele Git
</Button>
</Link>
) : (
<>
<Link to="/login">
<Button
variant="outline"
className="rounded-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register">
<Button className="rounded-full bg-foreground text-background hover:bg-foreground/90">
30 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
)}
</div>
{/* Mobile toggle */}
<div className="flex items-center gap-2 md:hidden">
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Tema değiştir"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Menü"
>
{mobileMenuOpen ? <X className="size-6" /> : <Menu className="size-6" />}
</button>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="border-t border-border px-4 py-4 md:hidden">
<nav className="flex flex-col gap-3 text-sm text-muted-foreground">
{/* biome-ignore lint/a11y/useValidAnchor: valid hash navigation link that also closes the mobile menu */}
<a
href="/#features"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
Özellikler
</a>
{/* biome-ignore lint/a11y/useValidAnchor: valid hash navigation link that also closes the mobile menu */}
<a
href="/#how-it-works"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
Nasıl Çalışır
</a>
<Link
to="/pricing"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
Fiyatlar
</Link>
<Link
to="/blog"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
Blog
</Link>
<Link
to="/contact"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
İletişim
</Link>
<Separator className="bg-border" />
{isAuthenticated ? (
<Link to="/dashboard/search" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
Panele Git
</Button>
</Link>
) : (
<>
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
<Button
variant="outline"
className="w-full rounded-full border-border text-muted-foreground"
>
Giriş Yap
</Button>
</Link>
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full rounded-full bg-foreground text-background">
30 Gün Ücretsiz Deneyin
</Button>
</Link>
</>
)}
</nav>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,40 @@
import { api } from "@/lib/api-client";
import { useQuery } from "@tanstack/react-query";
export interface BlogPostListItem {
slug: string;
title: string;
metaDescription: string | null;
tags: string[];
coverImage: string | null;
publishedAt: string;
}
export interface BlogPost extends BlogPostListItem {
id: string;
bodyMarkdown: string;
status: string;
source: string;
createdAt: string;
updatedAt: string;
}
const STALE = 30 * 60 * 1000; // 30 min — matches the API Redis cache TTL
export function useBlogPosts() {
return useQuery({
queryKey: ["blog", "list"],
queryFn: () => api.get<BlogPostListItem[]>("/blog/posts"),
staleTime: STALE,
});
}
export function useBlogPost(slug: string, enabled = true) {
return useQuery({
queryKey: ["blog", "post", slug],
queryFn: () => api.get<BlogPost>(`/blog/posts/${slug}`),
staleTime: STALE,
enabled: enabled && Boolean(slug),
retry: false,
});
}

View File

@@ -1,4 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header";
import { Button } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -16,21 +17,7 @@ function AboutPage() {
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Hakkımızda</h1>

View File

@@ -1,4 +1,6 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header";
import { useBlogPosts } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -7,7 +9,11 @@ export const Route = createFileRoute("/blog")({
component: BlogPage,
});
const posts = [
type PostCard = { title: string; description: string; date: string; slug: string };
// Original hand-authored posts. Kept so they never disappear; auto-published
// posts from the content pipeline are merged in from the API below.
const staticPosts: PostCard[] = [
{
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
@@ -45,23 +51,25 @@ function BlogPage() {
canonical: "https://sase.tr/blog",
});
const { data: apiPosts } = useBlogPosts();
// Merge static + API posts, dedupe by slug (static wins), newest first.
const bySlug = new Map<string, PostCard>();
for (const p of staticPosts) bySlug.set(p.slug, p);
for (const p of apiPosts ?? []) {
if (bySlug.has(p.slug)) continue;
bySlug.set(p.slug, {
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
});
}
const posts = [...bySlug.values()].sort((a, b) => b.date.localeCompare(a.date));
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold">Blog</h1>

View File

@@ -1,7 +1,11 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header";
import { useBlogPost } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
@@ -341,44 +345,75 @@ const POSTS: Record<string, BlogPost> = {
},
};
// ─── MARKDOWN RENDERING (API-backed posts) ─────────────────────────────────────
// Maps markdown to the same prose styling the hand-authored posts use.
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 ───────────────────────────────────────────────────────────────────
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];
const staticPost = POSTS[slug];
const { data: apiPost, isLoading, isError } = useBlogPost(slug, !staticPost);
const title = staticPost?.title ?? apiPost?.title ?? "Blog";
const description = staticPost?.description ?? apiPost?.metaDescription ?? "";
const date = staticPost?.date ?? apiPost?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${post.title} | Sase.tr Blog`,
description: post.description,
canonical: `https://sase.tr/blog/${post.slug}`,
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (staticPost) body = staticPost.content;
else if (apiPost) body = <MarkdownBody markdown={apiPost.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">
<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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
{/* Breadcrumb */}
@@ -387,15 +422,15 @@ function BlogPostPage() {
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{post.title}</span>
<span className="text-foreground">{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>
{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>}
<div className="mt-10">{post.content}</div>
<div className="mt-10">{body}</div>
</article>
{/* Back link */}

View File

@@ -1,4 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header";
import { Button } from "@sase/ui";
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -16,21 +17,7 @@ function ContactPage() {
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">İletişim</h1>
@@ -72,8 +59,24 @@ function ContactPage() {
<CardTitle className="text-lg">Adres</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">İstanbul, Türkiye</p>
<p className="mt-2 text-sm text-muted-foreground">
<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>

View File

@@ -639,6 +639,12 @@ export function HomePage() {
<Link to="/pricing" className="transition hover:text-foreground">
Fiyatlar
</Link>
<Link to="/blog" className="transition hover:text-foreground">
Blog
</Link>
<Link to="/contact" className="transition hover:text-foreground">
İletişim
</Link>
</nav>
<div className="hidden items-center gap-3 md:flex">
@@ -726,6 +732,20 @@ export function HomePage() {
>
Fiyatlar
</Link>
<Link
to="/blog"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
Blog
</Link>
<Link
to="/contact"
className="transition hover:text-foreground"
onClick={() => setMobileMenuOpen(false)}
>
İletişim
</Link>
<Separator className="bg-border" />
{isAuthenticated ? (
<Link to="/dashboard/search" onClick={() => setMobileMenuOpen(false)}>

View File

@@ -1,4 +1,5 @@
import { Button } from "@sase/ui";
import { SiteHeader } from "@/components/site-header";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/kvkk")({
@@ -8,21 +9,7 @@ export const Route = createFileRoute("/kvkk")({
function KvkkPage() {
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">KVKK Aydınlatma Metni</h1>

View File

@@ -1,4 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { SiteHeader } from "@/components/site-header";
import { useTranslation } from "@/lib/i18n";
import { FULL_PLAN_BRAND_LIMIT, formatTRY } from "@sase/shared";
import { Button } from "@sase/ui";
@@ -61,21 +62,7 @@ function PricingPage() {
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 tracking-wider">
SASE
</Link>
<div className="flex items-center gap-4">
<Link to="/login">
<Button variant="ghost">{t("common.login")}</Button>
</Link>
<Link to="/register">
<Button>{t("common.register")}</Button>
</Link>
</div>
</div>
</header>
<SiteHeader />
<main id="main-content" className="container mx-auto px-4 py-24">
<div className="text-center">

View File

@@ -1,4 +1,5 @@
import { Button } from "@sase/ui";
import { SiteHeader } from "@/components/site-header";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/privacy")({
@@ -8,21 +9,7 @@ export const Route = createFileRoute("/privacy")({
function PrivacyPage() {
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Gizlilik Politikası</h1>

View File

@@ -1,4 +1,5 @@
import { Button } from "@sase/ui";
import { SiteHeader } from "@/components/site-header";
import { Link, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/terms")({
@@ -8,21 +9,7 @@ export const Route = createFileRoute("/terms")({
function TermsPage() {
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 tracking-wider">
SASE
</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>
<SiteHeader />
<main className="container mx-auto max-w-3xl px-4 py-16">
<h1 className="text-4xl font-bold">Kullanım Koşulları</h1>

955
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff