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>
)}
</>
);
}
+57 -7
View File
@@ -10,7 +10,7 @@ import {
Textarea,
} from "@/components/admin/form";
import { saveService } from "@/lib/admin-actions";
import type { ServiceRow } from "@/lib/types";
import type { FaqItem, ServiceRow } from "@/lib/types";
const ICON_OPTIONS = [
"Globe",
@@ -24,6 +24,20 @@ const ICON_OPTIONS = [
"Layers",
];
function faqToText(items?: string[] | null): string {
if (!items) return "";
const parsed: FaqItem[] = [];
for (const raw of items) {
try {
const obj = JSON.parse(raw) as Partial<FaqItem>;
if (obj.q && obj.a) parsed.push({ q: obj.q, a: obj.a });
} catch {
/* ignore */
}
}
return parsed.map((it) => `${it.q}\n${it.a}`).join("\n---\n");
}
export function ServiceForm({ service }: { service?: ServiceRow }) {
return (
<div>
@@ -56,20 +70,56 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
</option>
))}
</select>
<span className="mt-1 block text-xs text-[var(--muted)]">
Lucide icon adı.
</span>
</label>
<Field
label="Hero görsel URL"
name="hero_image"
type="url"
defaultValue={service?.hero_image}
help="Detay sayfası başında gösterilir (opsiyonel)."
/>
</div>
<div className="mt-5">
<div className="mt-5 space-y-5">
<Textarea
label="Açıklama"
label="Kısa açıklama (kart için)"
name="description"
required
defaultValue={service?.description}
rows={4}
rows={3}
help="Listede ve anasayfa kartında gösterilir."
/>
<Textarea
label="Detay içerik (Markdown)"
name="content"
defaultValue={service?.content}
rows={10}
placeholder={"## Yaklaşım\n\nMarkdown desteklenir…"}
help="Hizmet detay sayfasında ana içerik olarak gösterilir."
/>
<Textarea
label="Özellikler"
name="features"
defaultValue={service?.features?.join(", ")}
rows={3}
placeholder="SEO uyumlu kod, Mobil responsive, Hızlı yüklenme, …"
help="Virgülle ayırın. Detay sayfasında checklist olarak gösterilir."
/>
<Textarea
label="SSS"
name="faq"
defaultValue={faqToText(service?.faq)}
rows={8}
placeholder={
"Soru 1?\nCevap 1 burada.\n---\nSoru 2?\nCevap 2 burada."
}
help="Her soru/cevap blokunu '---' ile ayırın. İlk satır soru, kalanı cevap."
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar (Anasayfada göster)"
@@ -12,5 +12,5 @@ export default async function EditProjectPage({
const { id } = await params;
const project = await getRow<ProjectRow>(TABLES.projects, id);
if (!project) notFound();
return <ProjectForm project={project} />;
return await ProjectForm({ project });
}
+60 -5
View File
@@ -10,9 +10,12 @@ import {
Textarea,
} from "@/components/admin/form";
import { saveProject } from "@/lib/admin-actions";
import { listServices } from "@/lib/data";
import type { ProjectRow } from "@/lib/types";
export function ProjectForm({ project }: { project?: ProjectRow }) {
export async function ProjectForm({ project }: { project?: ProjectRow }) {
const services = await listServices();
return (
<div>
<PageHeader
@@ -26,6 +29,34 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
<Field label="Başlık" name="title" required defaultValue={project?.title} />
<Field label="Slug" name="slug" defaultValue={project?.slug} />
<Field label="Kategori" name="category" defaultValue={project?.category} />
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
İlgili hizmet
</span>
<select
name="service_slug"
defaultValue={project?.service_slug ?? ""}
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
>
<option value=""> Yok </option>
{services.map((s) => (
<option key={s.slug} value={s.slug}>
{s.title}
</option>
))}
</select>
<span className="mt-1 block text-xs text-[var(--muted)]">
Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür.
</span>
</label>
<Field label="Müşteri" name="client_name" defaultValue={project?.client_name} />
<Field label="Sektör" name="industry" defaultValue={project?.industry} />
<Field
label="Süre"
name="duration"
defaultValue={project?.duration}
placeholder="örn: 3 ay"
/>
<Field
label="Yıl"
name="year"
@@ -33,7 +64,7 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
defaultValue={project?.year ?? new Date().getFullYear()}
/>
<Field
label="Görsel URL"
label="Kapak görseli URL"
name="image_url"
type="url"
defaultValue={project?.image_url}
@@ -52,15 +83,39 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
help="Virgülle ayırın."
/>
</div>
<div className="mt-5">
<div className="mt-5 space-y-5">
<Textarea
label="Açıklama"
label="Kısa açıklama (kart için)"
name="description"
required
defaultValue={project?.description}
rows={6}
rows={3}
/>
<Textarea
label="Vaka çalışması içeriği (Markdown)"
name="content"
defaultValue={project?.content}
rows={12}
placeholder={
"## Müşteri\n\n## Problem\n\n## Çözüm\n\n## Sonuç"
}
help="Proje detay sayfasında uzun anlatım olarak gösterilir."
/>
<Textarea
label="Galeri görselleri"
name="gallery"
defaultValue={project?.gallery?.join("\n")}
rows={5}
placeholder={
"https://example.com/image1.jpg\nhttps://example.com/image2.jpg"
}
help="Her satıra bir URL. Medya kütüphanesinden URL'leri kopyalayın."
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar (Anasayfada göster)"
+2 -2
View File
@@ -1,5 +1,5 @@
import { ProjectForm } from "../form";
export default function NewProjectPage() {
return <ProjectForm />;
export default async function NewProjectPage() {
return await ProjectForm({});
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import type { FaqItem } from "@/lib/types";
export function FaqList({ items }: { items: FaqItem[] }) {
const [open, setOpen] = useState<number | null>(0);
return (
<div className="divide-y divide-[var(--border)] rounded-2xl border border-[var(--border)] bg-white">
{items.map((it, i) => {
const isOpen = open === i;
return (
<details
key={i}
open={isOpen}
onToggle={(e) => {
if ((e.target as HTMLDetailsElement).open) setOpen(i);
else if (isOpen) setOpen(null);
}}
className="group"
>
<summary className="flex cursor-pointer items-center justify-between gap-4 px-5 py-4 text-sm font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]/60">
<span>{it.q}</span>
<ChevronDown
className={`size-4 shrink-0 text-[var(--muted)] transition ${
isOpen ? "rotate-180" : ""
}`}
/>
</summary>
<div className="px-5 pb-5 text-sm leading-relaxed text-[var(--muted)]">
{it.a}
</div>
</details>
);
})}
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
const [active, setActive] = useState<number | null>(null);
useEffect(() => {
if (active === null) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setActive(null);
if (e.key === "ArrowRight")
setActive((i) => (i === null ? null : (i + 1) % images.length));
if (e.key === "ArrowLeft")
setActive((i) =>
i === null ? null : (i - 1 + images.length) % images.length,
);
};
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKey);
};
}, [active, images.length]);
return (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{images.map((src, i) => (
<button
key={src + i}
type="button"
onClick={() => setActive(i)}
className="group relative aspect-video overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--navy-50)]"
>
<Image
src={src}
alt={`${alt}${i + 1}`}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover transition group-hover:scale-105"
/>
</button>
))}
</div>
{active !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
onClick={() => setActive(null)}
>
<button
type="button"
aria-label="Kapat"
onClick={() => setActive(null)}
className="absolute right-4 top-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<X className="size-5" />
</button>
{images.length > 1 && (
<>
<button
type="button"
aria-label="Önceki"
onClick={(e) => {
e.stopPropagation();
setActive((i) =>
i === null ? null : (i - 1 + images.length) % images.length,
);
}}
className="absolute left-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<ChevronLeft className="size-5" />
</button>
<button
type="button"
aria-label="Sonraki"
onClick={(e) => {
e.stopPropagation();
setActive((i) =>
i === null ? null : (i + 1) % images.length,
);
}}
className="absolute right-4 z-10 flex size-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
>
<ChevronRight className="size-5" />
</button>
</>
)}
<div
className="relative max-h-[90vh] max-w-6xl"
onClick={(e) => e.stopPropagation()}
>
<Image
src={images[active]}
alt={`${alt}${active + 1}`}
width={1600}
height={1200}
className="max-h-[90vh] w-auto rounded-lg object-contain"
/>
<p className="mt-3 text-center text-xs text-white/60">
{active + 1} / {images.length}
</p>
</div>
</div>
)}
</>
);
}
+12 -5
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { ArrowUpRight, ExternalLink } from "lucide-react";
import type { ProjectRow } from "@/lib/types";
export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
@@ -20,6 +21,7 @@ export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
key={p.$id}
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-xl"
>
<Link href={`/projeler/${p.slug}`} className="block">
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
{p.image_url ? (
<Image
@@ -40,21 +42,26 @@ export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
</span>
)}
</div>
</Link>
<div className="p-6">
<div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-[var(--navy)]">
<Link href={`/projeler/${p.slug}`} className="block">
<h3 className="text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
{p.live_url && (
</Link>
{p.live_url ? (
<a
href={p.live_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Projeyi aç"
aria-label="Projeyi canlı aç"
className="text-[var(--sky-600)] hover:text-[var(--navy)]"
>
<ArrowUpRight className="size-5" />
<ExternalLink className="size-4" />
</a>
) : (
<ArrowUpRight className="size-5 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
)}
</div>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
+11 -4
View File
@@ -1,3 +1,5 @@
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { Icon } from "@/components/icon";
import { siteConfig } from "@/lib/site-config";
import type { ServiceRow } from "@/lib/types";
@@ -18,24 +20,29 @@ export function ServicesGrid({ services }: { services: ServiceRow[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{items.map((s) => (
<article
<Link
key={s.slug}
href={`/hizmetler/${s.slug}`}
id={s.slug}
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-6 transition hover:border-[var(--sky)]/40 hover:shadow-lg hover:shadow-[var(--sky)]/10"
>
<div className="absolute -right-12 -top-12 size-32 rounded-full bg-[var(--sky-50)] opacity-0 transition group-hover:opacity-100" aria-hidden />
<div
className="absolute -right-12 -top-12 size-32 rounded-full bg-[var(--sky-50)] opacity-0 transition group-hover:opacity-100"
aria-hidden
/>
<ArrowUpRight className="absolute right-5 top-5 size-4 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
<div className="relative">
<div className="flex size-12 items-center justify-center rounded-xl bg-[var(--navy-50)] text-[var(--navy)]">
<Icon name={s.icon} className="size-6" />
</div>
<h3 className="mt-5 text-lg font-semibold text-[var(--navy)]">
<h3 className="mt-5 text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
{s.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
{s.description}
</p>
</div>
</article>
</Link>
))}
</div>
);
+30
View File
@@ -129,6 +129,19 @@ export async function saveService(formData: FormData) {
if (!description) throw new Error("Açıklama zorunlu");
const slug = str(formData.get("slug")) || slugify(title);
// FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
const faqRaw = String(formData.get("faq") ?? "");
const faq = faqRaw
.split("\n---\n")
.map((block) => {
const lines = block.trim().split("\n");
const q = lines[0]?.trim();
const a = lines.slice(1).join("\n").trim();
if (!q || !a) return null;
return JSON.stringify({ q, a });
})
.filter((x): x is string => x !== null);
const data = {
slug,
title,
@@ -136,6 +149,10 @@ export async function saveService(formData: FormData) {
icon: str(formData.get("icon")),
order: num(formData.get("order")) ?? 0,
featured: bool(formData.get("featured")),
content: str(formData.get("content")),
features: strArr(formData.get("features"))?.filter(Boolean) ?? null,
faq: faq.length > 0 ? faq : null,
hero_image: str(formData.get("hero_image")),
};
if (id) {
await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, data, secret);
@@ -172,6 +189,13 @@ export async function saveProject(formData: FormData) {
const description = str(formData.get("description"));
if (!description) throw new Error("Açıklama zorunlu");
// Gallery: one URL per line
const galleryRaw = String(formData.get("gallery") ?? "");
const gallery = galleryRaw
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const data = {
slug,
title,
@@ -182,6 +206,12 @@ export async function saveProject(formData: FormData) {
technologies: strArr(formData.get("technologies")),
year: num(formData.get("year")),
featured: bool(formData.get("featured")),
gallery: gallery.length > 0 ? gallery : null,
content: str(formData.get("content")),
client_name: str(formData.get("client_name")),
industry: str(formData.get("industry")),
duration: str(formData.get("duration")),
service_slug: str(formData.get("service_slug")),
};
if (id) {
+22 -1
View File
@@ -41,12 +41,33 @@ export async function listServices(opts?: { featured?: boolean }) {
return safeList<ServiceRow>(TABLES.services, q);
}
export async function listProjects(opts?: { featured?: boolean; limit?: number }) {
export async function listProjects(opts?: {
featured?: boolean;
limit?: number;
serviceSlug?: string;
}) {
const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
if (opts?.featured) q.unshift(Q.equal("featured", true));
if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug));
return safeList<ProjectRow>(TABLES.projects, q);
}
export async function getServiceBySlug(slug: string): Promise<ServiceRow | null> {
const res = await safeList<ServiceRow>(TABLES.services, [
Q.equal("slug", slug),
Q.limit(1),
]);
return res[0] ?? null;
}
export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> {
const res = await safeList<ProjectRow>(TABLES.projects, [
Q.equal("slug", slug),
Q.limit(1),
]);
return res[0] ?? null;
}
export async function listPublishedPosts(opts?: { limit?: number }) {
return safeList<BlogPostRow>(TABLES.blogPosts, [
Q.equal("status", "published"),
+15
View File
@@ -9,6 +9,15 @@ export interface ServiceRow extends AwRow {
icon?: string | null;
order?: number | null;
featured?: boolean | null;
content?: string | null;
features?: string[] | null;
faq?: string[] | null; // each item is JSON: {"q":"...","a":"..."}
hero_image?: string | null;
}
export interface FaqItem {
q: string;
a: string;
}
export interface ProjectRow extends AwRow {
@@ -21,6 +30,12 @@ export interface ProjectRow extends AwRow {
technologies?: string[] | null;
year?: number | null;
featured?: boolean | null;
gallery?: string[] | null;
content?: string | null;
client_name?: string | null;
industry?: string | null;
duration?: string | null;
service_slug?: string | null;
}
export interface BlogPostRow extends AwRow {