feat: Çözümler bölümü + mobil menü; admin parser düzeltmeleri

- Çözümler: solutions tablosu, /cozumler liste + detay sayfası, anasayfa
  bölümü, tam admin CRUD (/admin/cozumler), header & footer linkleri,
  projelerde solution_slug ilişkisi, services-grid genelleştirildi
- Mobil menü (hamburger drawer) eklendi — header artık < lg'de gezilebilir
- Site ayarları parser: textarea CRLF (\r\n) normalizasyonu — neden biz,
  süreç adımları, değerler ve SSS blokları artık doğru parçalanıyor
- homepage_faq + garanti (title/description/items) saveSiteSettings'e
  bağlandı (daha önce hiç kaydedilmiyordu)
This commit is contained in:
egecankomur
2026-06-02 18:21:58 +03:00
parent f49df9cbeb
commit 2e001680bf
21 changed files with 1191 additions and 27 deletions
+124
View File
@@ -0,0 +1,124 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CheckCircle2 } from "lucide-react";
import { renderContent } from "@/lib/content-render";
import { getSolutionBySlug, getSiteSettings, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { ProjectsGrid } from "@/components/projects-grid";
import { SectionTitle } from "@/components/section-title";
import { FaqList } from "@/components/faq-list";
import { SolutionHero } from "@/components/solution-hero";
import { SolutionSidebar } from "@/components/solution-sidebar";
import type { FaqItem } from "@/lib/types";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const solution = await getSolutionBySlug(slug);
if (!solution) return { title: "Çözüm bulunamadı" };
return buildMetadata(`/cozumler/${slug}`, {
title: solution.title,
description: solution.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 SolutionDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const solution = await getSolutionBySlug(slug);
if (!solution) notFound();
const [relatedProjects, settings] = await Promise.all([
listProjects({ solutionSlug: slug, limit: 6 }),
getSiteSettings(),
]);
const faqItems = parseFaq(solution.faq);
const html = renderContent(solution.content);
return (
<>
<SolutionHero solution={solution} settings={settings} />
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
<div>
{solution.features && solution.features.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Bu çözüm kapsamında
</h2>
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
{solution.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>
<SolutionSidebar currentSlug={slug} />
</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={`${solution.title} alanındaki projelerimiz`}
description="Bu çözümde tamamladığımız işlerden seçkiler."
/>
<div className="mt-10">
<ProjectsGrid projects={relatedProjects} />
</div>
</div>
</section>
)}
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { listSolutions } from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/cozumler", {
title: "Çözümler",
description:
"İşletmeniz için uçtan uca dijital çözümler: kurumsal dijitalleşme, online satış altyapısı, CRM ve büyüme paketleri.",
});
}
export default async function SolutionsPage() {
const solutions = await listSolutions();
return (
<div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle
eyebrow="Çözümlerimiz"
title="İşletmenize özel dijital çözümler"
description="Tek tek hizmetleri değil, işinizi büyüten bütün paketleri tek elden kuruyoruz."
/>
<div className="mt-14">
<ServicesGrid
services={solutions}
basePath="/cozumler"
fallback={siteConfig.fallbackSolutions}
/>
</div>
</div>
);
}
+38 -6
View File
@@ -18,6 +18,7 @@ import {
getSiteSettings, getSiteSettings,
listProjects, listProjects,
listServices, listServices,
listSolutions,
listTestimonials, listTestimonials,
} from "@/lib/data"; } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
@@ -28,12 +29,14 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function Home() { export default async function Home() {
const [services, projects, testimonials, settings] = await Promise.all([ const [services, solutions, projects, testimonials, settings] =
listServices({ featured: true }), await Promise.all([
listProjects({ featured: true, limit: 6 }), listServices({ featured: true }),
listTestimonials({ featured: true }), listSolutions({ featured: true }),
getSiteSettings(), listProjects({ featured: true, limit: 6 }),
]); listTestimonials({ featured: true }),
getSiteSettings(),
]);
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw; const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone; const phone = settings?.contact_phone ?? siteConfig.contact.phone;
@@ -118,6 +121,35 @@ export default async function Home() {
</div> </div>
</section> </section>
<section className="border-b border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
eyebrow={settings?.solutions_eyebrow ?? "İşletmeler için"}
title={settings?.solutions_title ?? "Hazır dijital çözüm paketleri"}
description={
settings?.solutions_description ??
"Tek tek hizmetleri değil; işinizi büyüten bütün paketleri tek elden kuruyoruz."
}
/>
<div className="mt-12">
<ServicesGrid
services={solutions}
basePath="/cozumler"
fallback={siteConfig.fallbackSolutions}
/>
</div>
<div className="mt-10 text-center">
<Link
href="/cozumler"
className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-white px-5 py-2.5 text-sm font-semibold text-[var(--navy)] transition hover:border-[var(--sky)] hover:text-[var(--sky-600)]"
>
Tüm çözümleri gör
<ArrowRight className="size-4" />
</Link>
</div>
</div>
</section>
<WhyUs settings={settings} /> <WhyUs settings={settings} />
<Guarantee settings={settings} /> <Guarantee settings={settings} />
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getRow } from "@/lib/data";
import { TABLES } from "@/lib/appwrite-rest";
import type { SolutionRow } from "@/lib/types";
import { SolutionForm } from "../../form";
export default async function EditSolutionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const solution = await getRow<SolutionRow>(TABLES.solutions, id);
if (!solution) notFound();
return <SolutionForm solution={solution} />;
}
+148
View File
@@ -0,0 +1,148 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveSolution } from "@/lib/admin-actions";
import type { FaqItem, SolutionRow } from "@/lib/types";
const ICON_OPTIONS = [
"Globe",
"ShoppingCart",
"Smartphone",
"Code2",
"Users",
"TrendingUp",
"Share2",
"Megaphone",
"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 SolutionForm({ solution }: { solution?: SolutionRow }) {
return (
<div>
<PageHeader
title={solution ? "Çözümü düzenle" : "Yeni çözüm"}
backHref="/admin/cozumler"
/>
<form action={saveSolution}>
{solution && <input type="hidden" name="id" value={solution.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Başlık" name="title" required defaultValue={solution?.title} />
<Field label="Slug" name="slug" defaultValue={solution?.slug} />
<Field
label="Sıra"
name="order"
type="number"
defaultValue={solution?.order ?? 0}
/>
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">İkon</span>
<select
name="icon"
defaultValue={solution?.icon ?? "Layers"}
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"
>
{ICON_OPTIONS.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>
</label>
<MediaPicker
label="Hero görsel"
name="hero_image"
defaultValue={solution?.hero_image}
help="Detay sayfasının üst kısmında gösterilir (opsiyonel)."
/>
</div>
<div className="mt-5 space-y-5">
<Textarea
label="Kısa açıklama (kart için)"
name="description"
required
defaultValue={solution?.description}
rows={3}
help="Listede ve anasayfa kartında gösterilir."
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
Detay içerik
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={solution?.content}
placeholder="Çözümün detaylarını anlatın… `/` ile blok ekleyin"
/>
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Çözüm detay sayfasında ana içerik olarak gösterilir.
</p>
</div>
<Textarea
label="Özellikler"
name="features"
defaultValue={solution?.features?.join(", ")}
rows={3}
placeholder="Uçtan uca kurulum, Eğitim ve devir, 1 yıl destek, …"
help="Virgülle ayırın. Detay sayfasında checklist olarak gösterilir."
/>
<Textarea
label="SSS"
name="faq"
defaultValue={faqToText(solution?.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)"
name="featured"
defaultChecked={solution?.featured ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/cozumler">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
@@ -0,0 +1,5 @@
import { SolutionForm } from "../form";
export default function NewSolutionPage() {
return <SolutionForm />;
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { Plus, Edit } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listSolutions } from "@/lib/data";
import { deleteSolution } from "@/lib/admin-actions";
export default async function SolutionsAdminPage() {
const solutions = await listSolutions();
return (
<div>
<PageHeader
title="Çözümler"
description="Anasayfa ve /cozumler sayfasında gösterilen çözüm kartları."
action={
<Link
href="/admin/cozumler/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni çözüm
</Link>
}
/>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Sıra</th>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Slug</th>
<th className="px-4 py-3 text-left">İkon</th>
<th className="px-4 py-3 text-left">Öne çıkan</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{solutions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-[var(--muted)]">
Çözüm eklenmemiş.
</td>
</tr>
)}
{solutions.map((s) => (
<tr key={s.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 text-[var(--muted)]">{s.order ?? 0}</td>
<td className="px-4 py-3 font-medium text-[var(--navy)]">{s.title}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.slug}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.icon ?? "—"}</td>
<td className="px-4 py-3">
{s.featured ? (
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
Öne çıkan
</span>
) : (
<span className="text-xs text-[var(--muted)]"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/cozumler/${s.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteSolution}>
<input type="hidden" name="id" value={s.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+25 -2
View File
@@ -12,11 +12,14 @@ import {
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor"; import { RichEditor } from "@/components/admin/rich-editor";
import { saveProject } from "@/lib/admin-actions"; import { saveProject } from "@/lib/admin-actions";
import { listServices } from "@/lib/data"; import { listServices, listSolutions } from "@/lib/data";
import type { ProjectRow } from "@/lib/types"; import type { ProjectRow } from "@/lib/types";
export async function ProjectForm({ project }: { project?: ProjectRow }) { export async function ProjectForm({ project }: { project?: ProjectRow }) {
const services = await listServices(); const [services, solutions] = await Promise.all([
listServices(),
listSolutions(),
]);
return ( return (
<div> <div>
@@ -51,6 +54,26 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür. Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür.
</span> </span>
</label> </label>
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
İlgili çözüm
</span>
<select
name="solution_slug"
defaultValue={project?.solution_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>
{solutions.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 çözüm çözüm 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="Müşteri" name="client_name" defaultValue={project?.client_name} />
<Field label="Sektör" name="industry" defaultValue={project?.industry} /> <Field label="Sektör" name="industry" defaultValue={project?.industry} />
<Field <Field
+23
View File
@@ -231,6 +231,29 @@ export default async function SiteSettingsPage() {
</div> </div>
</Section> </Section>
<Section
title="Çözümler bölümü başlığı"
description="Anasayfadaki çözüm kartlarının üstündeki yazı."
>
<div className="grid gap-5 md:grid-cols-3">
<Field
label="Eyebrow"
name="solutions_eyebrow"
defaultValue={s?.solutions_eyebrow}
/>
<Field
label="Başlık"
name="solutions_title"
defaultValue={s?.solutions_title}
/>
<Field
label="Açıklama"
name="solutions_description"
defaultValue={s?.solutions_description}
/>
</div>
</Section>
<Section <Section
title="Projeler bölümü başlığı" title="Projeler bölümü başlığı"
description="Anasayfadaki proje kartlarının üstündeki yazı." description="Anasayfadaki proje kartlarının üstündeki yazı."
+2
View File
@@ -8,6 +8,7 @@ import {
Settings, Settings,
Newspaper, Newspaper,
Layers, Layers,
Boxes,
Briefcase, Briefcase,
MessageSquareQuote, MessageSquareQuote,
Search, Search,
@@ -25,6 +26,7 @@ const items: Item[] = [
{ href: "/admin/site", label: "Site Ayarları", icon: Settings }, { href: "/admin/site", label: "Site Ayarları", icon: Settings },
{ href: "/admin/blog", label: "Blog", icon: Newspaper }, { href: "/admin/blog", label: "Blog", icon: Newspaper },
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers }, { href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
{ href: "/admin/cozumler", label: "Çözümler", icon: Boxes },
{ href: "/admin/projeler", label: "Projeler", icon: Briefcase }, { href: "/admin/projeler", label: "Projeler", icon: Briefcase },
{ href: "/admin/sektorler", label: "Sektörler", icon: Building2 }, { href: "/admin/sektorler", label: "Sektörler", icon: Building2 },
{ href: "/admin/ekip", label: "Ekip", icon: UsersIcon }, { href: "/admin/ekip", label: "Ekip", icon: UsersIcon },
+5
View File
@@ -57,6 +57,11 @@ export async function Footer() {
</Link> </Link>
</li> </li>
))} ))}
<li className="pt-1">
<Link href="/cozumler" className="font-medium text-white/90 hover:text-white">
Çözümler
</Link>
</li>
</ul> </ul>
</div> </div>
+15 -1
View File
@@ -4,6 +4,7 @@ import { ChevronDown, Phone } from "lucide-react";
import { getSiteSettings, listServices } from "@/lib/data"; import { getSiteSettings, listServices } from "@/lib/data";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { HeaderScrollEffect } from "@/components/header-scroll"; import { HeaderScrollEffect } from "@/components/header-scroll";
import { MobileMenu } from "@/components/mobile-menu";
export async function Header() { export async function Header() {
const [settings, services] = await Promise.all([ const [settings, services] = await Promise.all([
@@ -111,6 +112,12 @@ export async function Header() {
</div> </div>
</div> </div>
<Link
href="/cozumler"
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
Çözümler
</Link>
<Link <Link
href="/projeler" href="/projeler"
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
@@ -162,10 +169,17 @@ export async function Header() {
</a> </a>
<Link <Link
href="/iletisim" href="/iletisim"
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)]" className="hidden h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)] sm:inline-flex"
> >
Ücretsiz Teklif Ücretsiz Teklif
</Link> </Link>
{/* Mobil menü (hamburger) — sadece < lg */}
<MobileMenu
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
phone={phone}
phoneRaw={phoneRaw}
/>
</div> </div>
</nav> </nav>
</header> </header>
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X, ChevronDown, Phone, ArrowRight } from "lucide-react";
type NavService = { slug: string; title: string };
const LINKS = [
{ href: "/", label: "Anasayfa" },
{ href: "/cozumler", label: "Çözümler" },
{ href: "/projeler", label: "Projeler" },
{ href: "/blog", label: "Blog" },
{ href: "/hakkimizda", label: "Hakkımızda" },
{ href: "/iletisim", label: "İletişim" },
];
export function MobileMenu({
services,
phone,
phoneRaw,
}: {
services: NavService[];
phone: string;
phoneRaw: string;
}) {
const [open, setOpen] = useState(false);
const [servicesOpen, setServicesOpen] = useState(false);
const pathname = usePathname();
// Rota değişince menüyü kapat
useEffect(() => {
setOpen(false);
}, [pathname]);
// Açıkken arka plan kaydırmasını kilitle
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return (
<div className="lg:hidden">
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Menüyü aç"
aria-expanded={open}
className="inline-flex size-9 items-center justify-center rounded-lg text-gray-700 transition-colors hover:bg-gray-100 hover:text-[var(--navy)]"
>
<Menu className="size-5" />
</button>
{/* Overlay + drawer */}
<div
className={`fixed inset-0 z-[60] lg:hidden ${
open ? "" : "pointer-events-none"
}`}
aria-hidden={!open}
>
{/* Backdrop */}
<div
onClick={() => setOpen(false)}
className={`absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0"
}`}
/>
{/* Panel */}
<div
className={`absolute right-0 top-0 flex h-full w-[86%] max-w-sm flex-col bg-white shadow-2xl transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
{/* Üst bar */}
<div className="flex h-14 items-center justify-between border-b border-gray-100 px-5">
<span className="text-sm font-semibold tracking-tight text-[var(--navy)]">
Menü
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Menüyü kapat"
className="inline-flex size-9 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-100 hover:text-[var(--navy)]"
>
<X className="size-5" />
</button>
</div>
{/* Linkler */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<Link
href="/"
className="block rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
>
Anasayfa
</Link>
{/* Hizmetler — açılır */}
<button
type="button"
onClick={() => setServicesOpen((v) => !v)}
aria-expanded={servicesOpen}
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
>
Hizmetler
<ChevronDown
className={`size-4 transition-transform duration-200 ${
servicesOpen ? "rotate-180" : ""
}`}
/>
</button>
{servicesOpen && (
<div className="mb-1 ml-3 border-l border-gray-100 pl-3">
{services.map((s) => (
<Link
key={s.slug}
href={`/hizmetler/${s.slug}`}
className="block rounded-lg px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
>
{s.title}
</Link>
))}
<Link
href="/hizmetler"
className="block rounded-lg px-3 py-2 text-sm font-semibold text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tüm hizmetleri gör
</Link>
</div>
)}
{LINKS.filter((l) => l.href !== "/").map((l) => (
<Link
key={l.href}
href={l.href}
className="block rounded-xl px-4 py-3 text-base font-medium text-gray-800 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
>
{l.label}
</Link>
))}
</nav>
{/* Alt CTA */}
<div className="space-y-2 border-t border-gray-100 p-4">
<a
href={`tel:${phoneRaw}`}
className="flex items-center justify-center gap-2 rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-[var(--navy)] transition-colors hover:border-[var(--navy)]"
>
<Phone className="size-4" />
{phone}
</a>
<Link
href="/iletisim"
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--navy)] px-4 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--navy-700)]"
>
Ücretsiz Teklif
<ArrowRight className="size-4" />
</Link>
</div>
</div>
</div>
</div>
);
}
+12 -4
View File
@@ -2,7 +2,6 @@ import Link from "next/link";
import { ArrowUpRight } from "lucide-react"; 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";
type ServiceLike = { type ServiceLike = {
slug: string; slug: string;
@@ -11,18 +10,27 @@ type ServiceLike = {
icon?: string | null; icon?: string | null;
}; };
export function ServicesGrid({ services }: { services: ServiceRow[] }) { // Hem Hizmetler hem Çözümler için kullanılır — sadece basePath ve fallback değişir.
export function ServicesGrid({
services,
basePath = "/hizmetler",
fallback,
}: {
services: ServiceLike[];
basePath?: string;
fallback?: readonly ServiceLike[];
}) {
const items: ServiceLike[] = const items: ServiceLike[] =
services.length > 0 services.length > 0
? services ? services
: (siteConfig.fallbackServices as readonly ServiceLike[]).slice(); : ((fallback ?? siteConfig.fallbackServices) as readonly ServiceLike[]).slice();
return ( return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((s) => ( {items.map((s) => (
<Link <Link
key={s.slug} key={s.slug}
href={`/hizmetler/${s.slug}`} href={`${basePath}/${s.slug}`}
id={s.slug} id={s.slug}
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-2 hover:border-[var(--sky)]/40 hover:shadow-2xl hover:shadow-[var(--navy)]/10" className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-2 hover:border-[var(--sky)]/40 hover:shadow-2xl hover:shadow-[var(--navy)]/10"
> >
+209
View File
@@ -0,0 +1,209 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, ArrowRight, MessageCircle, Phone, Sparkles, CheckCircle2 } from "lucide-react";
import { Icon } from "@/components/icon";
import type { SolutionRow, SiteSettingsRow } from "@/lib/types";
import { siteConfig } from "@/lib/site-config";
const QUICK_TRUST = [
"İşletmenize özel kurgu",
"Tek elden uçtan uca",
"Ücretsiz keşif görüşmesi",
"Yerel ekip — Kocaeli",
];
export function SolutionHero({
solution,
settings,
}: {
solution: SolutionRow;
settings?: SiteSettingsRow | null;
}) {
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const wa = phoneRaw.replace(/[^\d]/g, "");
const waMessage = settings?.whatsapp_message ?? `Merhaba, ${solution.title} çözümü hakkında bilgi almak istiyorum.`;
const waHref = `https://wa.me/${wa}?text=${encodeURIComponent(waMessage)}`;
return (
<section className="relative overflow-hidden border-b border-[var(--border)] bg-gradient-to-br from-[var(--navy-50)]/60 via-white to-[var(--sky-50)]/40">
{/* Subtle grid + glow */}
<div className="absolute inset-0 hero-grid opacity-50" aria-hidden />
<div
className="absolute -right-32 top-1/2 size-[520px] -translate-y-1/2 rounded-full bg-gradient-to-br from-[var(--sky)]/15 to-transparent blur-3xl"
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 py-16 lg:py-20">
<Link
href="/cozumler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm çözümler
</Link>
<div className="mt-8 grid items-start gap-12 lg:grid-cols-[1.3fr_1fr]">
{/* Left — content */}
<div>
<div className="flex items-center gap-3">
<div className="relative">
<div
className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-br from-[var(--sky)] to-purple-500 blur-md opacity-50"
aria-hidden
/>
<div className="flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--sky)] to-purple-500 text-white shadow-lg">
<Icon name={solution.icon} className="size-8" />
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--sky)]/30 bg-white px-3 py-1 text-xs font-medium text-[var(--sky-600)]">
<Sparkles className="size-3.5" />
İşletmenize özel çözüm
</span>
</div>
<h1 className="mt-6 text-4xl font-extrabold leading-[1.1] tracking-tight text-[var(--navy)] sm:text-5xl lg:text-6xl">
<span className="gradient-text">{solution.title}</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
{solution.description}
</p>
{/* Quick trust strip */}
<ul className="mt-8 grid max-w-xl grid-cols-2 gap-2">
{QUICK_TRUST.map((it) => (
<li
key={it}
className="flex items-center gap-2 text-sm text-[var(--foreground)]"
>
<CheckCircle2 className="size-4 shrink-0 text-[var(--sky-600)]" />
{it}
</li>
))}
</ul>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href="/iletisim"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--navy)] px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-[var(--navy)]/20 transition hover:-translate-y-0.5 hover:bg-[var(--navy-700)]"
>
Ücretsiz teklif al
<ArrowRight className="size-4" />
</Link>
<a
href={waHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25d366] px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-[#25d366]/20 transition hover:-translate-y-0.5 hover:bg-[#1ebe5d]"
>
<MessageCircle className="size-4" />
WhatsApp'tan yaz
</a>
<a
href={`tel:${phoneRaw}`}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-white px-6 py-3.5 text-sm font-semibold text-[var(--navy)] transition hover:border-[var(--navy)]"
>
<Phone className="size-4" />
{phone}
</a>
</div>
</div>
{/* Right — hero card */}
<div className="relative">
{solution.hero_image ? (
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl shadow-2xl shadow-[var(--navy)]/10">
<Image
src={solution.hero_image}
alt={solution.title}
fill
sizes="(min-width: 1024px) 480px, 100vw"
className="object-cover"
priority
/>
{/* Floating badge */}
<div className="absolute bottom-4 left-4 right-4 rounded-xl bg-white/95 p-4 backdrop-blur shadow-lg">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--sky-600)]">
Şimdi başla
</p>
<p className="mt-1 text-sm font-bold text-[var(--navy)]">
Ücretsiz keşif görüşmesi
</p>
</div>
</div>
) : (
<DecorativeSolutionCard solution={solution} />
)}
</div>
</div>
</div>
</section>
);
}
function DecorativeSolutionCard({ solution }: { solution: SolutionRow }) {
return (
<div className="relative">
{/* Outer gradient frame */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--navy)] via-[var(--sky-600)] to-[var(--sky)] p-px shadow-2xl shadow-[var(--navy)]/20">
<div className="relative rounded-3xl bg-[#0f172a] p-8">
{/* Animated dots */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
backgroundSize: "24px 24px",
}}
aria-hidden
/>
{/* Glow */}
<div className="absolute -right-20 -top-20 size-64 rounded-full bg-[var(--sky)]/30 blur-3xl" aria-hidden />
{/* Card content */}
<div className="relative">
<div className="flex size-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur ring-1 ring-white/20">
<Icon name={solution.icon} className="size-10 text-[var(--sky)]" />
</div>
<div className="mt-8 space-y-2 text-white">
<p className="text-[11px] font-mono uppercase tracking-[0.2em] text-[var(--sky)]">
kovak.yazilim
</p>
<p className="text-2xl font-bold leading-tight">
{solution.title}
</p>
<p className="text-sm leading-relaxed text-white/60">
İşletmenize özel, uçtan uca çözüm.
</p>
</div>
{/* Bottom badges */}
<div className="mt-8 flex flex-wrap gap-2">
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
Hızlı
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
🛡 Garantili
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
📞 7/24 Destek
</span>
</div>
</div>
</div>
</div>
{/* Floating accent */}
<div className="absolute -right-4 -top-4 rounded-2xl bg-white p-4 shadow-xl ring-1 ring-[var(--border)]">
<p className="text-xs font-medium text-[var(--muted)]">Memnuniyet</p>
<p className="text-2xl font-bold text-[var(--navy)]">100%</p>
</div>
<div className="absolute -bottom-4 -left-4 rounded-2xl bg-white p-4 shadow-xl ring-1 ring-[var(--border)]">
<p className="text-xs font-medium text-[var(--muted)]">Proje</p>
<p className="text-2xl font-bold text-[var(--navy)]">150+</p>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import Link from "next/link";
import { ArrowRight, MessageCircle, Phone, ShieldCheck } from "lucide-react";
import { Icon } from "@/components/icon";
import { getSiteSettings, listSolutions } from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { QuickLeadForm } from "@/components/quick-lead-form";
export async function SolutionSidebar({
currentSlug,
}: {
currentSlug: string;
}) {
const [settings, solutions] = await Promise.all([
getSiteSettings(),
listSolutions(),
]);
const otherSolutions = solutions
.filter((s) => s.slug !== currentSlug)
.slice(0, 6);
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const wa = phoneRaw.replace(/[^\d]/g, "");
const waMessage = settings?.whatsapp_message ?? "";
const waHref = `https://wa.me/${wa}${
waMessage ? `?text=${encodeURIComponent(waMessage)}` : ""
}`;
return (
<aside className="space-y-5 lg:sticky lg:top-24 lg:self-start">
{/* Quick lead form */}
<QuickLeadForm
title="Bu çözüm için teklif"
description="Adınızı ve telefonunuzu bırakın, 24 saat içinde sizi arayalım."
buttonLabel="Beni arayın"
/>
{/* CTA card */}
<div className="overflow-hidden rounded-2xl border border-[var(--border)] bg-gradient-to-br from-[var(--navy)] to-[var(--sky-600)] p-6 text-white">
<h3 className="text-base font-bold">Hızlı iletişim</h3>
<p className="mt-1 text-sm text-white/80">
Telefon veya WhatsApp ile dakikalar içinde konuşalım.
</p>
<div className="mt-4 space-y-2">
<a
href={`tel:${phoneRaw}`}
className="flex items-center justify-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-[var(--navy)] transition hover:bg-blue-50"
>
<Phone className="size-3.5" />
{phone}
</a>
<a
href={waHref}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-xl bg-[#25d366] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-[#1ebe5d]"
>
<MessageCircle className="size-3.5" />
WhatsApp'tan yaz
</a>
</div>
</div>
{/* Guarantee mini */}
<div className="rounded-2xl border border-[var(--sky)]/30 bg-[var(--sky-50)]/50 p-5">
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-[var(--sky-600)]" />
<h3 className="text-sm font-bold text-[var(--navy)]">
Risk almazsınız
</h3>
</div>
<ul className="mt-3 space-y-1.5 text-xs text-[var(--foreground)]">
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
Ücretsiz keşif görüşmesi
</li>
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
1 yıl ücretsiz teknik destek
</li>
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
Kaynak kodlar size ait
</li>
</ul>
</div>
{/* Diğer çözümler — full list */}
{otherSolutions.length > 0 && (
<div className="rounded-2xl border border-[var(--border)] bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold uppercase tracking-wider text-[var(--navy)]">
Diğer çözümler
</h3>
<Link
href="/cozumler"
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümü
</Link>
</div>
<ul className="mt-4 space-y-1">
{otherSolutions.map((s) => (
<li key={s.slug}>
<Link
href={`/cozumler/${s.slug}`}
className="group flex items-center gap-3 rounded-lg px-2 py-2 text-sm transition hover:bg-[var(--navy-50)]"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--navy-50)] text-[var(--navy)] transition group-hover:bg-gradient-to-br group-hover:from-[var(--sky)] group-hover:to-purple-500 group-hover:text-white">
<Icon name={s.icon} className="size-4" />
</div>
<span className="flex-1 font-medium text-[var(--foreground)] group-hover:text-[var(--navy)]">
{s.title}
</span>
<ArrowRight className="size-3 text-[var(--muted)] opacity-0 transition group-hover:opacity-100" />
</Link>
</li>
))}
</ul>
</div>
)}
{/* Site analizi lead magnet */}
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-white p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--sky-600)]">
Ücretsiz fırsat
</p>
<h3 className="mt-1 text-sm font-bold text-[var(--navy)]">
Site analizi raporu
</h3>
<p className="mt-2 text-xs leading-relaxed text-[var(--muted)]">
Mevcut sitenizin SEO, hız ve dönüşüm performansını ücretsiz değerlendirelim.
</p>
<Link
href="/site-analizi"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Hemen başla
<ArrowRight className="size-3" />
</Link>
</div>
</aside>
);
}
+98 -14
View File
@@ -45,6 +45,12 @@ function strArr(v: FormDataEntryValue | null) {
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean); .filter(Boolean);
} }
// Çok satırlı (textarea) alanlar için ham metin. Tarayıcılar textarea
// içeriğini CRLF (\r\n) ile gönderir; satır-tabanlı parser'lar \n beklediği
// için (özellikle "\n---\n" blok ayracı) okurken normalize ediyoruz.
function raw(v: FormDataEntryValue | null) {
return String(v ?? "").replace(/\r\n?/g, "\n");
}
// ─── Media ─────────────────────────────────────────────────────── // ─── Media ───────────────────────────────────────────────────────
@@ -130,7 +136,7 @@ export async function saveService(formData: FormData) {
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":"..."} // FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
const faqRaw = String(formData.get("faq") ?? ""); const faqRaw = raw(formData.get("faq"));
const faq = faqRaw const faq = faqRaw
.split("\n---\n") .split("\n---\n")
.map((block) => { .map((block) => {
@@ -178,6 +184,60 @@ export async function deleteService(formData: FormData) {
revalidatePath("/hizmetler"); revalidatePath("/hizmetler");
} }
// ─── Solutions ───────────────────────────────────────────────────
export async function saveSolution(formData: FormData) {
const secret = await requireSessionSecret();
const id = str(formData.get("id"));
const title = str(formData.get("title"));
if (!title) throw new Error("Başlık zorunlu");
const description = str(formData.get("description"));
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 = raw(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,
description,
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.solutions, id, data, secret);
} else {
await tablesDB.createRow(DATABASE_ID, TABLES.solutions, slug, data, secret);
}
revalidatePath("/admin/cozumler");
revalidatePath("/cozumler");
revalidatePath("/");
}
export async function deleteSolution(formData: FormData) {
const secret = await requireSessionSecret();
const id = String(formData.get("id"));
await tablesDB.deleteRow(DATABASE_ID, TABLES.solutions, id, secret);
revalidatePath("/admin/cozumler");
revalidatePath("/cozumler");
}
// ─── Projects ──────────────────────────────────────────────────── // ─── Projects ────────────────────────────────────────────────────
function parseMetricsInput(raw: string): string[] { function parseMetricsInput(raw: string): string[] {
@@ -201,7 +261,7 @@ export async function saveProject(formData: FormData) {
if (!description) throw new Error("Açıklama zorunlu"); if (!description) throw new Error("Açıklama zorunlu");
// Gallery: one URL per line // Gallery: one URL per line
const galleryRaw = String(formData.get("gallery") ?? ""); const galleryRaw = raw(formData.get("gallery"));
const gallery = galleryRaw const gallery = galleryRaw
.split("\n") .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
@@ -223,8 +283,9 @@ export async function saveProject(formData: FormData) {
industry: str(formData.get("industry")), industry: str(formData.get("industry")),
duration: str(formData.get("duration")), duration: str(formData.get("duration")),
service_slug: str(formData.get("service_slug")), service_slug: str(formData.get("service_slug")),
solution_slug: str(formData.get("solution_slug")),
metrics: (() => { metrics: (() => {
const m = parseMetricsInput(String(formData.get("metrics") ?? "")); const m = parseMetricsInput(raw(formData.get("metrics")));
return m.length > 0 ? m : null; return m.length > 0 ? m : null;
})(), })(),
}; };
@@ -302,7 +363,7 @@ export async function saveSiteSettings(formData: FormData) {
const secret = await requireSessionSecret(); const secret = await requireSessionSecret();
// Hero stats: 3 satır halinde "value|label" formatında — JSON array'e çevir // Hero stats: 3 satır halinde "value|label" formatında — JSON array'e çevir
const statsRaw = String(formData.get("hero_stats") ?? ""); const statsRaw = raw(formData.get("hero_stats"));
const stats = statsRaw const stats = statsRaw
.split("\n") .split("\n")
.map((line) => { .map((line) => {
@@ -313,7 +374,7 @@ export async function saveSiteSettings(formData: FormData) {
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
// Trust items: "icon|value|label" satırlar // Trust items: "icon|value|label" satırlar
const trustRaw = String(formData.get("trust_items") ?? ""); const trustRaw = raw(formData.get("trust_items"));
const trust = trustRaw const trust = trustRaw
.split("\n") .split("\n")
.map((line) => { .map((line) => {
@@ -350,21 +411,18 @@ export async function saveSiteSettings(formData: FormData) {
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
} }
const whyUs = parseBlocks(String(formData.get("why_us") ?? ""), true); const whyUs = parseBlocks(raw(formData.get("why_us")), true);
const processSteps = parseBlocks( const processSteps = parseBlocks(raw(formData.get("process_steps")), false);
String(formData.get("process_steps") ?? ""),
false,
);
// Client logos: her satıra bir URL // Client logos: her satıra bir URL
const logosRaw = String(formData.get("client_logos") ?? ""); const logosRaw = raw(formData.get("client_logos"));
const logos = logosRaw const logos = logosRaw
.split("\n") .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
// Hakkımızda values: blok '---' ile, ilk satır title, kalanı description // Hakkımızda values: blok '---' ile, ilk satır title, kalanı description
const aboutValuesRaw = String(formData.get("about_values") ?? ""); const aboutValuesRaw = raw(formData.get("about_values"));
const aboutValues = aboutValuesRaw const aboutValues = aboutValuesRaw
.split("\n---\n") .split("\n---\n")
.map((block) => { .map((block) => {
@@ -377,7 +435,7 @@ export async function saveSiteSettings(formData: FormData) {
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
// Hakkımızda stats: 'value | label' satırlar // Hakkımızda stats: 'value | label' satırlar
const aboutStatsRaw = String(formData.get("about_stats") ?? ""); const aboutStatsRaw = raw(formData.get("about_stats"));
const aboutStats = aboutStatsRaw const aboutStats = aboutStatsRaw
.split("\n") .split("\n")
.map((line) => { .map((line) => {
@@ -387,6 +445,24 @@ export async function saveSiteSettings(formData: FormData) {
}) })
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
// Garanti maddeleri: her satır bir madde
const guaranteeItems = raw(formData.get("guarantee_items"))
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
// Anasayfa SSS: blok '---' ile ayrılır, ilk satır soru, kalanı cevap
const homepageFaq = raw(formData.get("homepage_faq"))
.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 = {
hero_badge: str(formData.get("hero_badge")), hero_badge: str(formData.get("hero_badge")),
hero_title: str(formData.get("hero_title")), hero_title: str(formData.get("hero_title")),
@@ -401,6 +477,10 @@ export async function saveSiteSettings(formData: FormData) {
services_title: str(formData.get("services_title")), services_title: str(formData.get("services_title")),
services_description: str(formData.get("services_description")), services_description: str(formData.get("services_description")),
solutions_eyebrow: str(formData.get("solutions_eyebrow")),
solutions_title: str(formData.get("solutions_title")),
solutions_description: str(formData.get("solutions_description")),
projects_eyebrow: str(formData.get("projects_eyebrow")), projects_eyebrow: str(formData.get("projects_eyebrow")),
projects_title: str(formData.get("projects_title")), projects_title: str(formData.get("projects_title")),
projects_description: str(formData.get("projects_description")), projects_description: str(formData.get("projects_description")),
@@ -433,6 +513,10 @@ export async function saveSiteSettings(formData: FormData) {
trust_items: trust.length > 0 ? trust : null, trust_items: trust.length > 0 ? trust : null,
why_us: whyUs.length > 0 ? whyUs : null, why_us: whyUs.length > 0 ? whyUs : null,
process_steps: processSteps.length > 0 ? processSteps : null, process_steps: processSteps.length > 0 ? processSteps : null,
homepage_faq: homepageFaq.length > 0 ? homepageFaq : null,
guarantee_title: str(formData.get("guarantee_title")),
guarantee_description: str(formData.get("guarantee_description")),
guarantee_items: guaranteeItems.length > 0 ? guaranteeItems : null,
lead_form_title: str(formData.get("lead_form_title")), lead_form_title: str(formData.get("lead_form_title")),
lead_form_description: str(formData.get("lead_form_description")), lead_form_description: str(formData.get("lead_form_description")),
google_review_url: str(formData.get("google_review_url")), google_review_url: str(formData.get("google_review_url")),
@@ -596,7 +680,7 @@ export async function saveIndustry(formData: FormData) {
if (!title) throw new Error("Başlık zorunlu"); if (!title) throw new Error("Başlık zorunlu");
const slug = str(formData.get("slug")) || slugify(title); const slug = str(formData.get("slug")) || slugify(title);
const faqRaw = String(formData.get("faq") ?? ""); const faqRaw = raw(formData.get("faq"));
const faq = faqRaw const faq = faqRaw
.split("\n---\n") .split("\n---\n")
.map((block) => { .map((block) => {
+1
View File
@@ -16,6 +16,7 @@ export const MEDIA_BUCKET_ID =
export const TABLES = { export const TABLES = {
contactMessages: "contact_messages", contactMessages: "contact_messages",
services: "services", services: "services",
solutions: "solutions",
projects: "projects", projects: "projects",
blogPosts: "blog_posts", blogPosts: "blog_posts",
testimonials: "testimonials", testimonials: "testimonials",
+17
View File
@@ -7,6 +7,7 @@ import type {
IndustryRow, IndustryRow,
ProjectRow, ProjectRow,
ServiceRow, ServiceRow,
SolutionRow,
SeoPageRow, SeoPageRow,
SeoSettingsRow, SeoSettingsRow,
SiteSettingsRow, SiteSettingsRow,
@@ -44,14 +45,22 @@ export async function listServices(opts?: { featured?: boolean }) {
return safeList<ServiceRow>(TABLES.services, q); return safeList<ServiceRow>(TABLES.services, q);
} }
export async function listSolutions(opts?: { featured?: boolean }) {
const q = [Q.orderAsc("order"), Q.limit(50)];
if (opts?.featured) q.unshift(Q.equal("featured", true));
return safeList<SolutionRow>(TABLES.solutions, q);
}
export async function listProjects(opts?: { export async function listProjects(opts?: {
featured?: boolean; featured?: boolean;
limit?: number; limit?: number;
serviceSlug?: string; serviceSlug?: string;
solutionSlug?: 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)); if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug));
if (opts?.solutionSlug) q.unshift(Q.equal("solution_slug", opts.solutionSlug));
return safeList<ProjectRow>(TABLES.projects, q); return safeList<ProjectRow>(TABLES.projects, q);
} }
@@ -63,6 +72,14 @@ export async function getServiceBySlug(slug: string): Promise<ServiceRow | null>
return res[0] ?? null; return res[0] ?? null;
} }
export async function getSolutionBySlug(slug: string): Promise<SolutionRow | null> {
const res = await safeList<SolutionRow>(TABLES.solutions, [
Q.equal("slug", slug),
Q.limit(1),
]);
return res[0] ?? null;
}
export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> { export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> {
const res = await safeList<ProjectRow>(TABLES.projects, [ const res = await safeList<ProjectRow>(TABLES.projects, [
Q.equal("slug", slug), Q.equal("slug", slug),
+6
View File
@@ -25,4 +25,10 @@ export const siteConfig = {
{ slug: "sosyal-medya-yonetimi", title: "Sosyal Medya Yönetimi", icon: "Share2", description: "Marka diliyle uyumlu içerik üretimi ve topluluk yönetimi." }, { slug: "sosyal-medya-yonetimi", title: "Sosyal Medya Yönetimi", icon: "Share2", description: "Marka diliyle uyumlu içerik üretimi ve topluluk yönetimi." },
{ slug: "dijital-reklam", title: "Dijital Reklam", icon: "Megaphone", description: "Google Ads ve Meta Ads kampanyalarıyla hedefli erişim ve ölçülebilir sonuçlar." }, { slug: "dijital-reklam", title: "Dijital Reklam", icon: "Megaphone", description: "Google Ads ve Meta Ads kampanyalarıyla hedefli erişim ve ölçülebilir sonuçlar." },
], ],
fallbackSolutions: [
{ slug: "kurumsal-dijitallesme", title: "Kurumsal Dijitalleşme", icon: "Layers", description: "Web, mobil ve iç sistemleri tek çatı altında toplayan uçtan uca dijitalleşme paketi." },
{ slug: "online-satis-altyapisi", title: "Online Satış Altyapısı", icon: "ShoppingCart", description: "E-ticaret, ödeme ve stok entegrasyonlarıyla satışa hazır komple altyapı." },
{ slug: "musteri-yonetimi-crm", title: "Müşteri Yönetimi (CRM)", icon: "Users", description: "Satış, destek ve operasyon süreçlerini tek panelde toplayan CRM çözümü." },
{ slug: "buyume-pazarlama", title: "Büyüme & Pazarlama", icon: "TrendingUp", description: "SEO, reklam ve içerikle ölçülebilir müşteri kazanımı sağlayan büyüme paketi." },
],
} as const; } as const;
+19
View File
@@ -15,6 +15,20 @@ export interface ServiceRow extends AwRow {
hero_image?: string | null; hero_image?: string | null;
} }
// İşletmelere sunulan çözümler — Hizmetler ile birebir aynı yapı, ayrı tablo.
export interface SolutionRow extends AwRow {
slug: string;
title: string;
description: string;
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 { export interface FaqItem {
q: string; q: string;
a: string; a: string;
@@ -36,6 +50,7 @@ export interface ProjectRow extends AwRow {
industry?: string | null; industry?: string | null;
duration?: string | null; duration?: string | null;
service_slug?: string | null; service_slug?: string | null;
solution_slug?: string | null;
metrics?: string[] | null; // JSON {"value":"+150%","label":"Trafik artışı"} metrics?: string[] | null; // JSON {"value":"+150%","label":"Trafik artışı"}
} }
@@ -106,6 +121,10 @@ export interface SiteSettingsRow extends AwRow {
services_title?: string | null; services_title?: string | null;
services_description?: string | null; services_description?: string | null;
solutions_eyebrow?: string | null;
solutions_title?: string | null;
solutions_description?: string | null;
projects_eyebrow?: string | null; projects_eyebrow?: string | null;
projects_title?: string | null; projects_title?: string | null;
projects_description?: string | null; projects_description?: string | null;