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:
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from "@/components/admin/form";
|
} from "@/components/admin/form";
|
||||||
import { saveService } from "@/lib/admin-actions";
|
import { saveService } from "@/lib/admin-actions";
|
||||||
import type { ServiceRow } from "@/lib/types";
|
import type { FaqItem, ServiceRow } from "@/lib/types";
|
||||||
|
|
||||||
const ICON_OPTIONS = [
|
const ICON_OPTIONS = [
|
||||||
"Globe",
|
"Globe",
|
||||||
@@ -24,6 +24,20 @@ const ICON_OPTIONS = [
|
|||||||
"Layers",
|
"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 }) {
|
export function ServiceForm({ service }: { service?: ServiceRow }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -56,20 +70,56 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="mt-1 block text-xs text-[var(--muted)]">
|
|
||||||
Lucide icon adı.
|
|
||||||
</span>
|
|
||||||
</label>
|
</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>
|
||||||
<div className="mt-5">
|
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Açıklama"
|
label="Kısa açıklama (kart için)"
|
||||||
name="description"
|
name="description"
|
||||||
required
|
required
|
||||||
defaultValue={service?.description}
|
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>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Öne çıkar (Anasayfada göster)"
|
label="Öne çıkar (Anasayfada göster)"
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ export default async function EditProjectPage({
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const project = await getRow<ProjectRow>(TABLES.projects, id);
|
const project = await getRow<ProjectRow>(TABLES.projects, id);
|
||||||
if (!project) notFound();
|
if (!project) notFound();
|
||||||
return <ProjectForm project={project} />;
|
return await ProjectForm({ project });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from "@/components/admin/form";
|
} from "@/components/admin/form";
|
||||||
import { saveProject } from "@/lib/admin-actions";
|
import { saveProject } from "@/lib/admin-actions";
|
||||||
|
import { listServices } from "@/lib/data";
|
||||||
import type { ProjectRow } from "@/lib/types";
|
import type { ProjectRow } from "@/lib/types";
|
||||||
|
|
||||||
export function ProjectForm({ project }: { project?: ProjectRow }) {
|
export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||||
|
const services = await listServices();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -26,6 +29,34 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
|||||||
<Field label="Başlık" name="title" required defaultValue={project?.title} />
|
<Field label="Başlık" name="title" required defaultValue={project?.title} />
|
||||||
<Field label="Slug" name="slug" defaultValue={project?.slug} />
|
<Field label="Slug" name="slug" defaultValue={project?.slug} />
|
||||||
<Field label="Kategori" name="category" defaultValue={project?.category} />
|
<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
|
<Field
|
||||||
label="Yıl"
|
label="Yıl"
|
||||||
name="year"
|
name="year"
|
||||||
@@ -33,7 +64,7 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
|||||||
defaultValue={project?.year ?? new Date().getFullYear()}
|
defaultValue={project?.year ?? new Date().getFullYear()}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
label="Görsel URL"
|
label="Kapak görseli URL"
|
||||||
name="image_url"
|
name="image_url"
|
||||||
type="url"
|
type="url"
|
||||||
defaultValue={project?.image_url}
|
defaultValue={project?.image_url}
|
||||||
@@ -52,15 +83,39 @@ export function ProjectForm({ project }: { project?: ProjectRow }) {
|
|||||||
help="Virgülle ayırın."
|
help="Virgülle ayırın."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
|
||||||
|
<div className="mt-5 space-y-5">
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Açıklama"
|
label="Kısa açıklama (kart için)"
|
||||||
name="description"
|
name="description"
|
||||||
required
|
required
|
||||||
defaultValue={project?.description}
|
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>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Öne çıkar (Anasayfada göster)"
|
label="Öne çıkar (Anasayfada göster)"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ProjectForm } from "../form";
|
import { ProjectForm } from "../form";
|
||||||
|
|
||||||
export default function NewProjectPage() {
|
export default async function NewProjectPage() {
|
||||||
return <ProjectForm />;
|
return await ProjectForm({});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from "next/image";
|
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";
|
import type { ProjectRow } from "@/lib/types";
|
||||||
|
|
||||||
export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
|
export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
|
||||||
@@ -20,41 +21,47 @@ export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
|
|||||||
key={p.$id}
|
key={p.$id}
|
||||||
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-xl"
|
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-xl"
|
||||||
>
|
>
|
||||||
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
<Link href={`/projeler/${p.slug}`} className="block">
|
||||||
{p.image_url ? (
|
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
|
||||||
<Image
|
{p.image_url ? (
|
||||||
src={p.image_url}
|
<Image
|
||||||
alt={p.title}
|
src={p.image_url}
|
||||||
fill
|
alt={p.title}
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
fill
|
||||||
className="object-cover transition group-hover:scale-105"
|
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
|
||||||
/>
|
className="object-cover transition group-hover:scale-105"
|
||||||
) : (
|
/>
|
||||||
<div className="flex h-full items-center justify-center text-[var(--navy)]/30">
|
) : (
|
||||||
<span className="text-5xl font-bold">{p.title.charAt(0)}</span>
|
<div className="flex h-full items-center justify-center text-[var(--navy)]/30">
|
||||||
</div>
|
<span className="text-5xl font-bold">{p.title.charAt(0)}</span>
|
||||||
)}
|
</div>
|
||||||
{p.category && (
|
)}
|
||||||
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-[var(--navy)] shadow-sm">
|
{p.category && (
|
||||||
{p.category}
|
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-[var(--navy)] shadow-sm">
|
||||||
</span>
|
{p.category}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<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">
|
||||||
{p.title}
|
<h3 className="text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
|
||||||
</h3>
|
{p.title}
|
||||||
{p.live_url && (
|
</h3>
|
||||||
|
</Link>
|
||||||
|
{p.live_url ? (
|
||||||
<a
|
<a
|
||||||
href={p.live_url}
|
href={p.live_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="Projeyi aç"
|
aria-label="Projeyi canlı aç"
|
||||||
className="text-[var(--sky-600)] hover:text-[var(--navy)]"
|
className="text-[var(--sky-600)] hover:text-[var(--navy)]"
|
||||||
>
|
>
|
||||||
<ArrowUpRight className="size-5" />
|
<ExternalLink className="size-4" />
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<ArrowUpRight className="size-5 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
|
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import { Icon } from "@/components/icon";
|
import { Icon } from "@/components/icon";
|
||||||
import { siteConfig } from "@/lib/site-config";
|
import { siteConfig } from "@/lib/site-config";
|
||||||
import type { ServiceRow } from "@/lib/types";
|
import type { ServiceRow } from "@/lib/types";
|
||||||
@@ -18,24 +20,29 @@ export function ServicesGrid({ services }: { services: ServiceRow[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<article
|
<Link
|
||||||
key={s.slug}
|
key={s.slug}
|
||||||
|
href={`/hizmetler/${s.slug}`}
|
||||||
id={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"
|
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="relative">
|
||||||
<div className="flex size-12 items-center justify-center rounded-xl bg-[var(--navy-50)] text-[var(--navy)]">
|
<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" />
|
<Icon name={s.icon} className="size-6" />
|
||||||
</div>
|
</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}
|
{s.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
|
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
|
||||||
{s.description}
|
{s.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ export async function saveService(formData: FormData) {
|
|||||||
if (!description) throw new Error("Açıklama zorunlu");
|
if (!description) throw new Error("Açıklama zorunlu");
|
||||||
const slug = str(formData.get("slug")) || slugify(title);
|
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 = {
|
const data = {
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
@@ -136,6 +149,10 @@ export async function saveService(formData: FormData) {
|
|||||||
icon: str(formData.get("icon")),
|
icon: str(formData.get("icon")),
|
||||||
order: num(formData.get("order")) ?? 0,
|
order: num(formData.get("order")) ?? 0,
|
||||||
featured: bool(formData.get("featured")),
|
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) {
|
if (id) {
|
||||||
await tablesDB.updateRow(DATABASE_ID, TABLES.services, id, data, secret);
|
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"));
|
const description = str(formData.get("description"));
|
||||||
if (!description) throw new Error("Açıklama zorunlu");
|
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 = {
|
const data = {
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
@@ -182,6 +206,12 @@ export async function saveProject(formData: FormData) {
|
|||||||
technologies: strArr(formData.get("technologies")),
|
technologies: strArr(formData.get("technologies")),
|
||||||
year: num(formData.get("year")),
|
year: num(formData.get("year")),
|
||||||
featured: bool(formData.get("featured")),
|
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) {
|
if (id) {
|
||||||
|
|||||||
+22
-1
@@ -41,12 +41,33 @@ export async function listServices(opts?: { featured?: boolean }) {
|
|||||||
return safeList<ServiceRow>(TABLES.services, q);
|
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)];
|
const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
|
||||||
if (opts?.featured) q.unshift(Q.equal("featured", true));
|
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);
|
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 }) {
|
export async function listPublishedPosts(opts?: { limit?: number }) {
|
||||||
return safeList<BlogPostRow>(TABLES.blogPosts, [
|
return safeList<BlogPostRow>(TABLES.blogPosts, [
|
||||||
Q.equal("status", "published"),
|
Q.equal("status", "published"),
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ export interface ServiceRow extends AwRow {
|
|||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
order?: number | null;
|
order?: number | null;
|
||||||
featured?: boolean | 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 {
|
export interface ProjectRow extends AwRow {
|
||||||
@@ -21,6 +30,12 @@ export interface ProjectRow extends AwRow {
|
|||||||
technologies?: string[] | null;
|
technologies?: string[] | null;
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
featured?: boolean | 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 {
|
export interface BlogPostRow extends AwRow {
|
||||||
|
|||||||
Reference in New Issue
Block a user