feat: hizmet ve proje detay sayfaları + galeri sistemi

Yeni Appwrite kolonları:
- services: content (markdown), features[], faq[] (JSON-encoded), hero_image
- projects: gallery[], content (markdown), client_name, industry, duration, service_slug

Public sayfalar:
- /hizmetler/[slug]: hero + features checklist + markdown content + FAQ accordion
  + ilgili projeler (service_slug eşleşmesi)
- /projeler/[slug]: hero + meta tablosu (müşteri/sektör/süre/yıl) + kapak görseli
  + markdown vaka çalışması + lightbox galeri + diğer projeler

Yeni componentler:
- components/gallery.tsx: lightbox galeri (keyboard nav, prev/next, ESC kapat)
- components/faq-list.tsx: accordion FAQ (tek seferde tek açık)

Admin formları:
- Hizmet formu: hero_image, content (markdown), features (virgülle), FAQ
  (her blok '---' ile ayrılır, ilk satır soru, kalanı cevap)
- Proje formu: gallery (her satıra bir URL), content (markdown), client_name,
  industry, duration, service_slug (dropdown — hizmetlerden seçim)

Linkler:
- ServicesGrid kartları → /hizmetler/[slug]
- ProjectsGrid kartları → /projeler/[slug] (live_url butonu ayrı, target=_blank)

