Compare commits
5 Commits
1813b96f82
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d49c9aa225 | |||
| a321ac5c9b | |||
| 2e001680bf | |||
| f49df9cbeb | |||
| f3604d96b8 |
@@ -0,0 +1,31 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Kovak Yazılım — Production Environment Variables
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Bu dosya GIT'e EKLENİR (örnek değerler, sırlı şey YOK).
|
||||
# .env.local lokal geliştirme için, .env.production sadece referans.
|
||||
# Coolify'da bu key'leri "Environment Variables" panelinden gir.
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─── Appwrite (zorunlu) ───────────────────────────────────────
|
||||
# Appwrite Console > Settings'tan al
|
||||
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
|
||||
NEXT_PUBLIC_APPWRITE_PROJECT_ID=69f27b51000a5bee46ce
|
||||
NEXT_PUBLIC_APPWRITE_DATABASE_ID=kovak-yazilim-db
|
||||
NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=kovak-yazilim-media
|
||||
|
||||
|
||||
# ─── Appwrite API Key (opsiyonel) ─────────────────────────────
|
||||
# Şu anki mimaride session cookie tabanlı auth kullanılıyor, API
|
||||
# key'e gerek YOK. Sadece ileride sunucu tarafı admin işlemleri için
|
||||
# (cron, scheduled jobs vs) eklemek istersen kullanılır.
|
||||
#
|
||||
# Console > Settings > API Keys > Create:
|
||||
# Scopes: databases.read, tables.read, rows.read, rows.write,
|
||||
# files.read, files.write, users.read
|
||||
APPWRITE_API_KEY=
|
||||
|
||||
|
||||
# ─── Node ortamı ──────────────────────────────────────────────
|
||||
# Coolify otomatik 'production' verir, lokalde 'development'
|
||||
# NODE_ENV=production
|
||||
+5
-1
@@ -30,8 +30,9 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# env files — only ignore real secrets, keep .env.example for reference
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -39,3 +40,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# SEO audit çıktıları (repoya girmesin)
|
||||
seo-audit/
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Deployment Rehberi — Coolify
|
||||
|
||||
## Önkoşullar
|
||||
|
||||
1. Coolify'da yeni bir **Application** oluştur:
|
||||
- Source: Git → `ssh://git.kovaksoft.com:2222/kovakmedya/kovakyazilim.git`
|
||||
- Branch: `main`
|
||||
- Build Pack: **Nixpacks** (otomatik Next.js algılar) veya **Dockerfile** yoksa Nixpacks
|
||||
- Domain: örn. `kovakyazilim.com` veya `yeni.kovakyazilim.com`
|
||||
- Port: `3000`
|
||||
|
||||
2. Build & Start command'ları:
|
||||
- Install: `npm ci`
|
||||
- Build: `npm run build`
|
||||
- Start: `npm start`
|
||||
|
||||
## Environment Variables (Coolify > Environment)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://db.kovaksoft.com/v1
|
||||
NEXT_PUBLIC_APPWRITE_PROJECT_ID=69f27b51000a5bee46ce
|
||||
NEXT_PUBLIC_APPWRITE_DATABASE_ID=kovak-yazilim-db
|
||||
NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=kovak-yazilim-media
|
||||
```
|
||||
|
||||
> `APPWRITE_API_KEY` şu anda **kullanılmıyor** — mimari session cookie tabanlı. İleride cron/scheduled job eklersen Appwrite Console'dan key oluştur.
|
||||
|
||||
## Build sonrası kontroller
|
||||
|
||||
1. Site yükleniyor mu? → `https://[domain]/`
|
||||
2. Admin login çalışıyor mu? → `https://[domain]/admin/login`
|
||||
- İlk admin kullanıcısını **Appwrite Console > Auth > Users** üzerinden oluştur
|
||||
3. Cookie banner çıkıyor mu? → Anasayfa açıldığında 800ms sonra
|
||||
4. WhatsApp float + sticky mobil bar görünüyor mu?
|
||||
|
||||
## Gitea Webhook
|
||||
|
||||
Coolify, Gitea'dan push webhook'unu otomatik yapılandırır:
|
||||
- URL (Gitea > Settings > Webhooks): `https://admin.kovaksoft.com/webhooks/source/gitea/events/manual`
|
||||
- Event: `push`
|
||||
- Branch filter: `main`
|
||||
|
||||
Her `git push origin main` Coolify'da otomatik build + deploy tetikler.
|
||||
|
||||
## Domain yönlendirmesi
|
||||
|
||||
Mevcut WP sitesi `kovakyazilim.com`'da. Geçiş seçenekleri:
|
||||
|
||||
| Yaklaşım | Açıklama |
|
||||
|---|---|
|
||||
| **A. Tek seferlik geçiş** | DNS kaydını Coolify'a yönlendir. WP yedeğini al, sonra kapat. |
|
||||
| **B. Test subdomain'i** | `yeni.kovakyazilim.com` ile Next.js'i yayınla, test et, ardından ana domain'i değiştir. |
|
||||
| **C. Hibrit** | Eski WP `eski.kovakyazilim.com`'a taşı, ana domain'de Next.js. |
|
||||
|
||||
**Önerilen: B** — test ortamında doğrula, sonra geçiş.
|
||||
|
||||
## Production'a alma checklist
|
||||
|
||||
- [ ] Coolify'da app oluşturuldu, ENV'ler girildi
|
||||
- [ ] İlk build başarılı (`npm run build` 39 route üretmeli)
|
||||
- [ ] Appwrite Console'da admin user oluşturuldu
|
||||
- [ ] `/admin/login` çalışıyor
|
||||
- [ ] Anasayfa, hizmetler, projeler, iletişim formu sınanmış
|
||||
- [ ] WhatsApp + telefon CTA çalışıyor
|
||||
- [ ] Cookie banner görünüyor
|
||||
- [ ] SSL aktif (Coolify Let's Encrypt otomatik)
|
||||
- [ ] WP yedeği alındı (ihtiyaç olursa)
|
||||
- [ ] DNS yönlendirildi
|
||||
|
||||
## Sonradan eklenecekler
|
||||
|
||||
- **GTM ID**: `/admin/seo` üzerinden `gtm_id` alanına gir → Consent Mode v2 ile uyumlu otomatik yüklenir
|
||||
- **Müşteri logoları**: `/admin/site` → "Müşteri logoları" alanına URL ekle
|
||||
- **Blog yazıları, projeler, ekip fotoları**: `/admin/*` üzerinden
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Calendar } from "lucide-react";
|
||||
import { renderContent } from "@/lib/content-render";
|
||||
import { getPostBySlug } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { BlogPostingLd } from "@/components/json-ld";
|
||||
import { ContentSidebar } from "@/components/content-sidebar";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -19,6 +20,7 @@ export async function generateMetadata({
|
||||
return buildMetadata(`/blog/${slug}`, {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
keywords: post.tags ?? undefined,
|
||||
openGraph: {
|
||||
title: post.seo_title || post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
@@ -44,6 +46,7 @@ export default async function BlogPostPage({
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||
<BlogPostingLd post={post} />
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { SectionTitle } from "@/components/section-title";
|
||||
import { TeamGrid } from "@/components/team-grid";
|
||||
import { listTeamMembers } from "@/lib/data";
|
||||
import { getSiteSettings, listTeamMembers } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import type { AboutValue, StatItem } from "@/lib/types";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildMetadata("/hakkimizda", {
|
||||
@@ -14,31 +15,68 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
});
|
||||
}
|
||||
|
||||
const values = [
|
||||
{
|
||||
title: "Uçtan uca üretim",
|
||||
description:
|
||||
"Fikir aşamasından lansmana, lansman sonrası bakıma kadar tek bir ekip.",
|
||||
},
|
||||
{
|
||||
title: "Ölçülebilir sonuç",
|
||||
description:
|
||||
"Her projeyi performans, dönüşüm ve kullanıcı deneyimi metrikleriyle değerlendiriyoruz.",
|
||||
},
|
||||
{
|
||||
title: "Şeffaf süreç",
|
||||
description:
|
||||
"Her sprint demo ile başlar, her engel açıkça konuşulur. Sürprize yer yok.",
|
||||
},
|
||||
{
|
||||
title: "Uzun vadeli ortaklık",
|
||||
description:
|
||||
"Proje biter, iş büyür. Bakım ve geliştirme süreçlerinde yanınızdayız.",
|
||||
},
|
||||
const DEFAULT_VALUES: AboutValue[] = [
|
||||
{ title: "Uçtan uca üretim", description: "Fikir aşamasından lansmana, lansman sonrası bakıma kadar tek bir ekip." },
|
||||
{ title: "Ölçülebilir sonuç", description: "Her projeyi performans, dönüşüm ve kullanıcı deneyimi metrikleriyle değerlendiriyoruz." },
|
||||
{ title: "Şeffaf süreç", description: "Her sprint demo ile başlar, her engel açıkça konuşulur. Sürprize yer yok." },
|
||||
{ title: "Uzun vadeli ortaklık", description: "Proje biter, iş büyür. Bakım ve geliştirme süreçlerinde yanınızdayız." },
|
||||
];
|
||||
|
||||
const DEFAULT_STATS: StatItem[] = [
|
||||
{ value: "50+", label: "Tamamlanan proje" },
|
||||
{ value: "30+", label: "Mutlu müşteri" },
|
||||
{ value: "10+", label: "Yıllık deneyim" },
|
||||
];
|
||||
|
||||
function parseValues(items?: string[] | null): AboutValue[] {
|
||||
if (!items || items.length === 0) return DEFAULT_VALUES;
|
||||
const out: AboutValue[] = [];
|
||||
for (const raw of items) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<AboutValue>;
|
||||
if (obj.title && obj.description) out.push({ title: obj.title, description: obj.description });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : DEFAULT_VALUES;
|
||||
}
|
||||
|
||||
function parseStats(items?: string[] | null): StatItem[] {
|
||||
if (!items || items.length === 0) return DEFAULT_STATS;
|
||||
const out: StatItem[] = [];
|
||||
for (const raw of items) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<StatItem>;
|
||||
if (obj.value && obj.label) out.push({ value: obj.value, label: obj.label });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return out.length > 0 ? out : DEFAULT_STATS;
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const team = await listTeamMembers();
|
||||
const [team, settings] = await Promise.all([
|
||||
listTeamMembers(),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
|
||||
const eyebrow = settings?.about_eyebrow ?? "Hakkımızda";
|
||||
const title = settings?.about_title ?? "Kocaeli'den dünyaya dijital ürünler";
|
||||
const description =
|
||||
settings?.about_description ??
|
||||
"Kovak Yazılım, kurumsal markalardan girişimlere kadar geniş bir yelpazedeki müşterileri için web, mobil ve CRM çözümleri üretir. Hızlı, ölçeklenebilir ve estetik.";
|
||||
const values = parseValues(settings?.about_values);
|
||||
const heroImage = settings?.about_hero_image ?? null;
|
||||
|
||||
const teamEyebrow = settings?.about_team_eyebrow ?? "Ekibimiz";
|
||||
const teamTitle = settings?.about_team_title ?? "Projenizde Kimlerle Çalışırsınız?";
|
||||
const teamDescription =
|
||||
settings?.about_team_description ??
|
||||
"Sizin projenizde birebir çalışacak kurucular — teknik altyapı ve ürün geliştirmenin arkasındaki isimler.";
|
||||
|
||||
const stats = parseStats(settings?.about_stats);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,9 +85,9 @@ export default async function AboutPage() {
|
||||
<div>
|
||||
<SectionTitle
|
||||
align="left"
|
||||
eyebrow="Hakkımızda"
|
||||
title="Kocaeli'den dünyaya dijital ürünler"
|
||||
description="Kovak Yazılım, kurumsal markalardan girişimlere kadar geniş bir yelpazedeki müşterileri için web, mobil ve CRM çözümleri üretir. Hızlı, ölçeklenebilir ve estetik."
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
|
||||
<ul className="mt-10 space-y-4">
|
||||
@@ -67,14 +105,25 @@ export default async function AboutPage() {
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -z-10 rounded-3xl bg-gradient-to-br from-[var(--sky-50)] to-[var(--navy-50)]" />
|
||||
<div className="flex aspect-square items-center justify-center p-12">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Kovak Yazılım"
|
||||
width={400}
|
||||
height={400}
|
||||
className="size-full object-contain drop-shadow-xl"
|
||||
/>
|
||||
<div className="relative flex aspect-square items-center justify-center overflow-hidden rounded-3xl p-12">
|
||||
{heroImage ? (
|
||||
<Image
|
||||
src={heroImage}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Kovak Yazılım"
|
||||
width={400}
|
||||
height={400}
|
||||
className="size-full object-contain drop-shadow-xl"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,9 +133,9 @@ export default async function AboutPage() {
|
||||
<section className="border-y border-[var(--border)] bg-gray-50 py-20">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<SectionTitle
|
||||
eyebrow="Ekibimiz"
|
||||
title="Projenizde Kimlerle Çalışırsınız?"
|
||||
description="Sizin projenizde birebir çalışacak kurucular — teknik altyapı ve ürün geliştirmenin arkasındaki isimler."
|
||||
eyebrow={teamEyebrow}
|
||||
title={teamTitle}
|
||||
description={teamDescription}
|
||||
/>
|
||||
<div className="mt-14">
|
||||
<TeamGrid members={team} />
|
||||
@@ -95,20 +144,18 @@ export default async function AboutPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-[var(--navy)] py-20 text-white">
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 md:grid-cols-3">
|
||||
{[
|
||||
{ value: "50+", label: "Tamamlanan proje" },
|
||||
{ value: "30+", label: "Mutlu müşteri" },
|
||||
{ value: "10+", label: "Yıllık deneyim" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className="text-5xl font-bold">{s.value}</p>
|
||||
<p className="mt-2 text-sm text-white/70">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{stats.length > 0 && (
|
||||
<section className="bg-[var(--navy)] py-20 text-white">
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 md:grid-cols-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className="text-5xl font-bold">{s.value}</p>
|
||||
<p className="mt-2 text-sm text-white/70">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SectionTitle } from "@/components/section-title";
|
||||
import { FaqList } from "@/components/faq-list";
|
||||
import { ServiceHero } from "@/components/service-hero";
|
||||
import { ServiceSidebar } from "@/components/service-sidebar";
|
||||
import { ServiceLd, FaqLd } from "@/components/json-ld";
|
||||
import type { FaqItem } from "@/lib/types";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -59,6 +60,8 @@ export default async function ServiceDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServiceLd service={service} settings={settings} />
|
||||
<FaqLd items={faqItems} />
|
||||
<ServiceHero service={service} settings={settings} />
|
||||
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
|
||||
|
||||
+38
-6
@@ -18,6 +18,7 @@ import {
|
||||
getSiteSettings,
|
||||
listProjects,
|
||||
listServices,
|
||||
listSolutions,
|
||||
listTestimonials,
|
||||
} from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
@@ -28,12 +29,14 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const [services, projects, testimonials, settings] = await Promise.all([
|
||||
listServices({ featured: true }),
|
||||
listProjects({ featured: true, limit: 6 }),
|
||||
listTestimonials({ featured: true }),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
const [services, solutions, projects, testimonials, settings] =
|
||||
await Promise.all([
|
||||
listServices({ featured: true }),
|
||||
listSolutions({ featured: true }),
|
||||
listProjects({ featured: true, limit: 6 }),
|
||||
listTestimonials({ featured: true }),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
|
||||
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
|
||||
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
|
||||
@@ -118,6 +121,35 @@ export default async function Home() {
|
||||
</div>
|
||||
</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} />
|
||||
|
||||
<Guarantee settings={settings} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { renderContent } from "@/lib/content-render";
|
||||
import { getProjectBySlug, listProjects } from "@/lib/data";
|
||||
import { buildMetadata } from "@/lib/seo";
|
||||
import { Gallery } from "@/components/gallery";
|
||||
import { ArticleLd } from "@/components/json-ld";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import type { ProjectMetric } from "@/lib/types";
|
||||
|
||||
@@ -74,6 +75,7 @@ export default async function ProjectDetailPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArticleLd post={project} />
|
||||
<section className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto max-w-7xl px-6 py-12">
|
||||
<Link
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
FormActions,
|
||||
FormShell,
|
||||
PrimaryButton,
|
||||
} from "@/components/admin/form";
|
||||
import { saveNavMenu } from "@/lib/admin-actions";
|
||||
import { serializeNavItems, type NavItem } from "@/lib/nav";
|
||||
|
||||
export function MenuForm({ initial }: { initial: NavItem[] }) {
|
||||
const [items, setItems] = useState<NavItem[]>(initial);
|
||||
|
||||
function move(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVisible(index: number) {
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) =>
|
||||
i === index ? { ...it, visible: !it.visible } : it,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setLabel(index: number, value: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it, i) => (i === index ? { ...it, label: value } : it)),
|
||||
);
|
||||
}
|
||||
|
||||
const payload = serializeNavItems(
|
||||
items.map((i) => ({ key: i.key, visible: i.visible, label: i.label })),
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={saveNavMenu}>
|
||||
<input type="hidden" name="nav_items" value={payload} />
|
||||
<FormShell>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={`flex items-center gap-3 rounded-xl border border-[var(--border)] bg-white px-3 py-2.5 ${
|
||||
item.visible ? "" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<GripVertical className="size-4 shrink-0 text-[var(--muted)]" />
|
||||
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Yukarı taşı"
|
||||
className="text-[var(--muted)] transition hover:text-[var(--navy)] disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === items.length - 1}
|
||||
aria-label="Aşağı taşı"
|
||||
className="text-[var(--muted)] transition hover:text-[var(--navy)] disabled:opacity-30"
|
||||
>
|
||||
<ArrowDown className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={item.label}
|
||||
onChange={(e) => setLabel(i, e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--border)] bg-white px-3 py-1.5 text-sm outline-none transition focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs text-[var(--muted)]">
|
||||
{item.href}
|
||||
</span>
|
||||
|
||||
{item.mega && (
|
||||
<span className="shrink-0 rounded-full bg-[var(--navy-50)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--navy)]">
|
||||
Mega menü
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleVisible(i)}
|
||||
aria-label={item.visible ? "Gizle" : "Göster"}
|
||||
title={item.visible ? "Menüde görünür" : "Menüde gizli"}
|
||||
className={`inline-flex size-8 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
item.visible
|
||||
? "bg-[var(--navy)] text-white"
|
||||
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--navy)]"
|
||||
}`}
|
||||
>
|
||||
{item.visible ? (
|
||||
<Eye className="size-4" />
|
||||
) : (
|
||||
<EyeOff className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-[var(--muted)]">
|
||||
Sıralama ok tuşlarıyla değişir. Göz simgesiyle bir öğeyi menüden
|
||||
gizleyebilirsiniz. “Hizmetler” öğesi mega menü olarak
|
||||
açılır.
|
||||
</p>
|
||||
|
||||
<FormActions>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Menüyü kaydet
|
||||
</PrimaryButton>
|
||||
</FormActions>
|
||||
</FormShell>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PageHeader } from "@/components/admin/form";
|
||||
import { getSiteSettings } from "@/lib/data";
|
||||
import { resolveNavItems } from "@/lib/nav";
|
||||
import { MenuForm } from "./form";
|
||||
|
||||
export default async function MenuAdminPage() {
|
||||
const settings = await getSiteSettings();
|
||||
const items = resolveNavItems(settings?.nav_items);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Menü düzeni"
|
||||
description="Üst menü öğelerinin sırasını ve görünürlüğünü düzenleyin. Etiketi boş bırakırsanız varsayılan kullanılır."
|
||||
/>
|
||||
<MenuForm initial={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { RichEditor } from "@/components/admin/rich-editor";
|
||||
import { saveProject } from "@/lib/admin-actions";
|
||||
import { listServices } from "@/lib/data";
|
||||
import { listServices, listSolutions } from "@/lib/data";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
|
||||
export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
const services = await listServices();
|
||||
const [services, solutions] = await Promise.all([
|
||||
listServices(),
|
||||
listSolutions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<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.
|
||||
</span>
|
||||
</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="Sektör" name="industry" defaultValue={project?.industry} />
|
||||
<Field
|
||||
|
||||
@@ -40,6 +40,14 @@ export function SeoPageForm({ row }: { row?: SeoPageRow }) {
|
||||
rows={3}
|
||||
defaultValue={row?.description}
|
||||
/>
|
||||
<Textarea
|
||||
label="Anahtar kelimeler"
|
||||
name="keywords"
|
||||
rows={2}
|
||||
defaultValue={row?.keywords}
|
||||
placeholder="web tasarım, kurumsal site, kocaeli"
|
||||
help="Virgülle ayırın. Bu sayfaya özel; site geneli kelimelerle birleştirilir."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="OG görseli"
|
||||
name="og_image"
|
||||
|
||||
@@ -97,6 +97,17 @@ export default async function SeoAdminPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Anahtar kelimeler (site geneli)"
|
||||
name="default_keywords"
|
||||
rows={2}
|
||||
defaultValue={settings?.default_keywords}
|
||||
placeholder="yazılım geliştirme, web tasarım kocaeli, crm çözümleri, e-ticaret izmit"
|
||||
help="Virgülle ayırın. Tüm sayfalarda varsayılan olarak kullanılır; sayfa override ile birleştirilir."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<PrimaryButton>
|
||||
<Save className="size-4" /> Global ayarları kaydet
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getSiteSettings } from "@/lib/data";
|
||||
import { saveSiteSettings } from "@/lib/admin-actions";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import type {
|
||||
AboutValue,
|
||||
FaqItem,
|
||||
ProcessStep,
|
||||
StatItem,
|
||||
@@ -104,6 +105,21 @@ function faqToText(items?: string[] | null): string {
|
||||
return parsed.map((it) => `${it.q}\n${it.a}`).join("\n---\n");
|
||||
}
|
||||
|
||||
function aboutValuesToText(items?: string[] | null): string {
|
||||
if (!items) return "";
|
||||
const parsed: AboutValue[] = [];
|
||||
for (const raw of items) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<AboutValue>;
|
||||
if (obj.title && obj.description)
|
||||
parsed.push({ title: obj.title, description: obj.description });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return parsed.map((v) => `${v.title}\n${v.description}`).join("\n---\n");
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
@@ -215,6 +231,29 @@ export default async function SiteSettingsPage() {
|
||||
</div>
|
||||
</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
|
||||
title="Projeler bölümü başlığı"
|
||||
description="Anasayfadaki proje kartlarının üstündeki yazı."
|
||||
@@ -371,6 +410,96 @@ export default async function SiteSettingsPage() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Hakkımızda sayfası"
|
||||
description="/hakkimizda sayfasındaki metinler ve görsel."
|
||||
>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field
|
||||
label="Eyebrow"
|
||||
name="about_eyebrow"
|
||||
defaultValue={s?.about_eyebrow}
|
||||
placeholder="Hakkımızda"
|
||||
/>
|
||||
<Field
|
||||
label="Başlık"
|
||||
name="about_title"
|
||||
defaultValue={s?.about_title}
|
||||
placeholder="Kocaeli'den dünyaya dijital ürünler"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Açıklama paragrafı"
|
||||
name="about_description"
|
||||
rows={3}
|
||||
defaultValue={s?.about_description}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Değerler (4 madde önerilir)"
|
||||
name="about_values"
|
||||
rows={10}
|
||||
defaultValue={aboutValuesToText(s?.about_values)}
|
||||
placeholder={
|
||||
"Uçtan uca üretim\nFikir aşamasından lansmana, tek bir ekip.\n---\nÖlçülebilir sonuç\nHer projeyi metriklerle değerlendiriyoruz."
|
||||
}
|
||||
help='Her blok "---" ile ayrılır. İlk satır başlık, sonrası açıklama.'
|
||||
/>
|
||||
|
||||
<MediaPicker
|
||||
label="Hero görsel (opsiyonel)"
|
||||
name="about_hero_image"
|
||||
defaultValue={s?.about_hero_image}
|
||||
help="Boşsa logo gösterilir. Görsel eklersen logo yerine geçer."
|
||||
/>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Ekip bölümü
|
||||
</p>
|
||||
<div className="mt-3 grid gap-5 md:grid-cols-3">
|
||||
<Field
|
||||
label="Ekip eyebrow"
|
||||
name="about_team_eyebrow"
|
||||
defaultValue={s?.about_team_eyebrow}
|
||||
placeholder="Ekibimiz"
|
||||
/>
|
||||
<Field
|
||||
label="Ekip başlığı"
|
||||
name="about_team_title"
|
||||
defaultValue={s?.about_team_title}
|
||||
placeholder="Projenizde Kimlerle Çalışırsınız?"
|
||||
/>
|
||||
<Field
|
||||
label="Ekip açıklaması"
|
||||
name="about_team_description"
|
||||
defaultValue={s?.about_team_description}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--muted)]">
|
||||
Ekip üyeleri /admin/ekip üzerinden yönetilir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] pt-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
İstatistikler (en altta navy bant)
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Textarea
|
||||
label="Stats"
|
||||
name="about_stats"
|
||||
rows={4}
|
||||
defaultValue={statsToText(s?.about_stats)}
|
||||
placeholder={
|
||||
"50+ | Tamamlanan proje\n30+ | Mutlu müşteri\n10+ | Yıllık deneyim"
|
||||
}
|
||||
help='Her satır "değer | etiket" formatında.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Conversion / reklam optimizasyonu"
|
||||
description="Trust bandı, mini lead form ve WhatsApp metni."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
+2
-1
@@ -30,7 +30,8 @@ export const metadata: Metadata = {
|
||||
locale: "tr_TR",
|
||||
type: "website",
|
||||
},
|
||||
icons: { icon: "/logo.png" },
|
||||
// Favicon/app ikonları app/icon.png + app/apple-icon.png dosya
|
||||
// konvansiyonundan otomatik üretilir (logo.png'den kare kırpıldı).
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import {
|
||||
getSeoSettings,
|
||||
getSiteSettings,
|
||||
listIndustries,
|
||||
listProjects,
|
||||
listPublishedPosts,
|
||||
listServices,
|
||||
listSolutions,
|
||||
} from "@/lib/data";
|
||||
|
||||
// AI/LLM'lerin (ChatGPT, Perplexity, Claude, Google AI Overviews vb.) siteyi
|
||||
// hızlı ve doğru anlaması için /llms.txt rehberi.
|
||||
// Spec: https://llmstxt.org
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
const BASE = siteConfig.url;
|
||||
|
||||
function section(title: string, lines: string[]): string {
|
||||
if (lines.length === 0) return "";
|
||||
return `## ${title}\n\n${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [seo, site, services, solutions, posts, industries] = await Promise.all([
|
||||
getSeoSettings(),
|
||||
getSiteSettings(),
|
||||
listServices(),
|
||||
listSolutions(),
|
||||
listPublishedPosts({ limit: 30 }),
|
||||
listIndustries(),
|
||||
]);
|
||||
|
||||
const name = seo?.site_name || siteConfig.name;
|
||||
const summary =
|
||||
seo?.site_description || site?.footer_tagline || siteConfig.tagline;
|
||||
const phone = site?.contact_phone || siteConfig.contact.phone;
|
||||
const email = site?.contact_email || siteConfig.contact.email;
|
||||
const address = site?.contact_address || siteConfig.contact.address;
|
||||
|
||||
const link = (title: string, path: string, desc?: string) =>
|
||||
`- [${title}](${BASE}${path})${desc ? `: ${desc}` : ""}`;
|
||||
|
||||
const body = [
|
||||
`# ${name}`,
|
||||
"",
|
||||
`> ${summary}`,
|
||||
"",
|
||||
`${name}, Kocaeli/İzmit merkezli; yazılım geliştirme, web tasarım, e-ticaret, mobil uygulama, CRM ve dijital pazarlama hizmetleri sunar. İletişim: ${phone} · ${email} · ${address}`,
|
||||
"",
|
||||
section("Ana Sayfalar", [
|
||||
link("Anasayfa", "/"),
|
||||
link("Hizmetler", "/hizmetler", "Tüm hizmetlerin listesi"),
|
||||
link("Çözümler", "/cozumler", "Paket çözümler"),
|
||||
link("Projeler", "/projeler", "Portföy ve vaka çalışmaları"),
|
||||
link("Blog", "/blog", "Rehber içerikler ve yazılar"),
|
||||
link("Hakkımızda", "/hakkimizda"),
|
||||
link("İletişim", "/iletisim"),
|
||||
]),
|
||||
section(
|
||||
"Hizmetler",
|
||||
services.map((s) =>
|
||||
link(s.title, `/hizmetler/${s.slug}`, s.description ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Çözümler",
|
||||
solutions.map((s) =>
|
||||
link(s.title, `/cozumler/${s.slug}`, s.description ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Sektörler",
|
||||
industries.map((i) =>
|
||||
link(i.title, `/sektor/${i.slug}`, i.subtitle ?? undefined),
|
||||
),
|
||||
),
|
||||
section(
|
||||
"Blog Yazıları",
|
||||
posts.map((p) =>
|
||||
link(p.title, `/blog/${p.slug}`, p.excerpt ?? undefined),
|
||||
),
|
||||
),
|
||||
section("Kaynaklar", [link("Site haritası", "/sitemap.xml")]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/admin", "/api"],
|
||||
},
|
||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||
host: siteConfig.url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import {
|
||||
listIndustries,
|
||||
listProjects,
|
||||
listPublishedPosts,
|
||||
listServices,
|
||||
listSolutions,
|
||||
} from "@/lib/data";
|
||||
|
||||
const BASE = siteConfig.url;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const [posts, services, solutions, projects, industries] = await Promise.all([
|
||||
listPublishedPosts({ limit: 200 }),
|
||||
listServices(),
|
||||
listSolutions(),
|
||||
listProjects({ limit: 200 }),
|
||||
listIndustries(),
|
||||
]);
|
||||
|
||||
const staticRoutes: MetadataRoute.Sitemap = [
|
||||
{ url: `${BASE}/`, changeFrequency: "weekly", priority: 1 },
|
||||
{ url: `${BASE}/hizmetler`, changeFrequency: "monthly", priority: 0.9 },
|
||||
{ url: `${BASE}/cozumler`, changeFrequency: "monthly", priority: 0.9 },
|
||||
{ url: `${BASE}/projeler`, changeFrequency: "monthly", priority: 0.8 },
|
||||
{ url: `${BASE}/blog`, changeFrequency: "daily", priority: 0.8 },
|
||||
{ url: `${BASE}/hakkimizda`, changeFrequency: "yearly", priority: 0.6 },
|
||||
{ url: `${BASE}/iletisim`, changeFrequency: "yearly", priority: 0.7 },
|
||||
{ url: `${BASE}/site-analizi`, changeFrequency: "yearly", priority: 0.6 },
|
||||
{ url: `${BASE}/cerez-politikasi`, changeFrequency: "yearly", priority: 0.2 },
|
||||
];
|
||||
|
||||
const toEntry = (
|
||||
path: string,
|
||||
updatedAt?: string,
|
||||
priority = 0.7,
|
||||
): MetadataRoute.Sitemap[number] => ({
|
||||
url: `${BASE}${path}`,
|
||||
lastModified: updatedAt ? new Date(updatedAt) : undefined,
|
||||
changeFrequency: "weekly",
|
||||
priority,
|
||||
});
|
||||
|
||||
return [
|
||||
...staticRoutes,
|
||||
...posts.map((p) => toEntry(`/blog/${p.slug}`, p.$updatedAt, 0.7)),
|
||||
...services.map((s) => toEntry(`/hizmetler/${s.slug}`, s.$updatedAt, 0.8)),
|
||||
...solutions.map((s) => toEntry(`/cozumler/${s.slug}`, s.$updatedAt, 0.8)),
|
||||
...projects.map((p) => toEntry(`/projeler/${p.slug}`, p.$updatedAt, 0.7)),
|
||||
...industries.map((i) => toEntry(`/sektor/${i.slug}`, i.$updatedAt, 0.6)),
|
||||
];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Settings,
|
||||
Newspaper,
|
||||
Layers,
|
||||
Boxes,
|
||||
Briefcase,
|
||||
MessageSquareQuote,
|
||||
Search,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Image as ImageIcon,
|
||||
Users as UsersIcon,
|
||||
Building2,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -23,8 +25,10 @@ type Item = { href: string; label: string; icon: LucideIcon };
|
||||
const items: Item[] = [
|
||||
{ href: "/admin", label: "Pano", icon: LayoutDashboard },
|
||||
{ href: "/admin/site", label: "Site Ayarları", icon: Settings },
|
||||
{ href: "/admin/menu", label: "Menü Düzeni", icon: ListOrdered },
|
||||
{ href: "/admin/blog", label: "Blog", icon: Newspaper },
|
||||
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
|
||||
{ href: "/admin/cozumler", label: "Çözümler", icon: Boxes },
|
||||
{ href: "/admin/projeler", label: "Projeler", icon: Briefcase },
|
||||
{ href: "/admin/sektorler", label: "Sektörler", icon: Building2 },
|
||||
{ href: "/admin/ekip", label: "Ekip", icon: UsersIcon },
|
||||
|
||||
@@ -57,6 +57,11 @@ export async function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li className="pt-1">
|
||||
<Link href="/cozumler" className="font-medium text-white/90 hover:text-white">
|
||||
Çözümler →
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
+98
-86
@@ -3,7 +3,10 @@ import Link from "next/link";
|
||||
import { ChevronDown, Phone } from "lucide-react";
|
||||
import { getSiteSettings, listServices } from "@/lib/data";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { resolveNavItems } from "@/lib/nav";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
import { HeaderScrollEffect } from "@/components/header-scroll";
|
||||
import { MobileMenu } from "@/components/mobile-menu";
|
||||
|
||||
export async function Header() {
|
||||
const [settings, services] = await Promise.all([
|
||||
@@ -21,6 +24,9 @@ export async function Header() {
|
||||
["seo-dijital-pazarlama", "sosyal-medya-yonetimi", "dijital-reklam"].includes(s.slug),
|
||||
);
|
||||
|
||||
// Admin'den düzenlenebilir üst menü düzeni
|
||||
const navItems = resolveNavItems(settings?.nav_items).filter((i) => i.visible);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderScrollEffect />
|
||||
@@ -49,92 +55,26 @@ export async function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Col 2 — Desktop nav */}
|
||||
{/* Col 2 — Desktop nav (sıra admin'den yönetilir) */}
|
||||
<div className="hidden items-center gap-0.5 lg:flex">
|
||||
<Link
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
Anasayfa
|
||||
</Link>
|
||||
|
||||
{/* Hizmetler mega menu */}
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center justify-center gap-1 whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
Hizmetler
|
||||
<ChevronDown className="size-3 transition-transform duration-200 group-hover:rotate-180" />
|
||||
</button>
|
||||
<div className="pointer-events-none invisible absolute left-1/2 top-full z-50 w-[480px] -translate-x-1/2 pt-2 opacity-0 transition-all duration-150 ease-out group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100">
|
||||
<div className="translate-y-1 rounded-2xl border border-gray-100 bg-white p-4 shadow-xl transition-transform duration-150 group-hover:translate-y-0">
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
<div>
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Web & Yazılım
|
||||
</p>
|
||||
{webServices.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
{s.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Dijital Pazarlama
|
||||
</p>
|
||||
{marketingServices.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
{s.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<Link
|
||||
href="/hizmetler"
|
||||
className="block rounded-xl px-3 py-2 text-center text-xs font-semibold text-[var(--navy)] hover:bg-blue-50"
|
||||
>
|
||||
Tüm hizmetleri gör →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
Projeler
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
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"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/hakkimizda"
|
||||
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"
|
||||
>
|
||||
Hakkımızda
|
||||
</Link>
|
||||
<Link
|
||||
href="/iletisim"
|
||||
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"
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
{navItems.map((item) =>
|
||||
item.mega ? (
|
||||
<ServicesMegaMenu
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
webServices={webServices}
|
||||
marketingServices={marketingServices}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
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"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 3 — CTA */}
|
||||
@@ -162,10 +102,18 @@ export async function Header() {
|
||||
</a>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
{/* Mobil menü (hamburger) — sadece < lg */}
|
||||
<MobileMenu
|
||||
navItems={navItems}
|
||||
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
|
||||
phone={phone}
|
||||
phoneRaw={phoneRaw}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -174,3 +122,67 @@ export async function Header() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesMegaMenu({
|
||||
label,
|
||||
webServices,
|
||||
marketingServices,
|
||||
}: {
|
||||
label: string;
|
||||
webServices: ServiceRow[];
|
||||
marketingServices: ServiceRow[];
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center justify-center gap-1 whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className="size-3 transition-transform duration-200 group-hover:rotate-180" />
|
||||
</button>
|
||||
<div className="pointer-events-none invisible absolute left-1/2 top-full z-50 w-[480px] -translate-x-1/2 pt-2 opacity-0 transition-all duration-150 ease-out group-hover:pointer-events-auto group-hover:visible group-hover:opacity-100">
|
||||
<div className="translate-y-1 rounded-2xl border border-gray-100 bg-white p-4 shadow-xl transition-transform duration-150 group-hover:translate-y-0">
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
<div>
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Web & Yazılım
|
||||
</p>
|
||||
{webServices.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
{s.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Dijital Pazarlama
|
||||
</p>
|
||||
{marketingServices.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-blue-50 hover:text-[var(--navy)]"
|
||||
>
|
||||
{s.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<Link
|
||||
href="/hizmetler"
|
||||
className="block rounded-xl px-3 py-2 text-center text-xs font-semibold text-[var(--navy)] hover:bg-blue-50"
|
||||
>
|
||||
Tüm hizmetleri gör →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BlogPostRow,
|
||||
ProjectRow,
|
||||
ServiceRow,
|
||||
SiteSettingsRow,
|
||||
@@ -6,6 +7,12 @@ import type {
|
||||
} from "@/lib/types";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
|
||||
function absUrl(url?: string | null): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("http")) return url;
|
||||
return `${siteConfig.url}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
}
|
||||
|
||||
export function JsonLd({ data }: { data: object }) {
|
||||
return (
|
||||
<script
|
||||
@@ -137,6 +144,44 @@ export function BreadcrumbLd({
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogPostingLd({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
post: BlogPostRow;
|
||||
settings?: SiteSettingsRow | null;
|
||||
}) {
|
||||
const image = absUrl(post.seo_image || post.cover_image) ?? `${siteConfig.url}/logo.png`;
|
||||
const url = `${siteConfig.url}/blog/${post.slug}`;
|
||||
const data: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: post.title,
|
||||
description: post.seo_description || post.excerpt || undefined,
|
||||
image,
|
||||
datePublished: post.published_at ?? post.$createdAt,
|
||||
dateModified: post.$updatedAt,
|
||||
author: {
|
||||
"@type": post.author ? "Person" : "Organization",
|
||||
name: post.author || settings?.site_name || siteConfig.name,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: settings?.site_name ?? siteConfig.name,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${siteConfig.url}/logo.png`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
||||
url,
|
||||
};
|
||||
if (post.tags && post.tags.length > 0) {
|
||||
data.keywords = post.tags.join(", ");
|
||||
}
|
||||
return <JsonLd data={data} />;
|
||||
}
|
||||
|
||||
export function ArticleLd({ post }: { post: ProjectRow }) {
|
||||
return (
|
||||
<JsonLd
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Menu, X, ChevronDown, Phone, ArrowRight } from "lucide-react";
|
||||
import type { NavItem } from "@/lib/nav";
|
||||
|
||||
type NavService = { slug: string; title: string };
|
||||
|
||||
export function MobileMenu({
|
||||
navItems,
|
||||
services,
|
||||
phone,
|
||||
phoneRaw,
|
||||
}: {
|
||||
navItems: NavItem[];
|
||||
services: NavService[];
|
||||
phone: string;
|
||||
phoneRaw: string;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [servicesOpen, setServicesOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Drawer + overlay — header transform bağlamından kaçmak için body'ye portal
|
||||
const overlay = (
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] lg:hidden ${
|
||||
open ? "" : "pointer-events-none"
|
||||
}`}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Koyu overlay */}
|
||||
<div
|
||||
onClick={() => setOpen(false)}
|
||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-300 ${
|
||||
open ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Sağ drawer — beyaz panel */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`absolute right-0 top-0 flex h-full w-[82%] max-w-[340px] 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="-mr-2 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 — sıra admin'den yönetilir */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
{navItems
|
||||
.filter((item) => item.visible)
|
||||
.map((item) =>
|
||||
item.mega ? (
|
||||
<div key={item.key}>
|
||||
{/* 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)]"
|
||||
>
|
||||
{item.label}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.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)]"
|
||||
>
|
||||
{item.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>
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
{mounted && createPortal(overlay, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import type { ServiceRow } from "@/lib/types";
|
||||
|
||||
type ServiceLike = {
|
||||
slug: string;
|
||||
@@ -11,18 +10,27 @@ type ServiceLike = {
|
||||
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[] =
|
||||
services.length > 0
|
||||
? services
|
||||
: (siteConfig.fallbackServices as readonly ServiceLike[]).slice();
|
||||
: ((fallback ?? siteConfig.fallbackServices) as readonly ServiceLike[]).slice();
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/hizmetler/${s.slug}`}
|
||||
href={`${basePath}/${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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+160
-12
@@ -45,6 +45,12 @@ function strArr(v: FormDataEntryValue | null) {
|
||||
.map((x) => x.trim())
|
||||
.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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -130,7 +136,7 @@ export async function saveService(formData: FormData) {
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
|
||||
// FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
|
||||
const faqRaw = String(formData.get("faq") ?? "");
|
||||
const faqRaw = raw(formData.get("faq"));
|
||||
const faq = faqRaw
|
||||
.split("\n---\n")
|
||||
.map((block) => {
|
||||
@@ -178,6 +184,60 @@ export async function deleteService(formData: FormData) {
|
||||
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 ────────────────────────────────────────────────────
|
||||
|
||||
function parseMetricsInput(raw: string): string[] {
|
||||
@@ -201,7 +261,7 @@ export async function saveProject(formData: FormData) {
|
||||
if (!description) throw new Error("Açıklama zorunlu");
|
||||
|
||||
// Gallery: one URL per line
|
||||
const galleryRaw = String(formData.get("gallery") ?? "");
|
||||
const galleryRaw = raw(formData.get("gallery"));
|
||||
const gallery = galleryRaw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
@@ -223,8 +283,9 @@ export async function saveProject(formData: FormData) {
|
||||
industry: str(formData.get("industry")),
|
||||
duration: str(formData.get("duration")),
|
||||
service_slug: str(formData.get("service_slug")),
|
||||
solution_slug: str(formData.get("solution_slug")),
|
||||
metrics: (() => {
|
||||
const m = parseMetricsInput(String(formData.get("metrics") ?? ""));
|
||||
const m = parseMetricsInput(raw(formData.get("metrics")));
|
||||
return m.length > 0 ? m : null;
|
||||
})(),
|
||||
};
|
||||
@@ -302,7 +363,7 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
const secret = await requireSessionSecret();
|
||||
|
||||
// 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
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
@@ -313,7 +374,7 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
.filter((x): x is string => x !== null);
|
||||
|
||||
// Trust items: "icon|value|label" satırlar
|
||||
const trustRaw = String(formData.get("trust_items") ?? "");
|
||||
const trustRaw = raw(formData.get("trust_items"));
|
||||
const trust = trustRaw
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
@@ -350,19 +411,58 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
.filter((x): x is string => x !== null);
|
||||
}
|
||||
|
||||
const whyUs = parseBlocks(String(formData.get("why_us") ?? ""), true);
|
||||
const processSteps = parseBlocks(
|
||||
String(formData.get("process_steps") ?? ""),
|
||||
false,
|
||||
);
|
||||
const whyUs = parseBlocks(raw(formData.get("why_us")), true);
|
||||
const processSteps = parseBlocks(raw(formData.get("process_steps")), false);
|
||||
|
||||
// Client logos: her satıra bir URL
|
||||
const logosRaw = String(formData.get("client_logos") ?? "");
|
||||
const logosRaw = raw(formData.get("client_logos"));
|
||||
const logos = logosRaw
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Hakkımızda values: blok '---' ile, ilk satır title, kalanı description
|
||||
const aboutValuesRaw = raw(formData.get("about_values"));
|
||||
const aboutValues = aboutValuesRaw
|
||||
.split("\n---\n")
|
||||
.map((block) => {
|
||||
const lines = block.trim().split("\n");
|
||||
const title = lines[0]?.trim();
|
||||
const description = lines.slice(1).join("\n").trim();
|
||||
if (!title || !description) return null;
|
||||
return JSON.stringify({ title, description });
|
||||
})
|
||||
.filter((x): x is string => x !== null);
|
||||
|
||||
// Hakkımızda stats: 'value | label' satırlar
|
||||
const aboutStatsRaw = raw(formData.get("about_stats"));
|
||||
const aboutStats = aboutStatsRaw
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [value, label] = line.split("|").map((s) => s.trim());
|
||||
if (!value || !label) return null;
|
||||
return JSON.stringify({ value, label });
|
||||
})
|
||||
.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 = {
|
||||
hero_badge: str(formData.get("hero_badge")),
|
||||
hero_title: str(formData.get("hero_title")),
|
||||
@@ -377,6 +477,10 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
services_title: str(formData.get("services_title")),
|
||||
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_title: str(formData.get("projects_title")),
|
||||
projects_description: str(formData.get("projects_description")),
|
||||
@@ -409,11 +513,25 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
trust_items: trust.length > 0 ? trust : null,
|
||||
why_us: whyUs.length > 0 ? whyUs : 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_description: str(formData.get("lead_form_description")),
|
||||
google_review_url: str(formData.get("google_review_url")),
|
||||
google_rating: num(formData.get("google_rating")),
|
||||
google_review_count: num(formData.get("google_review_count")),
|
||||
|
||||
about_eyebrow: str(formData.get("about_eyebrow")),
|
||||
about_title: str(formData.get("about_title")),
|
||||
about_description: str(formData.get("about_description")),
|
||||
about_values: aboutValues.length > 0 ? aboutValues : null,
|
||||
about_hero_image: str(formData.get("about_hero_image")),
|
||||
about_team_eyebrow: str(formData.get("about_team_eyebrow")),
|
||||
about_team_title: str(formData.get("about_team_title")),
|
||||
about_team_description: str(formData.get("about_team_description")),
|
||||
about_stats: aboutStats.length > 0 ? aboutStats : null,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -437,6 +555,34 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
revalidatePath("/admin/site");
|
||||
}
|
||||
|
||||
// ─── Navigation Menu ─────────────────────────────────────────────
|
||||
|
||||
export async function saveNavMenu(formData: FormData) {
|
||||
const secret = await requireSessionSecret();
|
||||
// Client form, sıralı menüyü JSON string olarak nav_items'a koyar.
|
||||
const navItems = str(formData.get("nav_items"));
|
||||
const data = { nav_items: navItems };
|
||||
try {
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
} catch {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
}
|
||||
|
||||
// ─── SEO Settings ────────────────────────────────────────────────
|
||||
|
||||
export async function saveSeoSettings(formData: FormData) {
|
||||
@@ -444,6 +590,7 @@ export async function saveSeoSettings(formData: FormData) {
|
||||
const data = {
|
||||
site_name: str(formData.get("site_name")),
|
||||
site_description: str(formData.get("site_description")),
|
||||
default_keywords: str(formData.get("default_keywords")),
|
||||
default_og_image: str(formData.get("default_og_image")),
|
||||
twitter_handle: str(formData.get("twitter_handle")),
|
||||
facebook_url: str(formData.get("facebook_url")),
|
||||
@@ -485,6 +632,7 @@ export async function saveSeoPage(formData: FormData) {
|
||||
path,
|
||||
title: str(formData.get("title")),
|
||||
description: str(formData.get("description")),
|
||||
keywords: str(formData.get("keywords")),
|
||||
og_image: str(formData.get("og_image")),
|
||||
canonical: str(formData.get("canonical")),
|
||||
noindex: bool(formData.get("noindex")),
|
||||
@@ -562,7 +710,7 @@ export async function saveIndustry(formData: FormData) {
|
||||
if (!title) throw new Error("Başlık zorunlu");
|
||||
const slug = str(formData.get("slug")) || slugify(title);
|
||||
|
||||
const faqRaw = String(formData.get("faq") ?? "");
|
||||
const faqRaw = raw(formData.get("faq"));
|
||||
const faq = faqRaw
|
||||
.split("\n---\n")
|
||||
.map((block) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const MEDIA_BUCKET_ID =
|
||||
export const TABLES = {
|
||||
contactMessages: "contact_messages",
|
||||
services: "services",
|
||||
solutions: "solutions",
|
||||
projects: "projects",
|
||||
blogPosts: "blog_posts",
|
||||
testimonials: "testimonials",
|
||||
|
||||
+17
@@ -7,6 +7,7 @@ import type {
|
||||
IndustryRow,
|
||||
ProjectRow,
|
||||
ServiceRow,
|
||||
SolutionRow,
|
||||
SeoPageRow,
|
||||
SeoSettingsRow,
|
||||
SiteSettingsRow,
|
||||
@@ -44,14 +45,22 @@ export async function listServices(opts?: { featured?: boolean }) {
|
||||
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?: {
|
||||
featured?: boolean;
|
||||
limit?: number;
|
||||
serviceSlug?: string;
|
||||
solutionSlug?: string;
|
||||
}) {
|
||||
const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
|
||||
if (opts?.featured) q.unshift(Q.equal("featured", true));
|
||||
if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug));
|
||||
if (opts?.solutionSlug) q.unshift(Q.equal("solution_slug", opts.solutionSlug));
|
||||
return safeList<ProjectRow>(TABLES.projects, q);
|
||||
}
|
||||
|
||||
@@ -63,6 +72,14 @@ export async function getServiceBySlug(slug: string): Promise<ServiceRow | 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> {
|
||||
const res = await safeList<ProjectRow>(TABLES.projects, [
|
||||
Q.equal("slug", slug),
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Üst menü (header + mobil) düzeni. Öğeler sabit bir kayıttan gelir; admin
|
||||
// panelinden yalnızca SIRA, GÖRÜNÜRLÜK ve (opsiyonel) ETİKET düzenlenir.
|
||||
// Bu modül hem sunucu (header) hem istemci (admin formu) tarafında import
|
||||
// edilebilir — bu yüzden "server-only" YOK.
|
||||
|
||||
export type NavKey =
|
||||
| "home"
|
||||
| "services"
|
||||
| "solutions"
|
||||
| "projects"
|
||||
| "blog"
|
||||
| "about"
|
||||
| "contact";
|
||||
|
||||
export interface NavRegistryEntry {
|
||||
key: NavKey;
|
||||
label: string; // varsayılan etiket
|
||||
href: string;
|
||||
mega?: boolean; // Hizmetler — mega menü olarak render edilir
|
||||
}
|
||||
|
||||
export const NAV_REGISTRY: Record<NavKey, NavRegistryEntry> = {
|
||||
home: { key: "home", label: "Anasayfa", href: "/" },
|
||||
services: { key: "services", label: "Hizmetler", href: "/hizmetler", mega: true },
|
||||
solutions: { key: "solutions", label: "Çözümler", href: "/cozumler" },
|
||||
projects: { key: "projects", label: "Projeler", href: "/projeler" },
|
||||
blog: { key: "blog", label: "Blog", href: "/blog" },
|
||||
about: { key: "about", label: "Hakkımızda", href: "/hakkimizda" },
|
||||
contact: { key: "contact", label: "İletişim", href: "/iletisim" },
|
||||
};
|
||||
|
||||
export const DEFAULT_NAV_ORDER: NavKey[] = [
|
||||
"home",
|
||||
"services",
|
||||
"solutions",
|
||||
"projects",
|
||||
"blog",
|
||||
"about",
|
||||
"contact",
|
||||
];
|
||||
|
||||
export interface NavItem {
|
||||
key: NavKey;
|
||||
label: string; // çözülmüş etiket (override veya varsayılan)
|
||||
href: string;
|
||||
mega: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface StoredNavItem {
|
||||
key: string;
|
||||
visible?: boolean;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* site_settings.nav_items içindeki JSON'ı kayıt ile birleştirir.
|
||||
* - Kayıtlı sıra önceliklidir, geçersiz/silinmiş key'ler atlanır.
|
||||
* - Kayıtta olmayan (yeni eklenen) öğeler varsayılan sırayla sona eklenir.
|
||||
* - JSON yoksa/bozuksa tam varsayılan menü döner.
|
||||
*/
|
||||
export function resolveNavItems(navItemsJson?: string | null): NavItem[] {
|
||||
let stored: StoredNavItem[] = [];
|
||||
if (navItemsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(navItemsJson);
|
||||
if (Array.isArray(parsed)) stored = parsed as StoredNavItem[];
|
||||
} catch {
|
||||
/* bozuk JSON — varsayılanlara düş */
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<NavKey>();
|
||||
const ordered: NavItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
const reg = NAV_REGISTRY[item.key as NavKey];
|
||||
if (!reg || seen.has(reg.key)) continue;
|
||||
seen.add(reg.key);
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: item.label?.trim() || reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: item.visible !== false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of DEFAULT_NAV_ORDER) {
|
||||
if (seen.has(key)) continue;
|
||||
const reg = NAV_REGISTRY[key];
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Admin formundan gelen düzeni depolanacak kompakt JSON'a çevirir. */
|
||||
export function serializeNavItems(
|
||||
items: { key: NavKey; visible: boolean; label?: string }[],
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
items.map((i) => {
|
||||
const reg = NAV_REGISTRY[i.key];
|
||||
const out: StoredNavItem = { key: i.key, visible: i.visible };
|
||||
// Sadece varsayılandan farklıysa etiketi sakla
|
||||
if (i.label && i.label.trim() && i.label.trim() !== reg.label) {
|
||||
out.label = i.label.trim();
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
);
|
||||
}
|
||||
+43
-2
@@ -19,17 +19,58 @@ export async function buildMetadata(path: string, fallback?: Metadata): Promise<
|
||||
override?.description ??
|
||||
(fallback?.description as string | undefined) ??
|
||||
siteDesc;
|
||||
const ogImage = override?.og_image || ogDefault;
|
||||
|
||||
// Sayfanın kendi OG bilgisi (blog kapağı, type:"article" vb.) — fallback'ten
|
||||
// oku. Öncelik: sayfa SEO override > sayfanın fallback OG görseli > varsayılan.
|
||||
const fbOg = fallback?.openGraph as
|
||||
| { images?: unknown; type?: string }
|
||||
| undefined;
|
||||
const fbOgImage = (() => {
|
||||
const imgs = fbOg?.images;
|
||||
if (typeof imgs === "string") return imgs;
|
||||
if (Array.isArray(imgs) && imgs.length) {
|
||||
const first = imgs[0];
|
||||
if (typeof first === "string") return first;
|
||||
if (first && typeof first === "object" && "url" in first)
|
||||
return String((first as { url: unknown }).url);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const ogImage = override?.og_image || fbOgImage || ogDefault;
|
||||
const ogType = fbOg?.type ?? "website";
|
||||
|
||||
// Anahtar kelimeler: sayfa override + site geneli varsayılan + sayfanın kendi
|
||||
// keyword'leri (örn. blog etiketleri) birleştirilir, tekrarlar ayıklanır.
|
||||
const fbKeywords = fallback?.keywords;
|
||||
const fbKeywordsStr = Array.isArray(fbKeywords)
|
||||
? fbKeywords.join(",")
|
||||
: typeof fbKeywords === "string"
|
||||
? fbKeywords
|
||||
: "";
|
||||
const keywordsRaw = [override?.keywords, settings?.default_keywords, fbKeywordsStr]
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
const keywords = keywordsRaw
|
||||
? Array.from(
|
||||
new Set(
|
||||
keywordsRaw
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [{ url: ogImage }] : undefined,
|
||||
type: "website",
|
||||
type: ogType as "website" | "article",
|
||||
locale: "tr_TR",
|
||||
siteName,
|
||||
},
|
||||
|
||||
@@ -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: "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;
|
||||
|
||||
@@ -15,6 +15,20 @@ export interface ServiceRow extends AwRow {
|
||||
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 {
|
||||
q: string;
|
||||
a: string;
|
||||
@@ -36,6 +50,7 @@ export interface ProjectRow extends AwRow {
|
||||
industry?: string | null;
|
||||
duration?: string | null;
|
||||
service_slug?: string | null;
|
||||
solution_slug?: string | null;
|
||||
metrics?: string[] | null; // JSON {"value":"+150%","label":"Trafik artışı"}
|
||||
}
|
||||
|
||||
@@ -70,6 +85,7 @@ export interface SeoPageRow extends AwRow {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
keywords?: string | null; // virgülle ayrılmış anahtar kelimeler (sayfa override)
|
||||
og_image?: string | null;
|
||||
canonical?: string | null;
|
||||
noindex?: boolean | null;
|
||||
@@ -78,6 +94,7 @@ export interface SeoPageRow extends AwRow {
|
||||
export interface SeoSettingsRow extends AwRow {
|
||||
site_name?: string | null;
|
||||
site_description?: string | null;
|
||||
default_keywords?: string | null; // virgülle ayrılmış site geneli anahtar kelimeler
|
||||
default_og_image?: string | null;
|
||||
twitter_handle?: string | null;
|
||||
facebook_url?: string | null;
|
||||
@@ -106,6 +123,10 @@ export interface SiteSettingsRow extends AwRow {
|
||||
services_title?: string | null;
|
||||
services_description?: string | null;
|
||||
|
||||
solutions_eyebrow?: string | null;
|
||||
solutions_title?: string | null;
|
||||
solutions_description?: string | null;
|
||||
|
||||
projects_eyebrow?: string | null;
|
||||
projects_title?: string | null;
|
||||
projects_description?: string | null;
|
||||
@@ -133,6 +154,9 @@ export interface SiteSettingsRow extends AwRow {
|
||||
|
||||
footer_tagline?: string | null;
|
||||
|
||||
// Üst menü düzeni — JSON dizi: [{ key, visible, label? }] sırasıyla
|
||||
nav_items?: string | null;
|
||||
|
||||
whatsapp_message?: string | null;
|
||||
client_logos?: string[] | null;
|
||||
trust_items?: string[] | null; // JSON {"icon":"Star","value":"4.9","label":"..."}
|
||||
@@ -148,6 +172,22 @@ export interface SiteSettingsRow extends AwRow {
|
||||
guarantee_title?: string | null;
|
||||
guarantee_description?: string | null;
|
||||
guarantee_items?: string[] | null;
|
||||
|
||||
// Hakkımızda sayfası
|
||||
about_eyebrow?: string | null;
|
||||
about_title?: string | null;
|
||||
about_description?: string | null;
|
||||
about_values?: string[] | null; // JSON {"title","description"}
|
||||
about_hero_image?: string | null;
|
||||
about_team_eyebrow?: string | null;
|
||||
about_team_title?: string | null;
|
||||
about_team_description?: string | null;
|
||||
about_stats?: string[] | null; // JSON {"value","label"}
|
||||
}
|
||||
|
||||
export interface AboutValue {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TeamMemberRow extends AwRow {
|
||||
|
||||
Generated
+44
-1
@@ -8,6 +8,7 @@
|
||||
"name": "kovak-yazilim",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-image": "^3.23.5",
|
||||
"@tiptap/extension-link": "^3.23.5",
|
||||
"@tiptap/extension-placeholder": "^3.23.5",
|
||||
@@ -1083,6 +1084,18 @@
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz",
|
||||
@@ -1622,6 +1635,18 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -2128,6 +2153,19 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
@@ -2384,7 +2422,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
@@ -2437,6 +2474,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-image": "^3.23.5",
|
||||
"@tiptap/extension-link": "^3.23.5",
|
||||
"@tiptap/extension-placeholder": "^3.23.5",
|
||||
|
||||
Reference in New Issue
Block a user