29 route üretiliyor.
This commit is contained in:
Ege Can Komur
2026-05-20 02:46:11 +03:00
parent edd0af76dc
commit c0da5ae8d3
13 changed files with 792 additions and 47 deletions
+197
View File
@@ -0,0 +1,197 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react";
import { marked } from "marked";
import { getServiceBySlug, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { Icon } from "@/components/icon";
import { ProjectsGrid } from "@/components/projects-grid";
import { SectionTitle } from "@/components/section-title";
import { FaqList } from "@/components/faq-list";
import type { FaqItem } from "@/lib/types";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const service = await getServiceBySlug(slug);
if (!service) return { title: "Hizmet bulunamadı" };
return buildMetadata(`/hizmetler/${slug}`, {
title: service.title,
description: service.description.slice(0, 160),
});
}
function parseFaq(items?: string[] | null): FaqItem[] {
if (!items) return [];
const out: FaqItem[] = [];
for (const raw of items) {
try {
const obj = JSON.parse(raw) as Partial<FaqItem>;
if (obj.q && obj.a) out.push({ q: obj.q, a: obj.a });
} catch {
const [q, a] = raw.split("|||").map((s) => s.trim());
if (q && a) out.push({ q, a });
}
}
return out;
}
export default async function ServiceDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const service = await getServiceBySlug(slug);
if (!service) notFound();
const [relatedProjects] = await Promise.all([
listProjects({ serviceSlug: slug, limit: 6 }),
]);
const faqItems = parseFaq(service.faq);
const html = service.content
? (marked.parse(service.content, { async: false }) as string)
: "";
return (
<>
<section className="relative overflow-hidden border-b border-[var(--border)]">
<div className="absolute inset-0 hero-grid opacity-50" aria-hidden />
<div className="relative mx-auto max-w-7xl px-6 py-20">
<Link
href="/hizmetler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm hizmetler
</Link>
<div className="mt-6 grid items-start gap-10 md:grid-cols-[1fr_auto]">
<div>
<div className="flex size-14 items-center justify-center rounded-2xl bg-[var(--navy-50)] text-[var(--navy)]">
<Icon name={service.icon} className="size-7" />
</div>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-[var(--navy)] sm:text-5xl">
{service.title}
</h1>
<p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
{service.description}
</p>
<Link
href="/iletisim"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-6 py-3 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
Bu hizmet için teklif al
<ArrowRight className="size-4" />
</Link>
</div>
{service.hero_image && (
<div className="relative hidden aspect-square w-64 overflow-hidden rounded-2xl md:block lg:w-80">
<Image
src={service.hero_image}
alt={service.title}
fill
sizes="320px"
className="object-cover"
priority
/>
</div>
)}
</div>
</div>
</section>
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[2fr_1fr]">
<div>
{service.features && service.features.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Bu hizmet kapsamında
</h2>
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
{service.features.map((f) => (
<li
key={f}
className="flex items-start gap-2 rounded-xl border border-[var(--border)] bg-white p-4"
>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--sky-600)]" />
<span className="text-sm text-[var(--foreground)]">{f}</span>
</li>
))}
</ul>
</section>
)}
{html && (
<article
className="prose prose-lg max-w-none text-[var(--foreground)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
{faqItems.length > 0 && (
<section className="mt-12">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Sıkça sorulan sorular
</h2>
<div className="mt-6">
<FaqList items={faqItems} />
</div>
</section>
)}
</div>
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start">
<div className="rounded-2xl border border-[var(--border)] bg-[var(--navy)] p-6 text-white">
<h3 className="text-base font-semibold">Bir proje mi var?</h3>
<p className="mt-2 text-sm text-white/70">
İhtiyacınızı anlatın, size en uygun çözümü hep birlikte planlayalım.
</p>
<Link
href="/iletisim"
className="mt-4 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-medium text-[var(--navy)] transition hover:bg-[var(--sky-50)]"
>
Bize ulaşın
<ArrowRight className="size-3.5" />
</Link>
</div>
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
<h3 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted)]">
Diğer hizmetler
</h3>
<Link
href="/hizmetler"
className="mt-3 inline-flex items-center gap-1 text-sm text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümünü gör
<ArrowRight className="size-3.5" />
</Link>
</div>
</aside>
</div>
{relatedProjects.length > 0 && (
<section className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
align="left"
eyebrow="Referanslar"
title={`${service.title} alanındaki projelerimiz`}
description="Bu hizmette tamamladığımız işlerden seçkiler."
/>
<div className="mt-10">
<ProjectsGrid projects={relatedProjects} />
</div>
</div>
</section>
)}
</>
);
}
+209
View File
@@ -0,0 +1,209 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ArrowLeft, Building2, Calendar, Clock, ExternalLink, Tag } from "lucide-react";
import { marked } from "marked";
import { getProjectBySlug, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { Gallery } from "@/components/gallery";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const project = await getProjectBySlug(slug);
if (!project) return { title: "Proje bulunamadı" };
return buildMetadata(`/projeler/${slug}`, {
title: project.title,
description: project.description.slice(0, 160),
openGraph: {
title: project.title,
description: project.description.slice(0, 160),
images: project.image_url ? [{ url: project.image_url }] : undefined,
type: "article",
},
});
}
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const project = await getProjectBySlug(slug);
if (!project) notFound();
const html = project.content
? (marked.parse(project.content, { async: false }) as string)
: "";
const meta: { icon: React.ReactNode; label: string; value: string }[] = [];
if (project.client_name)
meta.push({ icon: <Building2 className="size-4" />, label: "Müşteri", value: project.client_name });
if (project.industry)
meta.push({ icon: <Tag className="size-4" />, label: "Sektör", value: project.industry });
if (project.duration)
meta.push({ icon: <Clock className="size-4" />, label: "Süre", value: project.duration });
if (project.year)
meta.push({ icon: <Calendar className="size-4" />, label: "Yıl", value: String(project.year) });
const relatedProjects = (
await listProjects({ limit: 4 })
).filter((p) => p.$id !== project.$id).slice(0, 3);
return (
<>
<section className="border-b border-[var(--border)]">
<div className="mx-auto max-w-7xl px-6 py-12">
<Link
href="/projeler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm projeler
</Link>
<div className="mt-6 grid items-start gap-10 lg:grid-cols-[1.4fr_1fr]">
<div>
{project.category && (
<span className="inline-flex rounded-full bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]">
{project.category}
</span>
)}
<h1 className="mt-3 text-4xl font-bold tracking-tight text-[var(--navy)] sm:text-5xl">
{project.title}
</h1>
<p className="mt-4 text-lg leading-relaxed text-[var(--muted)]">
{project.description}
</p>
{project.live_url && (
<a
href={project.live_url}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
Projeyi canlı görüntüle
<ExternalLink className="size-4" />
</a>
)}
{project.technologies && project.technologies.length > 0 && (
<div className="mt-8">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Teknolojiler
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{project.technologies.map((t) => (
<span
key={t}
className="rounded-md bg-[var(--navy-50)] px-2.5 py-1 text-xs text-[var(--navy-700)]"
>
{t}
</span>
))}
</div>
</div>
)}
</div>
{meta.length > 0 && (
<dl className="grid grid-cols-2 gap-4 rounded-2xl border border-[var(--border)] bg-white p-6">
{meta.map((m) => (
<div key={m.label}>
<dt className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-[var(--muted)]">
{m.icon}
{m.label}
</dt>
<dd className="mt-1 text-sm font-semibold text-[var(--navy)]">
{m.value}
</dd>
</div>
))}
</dl>
)}
</div>
{project.image_url && (
<div className="relative mt-10 aspect-video overflow-hidden rounded-2xl">
<Image
src={project.image_url}
alt={project.title}
fill
sizes="(min-width: 1024px) 1024px, 100vw"
className="object-cover"
priority
/>
</div>
)}
</div>
</section>
<div className="mx-auto max-w-5xl px-6 py-16">
{html && (
<article
className="prose prose-lg max-w-none text-[var(--foreground)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
{project.gallery && project.gallery.length > 0 && (
<section className={html ? "mt-16" : ""}>
<h2 className="text-2xl font-bold text-[var(--navy)]">Galeri</h2>
<p className="mt-1 text-sm text-[var(--muted)]">
Görsellerin üzerine tıklayarak büyütebilirsiniz.
</p>
<div className="mt-6">
<Gallery images={project.gallery} alt={project.title} />
</div>
</section>
)}
</div>
{relatedProjects.length > 0 && (
<section className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 py-16">
<div className="mx-auto max-w-7xl px-6">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Diğer projeler
</h2>
<div className="mt-8 grid gap-6 md:grid-cols-3">
{relatedProjects.map((p) => (
<Link
key={p.$id}
href={`/projeler/${p.slug}`}
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
>
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
{p.image_url ? (
<Image
src={p.image_url}
alt={p.title}
fill
sizes="(min-width: 1024px) 33vw, 100vw"
className="object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
{p.title.charAt(0)}
</div>
)}
</div>
<div className="p-5">
<h3 className="font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
<p className="mt-1 text-xs text-[var(--muted)]">{p.category}</p>
</div>
</Link>
))}
</div>
</div>
</section>
)}
</>
);
}