Compare commits

..

5 Commits

Author SHA1 Message Date
egecankomur d49c9aa225 feat: SEO altyapısı + admin editör/favicon/menü düzeltmeleri
Admin & site:
- @tailwindcss/typography ekle → editör ve yayın içeriği prose stilleriyle düzgün render
- Favicon: logo.png'den kare app/icon.png + apple-icon.png, varsayılan favicon.ico kaldırıldı
- SEO keyword: seo_settings.default_keywords + seo_pages.keywords + buildMetadata birleştirme
- Menü düzeni admin'den yönetilebilir (site_settings.nav_items, /admin/menu, header & mobile-menu refactor)

SEO:
- app/sitemap.ts (statik + blog/hizmet/çözüm/proje/sektör dinamik)
- app/robots.ts (sitemap ref + /admin,/api disallow)
- app/llms.txt/route.ts (AI/LLM rehberi)
- BlogPosting/Service/FAQ/Article JSON-LD wire (json-ld bileşenleri bağlandı)
- buildMetadata: blog/proje OG görseli + type article + keywords birleştirme düzeltmesi
- blog tags → keyword
2026-06-04 07:15:18 +03:00
egecankomur a321ac5c9b fix: mobil menü drawer'ı body'ye portal et — header transform'u fixed konumu bozuyordu
Header scroll efekti #floating-header-wrap'e transform uyguladığı için
içindeki position:fixed overlay viewport yerine header'a göre konumlanıyordu.
Drawer + overlay artık createPortal ile document.body'ye render ediliyor:
sağ drawer, beyaz panel, tam ekran koyu overlay (z-100).
2026-06-02 18:39:34 +03:00
egecankomur 2e001680bf feat: Çözümler bölümü + mobil menü; admin parser düzeltmeleri
- Çözümler: solutions tablosu, /cozumler liste + detay sayfası, anasayfa
  bölümü, tam admin CRUD (/admin/cozumler), header & footer linkleri,
  projelerde solution_slug ilişkisi, services-grid genelleştirildi
- Mobil menü (hamburger drawer) eklendi — header artık < lg'de gezilebilir
- Site ayarları parser: textarea CRLF (\r\n) normalizasyonu — neden biz,
  süreç adımları, değerler ve SSS blokları artık doğru parçalanıyor
- homepage_faq + garanti (title/description/items) saveSiteSettings'e
  bağlandı (daha önce hiç kaydedilmiyordu)
2026-06-02 18:21:58 +03:00
Ege Can Komur f49df9cbeb feat: Hakkımızda sayfası yönetilebilir (site_settings + /admin/site)
Önce hard-coded olan tüm metinler artık /admin/site > 'Hakkımızda sayfası'
bölümünden düzenlenebilir.

site_settings'e 9 yeni alan eklendi:
- about_eyebrow, about_title, about_description (üst hero)
- about_values (string array JSON {title, description}) — 4 değer kartı
- about_hero_image (opsiyonel, boşsa logo gösterilir)
- about_team_eyebrow, about_team_title, about_team_description
- about_stats (string array JSON {value, label}) — alt navy bant

Mevcut WP değerleri default olarak seed edildi.

Hakkımızda sayfası (app/(site)/hakkimizda/page.tsx) artık:
- Tüm metinler settings'ten okunuyor (fallback default'lar var)
- Hero image varsa logo yerine onu gösteriyor
- Stats sıfırdan farklı sayıda olabilir (3 yerine 2/4)

Admin form (/admin/site):
- Yeni 'Hakkımızda sayfası' section
- 4 alt-bölüm: Üst hero / Değerler / Ekip / Stats
- MediaPicker ile hero image
- Markdown benzeri textarea'lar (--- ayırıcı, | seperator)
2026-05-20 20:50:30 +03:00
Ege Can Komur f3604d96b8 docs: production env example + Coolify deploy rehberi
.gitignore'da '.env*' pattern'i .env.example'ı da engelliyordu.
'!.env.example' ile istisna eklendi — .env.local hala gizli kalıyor.

.env.example temizlendi — sadece gerçek kullanılan 4 değişken:
- NEXT_PUBLIC_APPWRITE_ENDPOINT
- NEXT_PUBLIC_APPWRITE_PROJECT_ID
- NEXT_PUBLIC_APPWRITE_DATABASE_ID
- NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID

APPWRITE_API_KEY opsiyonel (session-cookie tabanlı auth aktif).

DEPLOY.md eklendi:
- Coolify app oluşturma adımları
- Environment variables tam liste
- Build sonrası kontroller
- Gitea webhook bilgisi
- Domain yönlendirme (3 seçenek)
- Production checklist
2026-05-20 19:53:38 +03:00
45 changed files with 2278 additions and 167 deletions
+31
View File
@@ -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
View File
@@ -30,8 +30,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.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*
!.env.example
# vercel # vercel
.vercel .vercel
@@ -39,3 +40,6 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# SEO audit çıktıları (repoya girmesin)
seo-audit/
+74
View File
@@ -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
+3
View File
@@ -6,6 +6,7 @@ import { ArrowLeft, Calendar } from "lucide-react";
import { renderContent } from "@/lib/content-render"; import { renderContent } from "@/lib/content-render";
import { getPostBySlug } from "@/lib/data"; import { getPostBySlug } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
import { BlogPostingLd } from "@/components/json-ld";
import { ContentSidebar } from "@/components/content-sidebar"; import { ContentSidebar } from "@/components/content-sidebar";
export async function generateMetadata({ export async function generateMetadata({
@@ -19,6 +20,7 @@ export async function generateMetadata({
return buildMetadata(`/blog/${slug}`, { return buildMetadata(`/blog/${slug}`, {
title: post.seo_title || post.title, title: post.seo_title || post.title,
description: post.seo_description || post.excerpt || undefined, description: post.seo_description || post.excerpt || undefined,
keywords: post.tags ?? undefined,
openGraph: { openGraph: {
title: post.seo_title || post.title, title: post.seo_title || post.title,
description: post.seo_description || post.excerpt || undefined, description: post.seo_description || post.excerpt || undefined,
@@ -44,6 +46,7 @@ export default async function BlogPostPage({
return ( return (
<div className="mx-auto max-w-7xl px-6 py-16"> <div className="mx-auto max-w-7xl px-6 py-16">
<BlogPostingLd post={post} />
<Link <Link
href="/blog" href="/blog"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]" className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
+124
View File
@@ -0,0 +1,124 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CheckCircle2 } from "lucide-react";
import { renderContent } from "@/lib/content-render";
import { getSolutionBySlug, getSiteSettings, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo";
import { ProjectsGrid } from "@/components/projects-grid";
import { SectionTitle } from "@/components/section-title";
import { FaqList } from "@/components/faq-list";
import { SolutionHero } from "@/components/solution-hero";
import { SolutionSidebar } from "@/components/solution-sidebar";
import type { FaqItem } from "@/lib/types";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const solution = await getSolutionBySlug(slug);
if (!solution) return { title: "Çözüm bulunamadı" };
return buildMetadata(`/cozumler/${slug}`, {
title: solution.title,
description: solution.description.slice(0, 160),
});
}
function parseFaq(items?: string[] | null): FaqItem[] {
if (!items) return [];
const out: FaqItem[] = [];
for (const raw of items) {
try {
const obj = JSON.parse(raw) as Partial<FaqItem>;
if (obj.q && obj.a) out.push({ q: obj.q, a: obj.a });
} catch {
const [q, a] = raw.split("|||").map((s) => s.trim());
if (q && a) out.push({ q, a });
}
}
return out;
}
export default async function SolutionDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const solution = await getSolutionBySlug(slug);
if (!solution) notFound();
const [relatedProjects, settings] = await Promise.all([
listProjects({ solutionSlug: slug, limit: 6 }),
getSiteSettings(),
]);
const faqItems = parseFaq(solution.faq);
const html = renderContent(solution.content);
return (
<>
<SolutionHero solution={solution} settings={settings} />
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
<div>
{solution.features && solution.features.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Bu çözüm kapsamında
</h2>
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
{solution.features.map((f) => (
<li
key={f}
className="flex items-start gap-2 rounded-xl border border-[var(--border)] bg-white p-4"
>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--sky-600)]" />
<span className="text-sm text-[var(--foreground)]">{f}</span>
</li>
))}
</ul>
</section>
)}
{html && (
<article
className="prose prose-lg max-w-none text-[var(--foreground)]"
dangerouslySetInnerHTML={{ __html: html }}
/>
)}
{faqItems.length > 0 && (
<section className="mt-12">
<h2 className="text-2xl font-bold text-[var(--navy)]">
Sıkça sorulan sorular
</h2>
<div className="mt-6">
<FaqList items={faqItems} />
</div>
</section>
)}
</div>
<SolutionSidebar currentSlug={slug} />
</div>
{relatedProjects.length > 0 && (
<section className="border-t border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
align="left"
eyebrow="Referanslar"
title={`${solution.title} alanındaki projelerimiz`}
description="Bu çözümde tamamladığımız işlerden seçkiler."
/>
<div className="mt-10">
<ProjectsGrid projects={relatedProjects} />
</div>
</div>
</section>
)}
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import { SectionTitle } from "@/components/section-title";
import { ServicesGrid } from "@/components/services-grid";
import { listSolutions } from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { buildMetadata } from "@/lib/seo";
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/cozumler", {
title: "Çözümler",
description:
"İşletmeniz için uçtan uca dijital çözümler: kurumsal dijitalleşme, online satış altyapısı, CRM ve büyüme paketleri.",
});
}
export default async function SolutionsPage() {
const solutions = await listSolutions();
return (
<div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle
eyebrow="Çözümlerimiz"
title="İşletmenize özel dijital çözümler"
description="Tek tek hizmetleri değil, işinizi büyüten bütün paketleri tek elden kuruyoruz."
/>
<div className="mt-14">
<ServicesGrid
services={solutions}
basePath="/cozumler"
fallback={siteConfig.fallbackSolutions}
/>
</div>
</div>
);
}
+99 -52
View File
@@ -1,10 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Image from "next/image"; import Image from "next/image";
import { SectionTitle } from "@/components/section-title";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { SectionTitle } from "@/components/section-title";
import { TeamGrid } from "@/components/team-grid"; import { TeamGrid } from "@/components/team-grid";
import { listTeamMembers } from "@/lib/data"; import { getSiteSettings, listTeamMembers } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
import type { AboutValue, StatItem } from "@/lib/types";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/hakkimizda", { return buildMetadata("/hakkimizda", {
@@ -14,31 +15,68 @@ export async function generateMetadata(): Promise<Metadata> {
}); });
} }
const values = [ const DEFAULT_VALUES: AboutValue[] = [
{ { title: "Uçtan uca üretim", description: "Fikir aşamasından lansmana, lansman sonrası bakıma kadar tek bir ekip." },
title: "Uçtan uca üretim", { title: "Ölçülebilir sonuç", description: "Her projeyi performans, dönüşüm ve kullanıcı deneyimi metrikleriyle değerlendiriyoruz." },
description: { title: "Şeffaf süreç", description: "Her sprint demo ile başlar, her engel açıkça konuşulur. Sürprize yer yok." },
"Fikir aşamasından lansmana, lansman sonrası bakıma kadar tek bir ekip.", { title: "Uzun vadeli ortaklık", description: "Proje biter, iş büyür. Bakım ve geliştirme süreçlerinde yanınızdayız." },
},
{
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() { 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 ( return (
<> <>
@@ -47,9 +85,9 @@ export default async function AboutPage() {
<div> <div>
<SectionTitle <SectionTitle
align="left" align="left"
eyebrow="Hakkımızda" eyebrow={eyebrow}
title="Kocaeli'den dünyaya dijital ürünler" title={title}
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." description={description}
/> />
<ul className="mt-10 space-y-4"> <ul className="mt-10 space-y-4">
@@ -67,14 +105,25 @@ export default async function AboutPage() {
<div className="relative"> <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="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"> <div className="relative flex aspect-square items-center justify-center overflow-hidden rounded-3xl p-12">
<Image {heroImage ? (
src="/logo.png" <Image
alt="Kovak Yazılım" src={heroImage}
width={400} alt={title}
height={400} fill
className="size-full object-contain drop-shadow-xl" 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> </div>
</div> </div>
@@ -84,9 +133,9 @@ export default async function AboutPage() {
<section className="border-y border-[var(--border)] bg-gray-50 py-20"> <section className="border-y border-[var(--border)] bg-gray-50 py-20">
<div className="mx-auto max-w-7xl px-6"> <div className="mx-auto max-w-7xl px-6">
<SectionTitle <SectionTitle
eyebrow="Ekibimiz" eyebrow={teamEyebrow}
title="Projenizde Kimlerle Çalışırsınız?" title={teamTitle}
description="Sizin projenizde birebir çalışacak kurucular — teknik altyapı ve ürün geliştirmenin arkasındaki isimler." description={teamDescription}
/> />
<div className="mt-14"> <div className="mt-14">
<TeamGrid members={team} /> <TeamGrid members={team} />
@@ -95,20 +144,18 @@ export default async function AboutPage() {
</section> </section>
)} )}
<section className="bg-[var(--navy)] py-20 text-white"> {stats.length > 0 && (
<div className="mx-auto grid max-w-7xl gap-12 px-6 md:grid-cols-3"> <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" }, {stats.map((s) => (
{ value: "30+", label: "Mutlu müşteri" }, <div key={s.label} className="text-center">
{ value: "10+", label: "Yıllık deneyim" }, <p className="text-5xl font-bold">{s.value}</p>
].map((s) => ( <p className="mt-2 text-sm text-white/70">{s.label}</p>
<div key={s.label} className="text-center"> </div>
<p className="text-5xl font-bold">{s.value}</p> ))}
<p className="mt-2 text-sm text-white/70">{s.label}</p> </div>
</div> </section>
))} )}
</div>
</section>
</> </>
); );
} }
+3
View File
@@ -9,6 +9,7 @@ import { SectionTitle } from "@/components/section-title";
import { FaqList } from "@/components/faq-list"; import { FaqList } from "@/components/faq-list";
import { ServiceHero } from "@/components/service-hero"; import { ServiceHero } from "@/components/service-hero";
import { ServiceSidebar } from "@/components/service-sidebar"; import { ServiceSidebar } from "@/components/service-sidebar";
import { ServiceLd, FaqLd } from "@/components/json-ld";
import type { FaqItem } from "@/lib/types"; import type { FaqItem } from "@/lib/types";
export async function generateMetadata({ export async function generateMetadata({
@@ -59,6 +60,8 @@ export default async function ServiceDetailPage({
return ( return (
<> <>
<ServiceLd service={service} settings={settings} />
<FaqLd items={faqItems} />
<ServiceHero service={service} settings={settings} /> <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]"> <div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
+38 -6
View File
@@ -18,6 +18,7 @@ import {
getSiteSettings, getSiteSettings,
listProjects, listProjects,
listServices, listServices,
listSolutions,
listTestimonials, listTestimonials,
} from "@/lib/data"; } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
@@ -28,12 +29,14 @@ export async function generateMetadata(): Promise<Metadata> {
} }
export default async function Home() { export default async function Home() {
const [services, projects, testimonials, settings] = await Promise.all([ const [services, solutions, projects, testimonials, settings] =
listServices({ featured: true }), await Promise.all([
listProjects({ featured: true, limit: 6 }), listServices({ featured: true }),
listTestimonials({ featured: true }), listSolutions({ featured: true }),
getSiteSettings(), listProjects({ featured: true, limit: 6 }),
]); listTestimonials({ featured: true }),
getSiteSettings(),
]);
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw; const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone; const phone = settings?.contact_phone ?? siteConfig.contact.phone;
@@ -118,6 +121,35 @@ export default async function Home() {
</div> </div>
</section> </section>
<section className="border-b border-[var(--border)] bg-[var(--navy-50)]/40 py-20">
<div className="mx-auto max-w-7xl px-6">
<SectionTitle
eyebrow={settings?.solutions_eyebrow ?? "İşletmeler için"}
title={settings?.solutions_title ?? "Hazır dijital çözüm paketleri"}
description={
settings?.solutions_description ??
"Tek tek hizmetleri değil; işinizi büyüten bütün paketleri tek elden kuruyoruz."
}
/>
<div className="mt-12">
<ServicesGrid
services={solutions}
basePath="/cozumler"
fallback={siteConfig.fallbackSolutions}
/>
</div>
<div className="mt-10 text-center">
<Link
href="/cozumler"
className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-white px-5 py-2.5 text-sm font-semibold text-[var(--navy)] transition hover:border-[var(--sky)] hover:text-[var(--sky-600)]"
>
Tüm çözümleri gör
<ArrowRight className="size-4" />
</Link>
</div>
</div>
</section>
<WhyUs settings={settings} /> <WhyUs settings={settings} />
<Guarantee settings={settings} /> <Guarantee settings={settings} />
+2
View File
@@ -7,6 +7,7 @@ import { renderContent } from "@/lib/content-render";
import { getProjectBySlug, listProjects } from "@/lib/data"; import { getProjectBySlug, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
import { Gallery } from "@/components/gallery"; import { Gallery } from "@/components/gallery";
import { ArticleLd } from "@/components/json-ld";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import type { ProjectMetric } from "@/lib/types"; import type { ProjectMetric } from "@/lib/types";
@@ -74,6 +75,7 @@ export default async function ProjectDetailPage({
return ( return (
<> <>
<ArticleLd post={project} />
<section className="border-b border-[var(--border)]"> <section className="border-b border-[var(--border)]">
<div className="mx-auto max-w-7xl px-6 py-12"> <div className="mx-auto max-w-7xl px-6 py-12">
<Link <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} />;
}
+148
View File
@@ -0,0 +1,148 @@
import { Save } from "lucide-react";
import {
Checkbox,
Field,
FormActions,
FormShell,
GhostLink,
PageHeader,
PrimaryButton,
Textarea,
} from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveSolution } from "@/lib/admin-actions";
import type { FaqItem, SolutionRow } from "@/lib/types";
const ICON_OPTIONS = [
"Globe",
"ShoppingCart",
"Smartphone",
"Code2",
"Users",
"TrendingUp",
"Share2",
"Megaphone",
"Layers",
];
function faqToText(items?: string[] | null): string {
if (!items) return "";
const parsed: FaqItem[] = [];
for (const raw of items) {
try {
const obj = JSON.parse(raw) as Partial<FaqItem>;
if (obj.q && obj.a) parsed.push({ q: obj.q, a: obj.a });
} catch {
/* ignore */
}
}
return parsed.map((it) => `${it.q}\n${it.a}`).join("\n---\n");
}
export function SolutionForm({ solution }: { solution?: SolutionRow }) {
return (
<div>
<PageHeader
title={solution ? "Çözümü düzenle" : "Yeni çözüm"}
backHref="/admin/cozumler"
/>
<form action={saveSolution}>
{solution && <input type="hidden" name="id" value={solution.$id} />}
<FormShell>
<div className="grid gap-5 md:grid-cols-2">
<Field label="Başlık" name="title" required defaultValue={solution?.title} />
<Field label="Slug" name="slug" defaultValue={solution?.slug} />
<Field
label="Sıra"
name="order"
type="number"
defaultValue={solution?.order ?? 0}
/>
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">İkon</span>
<select
name="icon"
defaultValue={solution?.icon ?? "Layers"}
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
>
{ICON_OPTIONS.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>
</label>
<MediaPicker
label="Hero görsel"
name="hero_image"
defaultValue={solution?.hero_image}
help="Detay sayfasının üst kısmında gösterilir (opsiyonel)."
/>
</div>
<div className="mt-5 space-y-5">
<Textarea
label="Kısa açıklama (kart için)"
name="description"
required
defaultValue={solution?.description}
rows={3}
help="Listede ve anasayfa kartında gösterilir."
/>
<div>
<span className="text-sm font-medium text-[var(--navy)]">
Detay içerik
</span>
<div className="mt-1.5">
<RichEditor
name="content"
defaultValue={solution?.content}
placeholder="Çözümün detaylarını anlatın… `/` ile blok ekleyin"
/>
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Çözüm detay sayfasında ana içerik olarak gösterilir.
</p>
</div>
<Textarea
label="Özellikler"
name="features"
defaultValue={solution?.features?.join(", ")}
rows={3}
placeholder="Uçtan uca kurulum, Eğitim ve devir, 1 yıl destek, …"
help="Virgülle ayırın. Detay sayfasında checklist olarak gösterilir."
/>
<Textarea
label="SSS"
name="faq"
defaultValue={faqToText(solution?.faq)}
rows={8}
placeholder={
"Soru 1?\nCevap 1 burada.\n---\nSoru 2?\nCevap 2 burada."
}
help="Her soru/cevap blokunu '---' ile ayırın. İlk satır soru, kalanı cevap."
/>
</div>
<div className="mt-5">
<Checkbox
label="Öne çıkar (Anasayfada göster)"
name="featured"
defaultChecked={solution?.featured ?? false}
/>
</div>
<FormActions>
<GhostLink href="/admin/cozumler">İptal</GhostLink>
<PrimaryButton>
<Save className="size-4" /> Kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
</div>
);
}
@@ -0,0 +1,5 @@
import { SolutionForm } from "../form";
export default function NewSolutionPage() {
return <SolutionForm />;
}
+80
View File
@@ -0,0 +1,80 @@
import Link from "next/link";
import { Plus, Edit } from "lucide-react";
import { PageHeader } from "@/components/admin/form";
import { DeleteButton } from "@/components/admin/delete-button";
import { listSolutions } from "@/lib/data";
import { deleteSolution } from "@/lib/admin-actions";
export default async function SolutionsAdminPage() {
const solutions = await listSolutions();
return (
<div>
<PageHeader
title="Çözümler"
description="Anasayfa ve /cozumler sayfasında gösterilen çözüm kartları."
action={
<Link
href="/admin/cozumler/new"
className="inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
<Plus className="size-4" /> Yeni çözüm
</Link>
}
/>
<div className="mt-6 overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-[var(--navy-50)] text-xs uppercase tracking-wider text-[var(--muted)]">
<tr>
<th className="px-4 py-3 text-left">Sıra</th>
<th className="px-4 py-3 text-left">Başlık</th>
<th className="px-4 py-3 text-left">Slug</th>
<th className="px-4 py-3 text-left">İkon</th>
<th className="px-4 py-3 text-left">Öne çıkan</th>
<th className="px-4 py-3 text-right">İşlem</th>
</tr>
</thead>
<tbody>
{solutions.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-[var(--muted)]">
Çözüm eklenmemiş.
</td>
</tr>
)}
{solutions.map((s) => (
<tr key={s.$id} className="border-t border-[var(--border)]">
<td className="px-4 py-3 text-[var(--muted)]">{s.order ?? 0}</td>
<td className="px-4 py-3 font-medium text-[var(--navy)]">{s.title}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.slug}</td>
<td className="px-4 py-3 text-[var(--muted)]">{s.icon ?? "—"}</td>
<td className="px-4 py-3">
{s.featured ? (
<span className="rounded-full bg-[var(--sky-50)] px-2 py-0.5 text-xs text-[var(--sky-600)]">
Öne çıkan
</span>
) : (
<span className="text-xs text-[var(--muted)]"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/cozumler/${s.$id}/edit`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-white px-2.5 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--navy-50)]"
>
<Edit className="size-3.5" /> Düzenle
</Link>
<form action={deleteSolution}>
<input type="hidden" name="id" value={s.$id} />
<DeleteButton />
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+137
View File
@@ -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. &ldquo;Hizmetler&rdquo; öğesi mega menü olarak
açılır.
</p>
<FormActions>
<PrimaryButton>
<Save className="size-4" /> Menüyü kaydet
</PrimaryButton>
</FormActions>
</FormShell>
</form>
);
}
+19
View File
@@ -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>
);
}
+25 -2
View File
@@ -12,11 +12,14 @@ import {
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor"; import { RichEditor } from "@/components/admin/rich-editor";
import { saveProject } from "@/lib/admin-actions"; import { saveProject } from "@/lib/admin-actions";
import { listServices } from "@/lib/data"; import { listServices, listSolutions } from "@/lib/data";
import type { ProjectRow } from "@/lib/types"; import type { ProjectRow } from "@/lib/types";
export async function ProjectForm({ project }: { project?: ProjectRow }) { export async function ProjectForm({ project }: { project?: ProjectRow }) {
const services = await listServices(); const [services, solutions] = await Promise.all([
listServices(),
listSolutions(),
]);
return ( return (
<div> <div>
@@ -51,6 +54,26 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür. Bu projenin ait olduğu hizmet detay sayfasında "ilgili projeler" olarak görünür.
</span> </span>
</label> </label>
<label className="block">
<span className="text-sm font-medium text-[var(--navy)]">
İlgili çözüm
</span>
<select
name="solution_slug"
defaultValue={project?.solution_slug ?? ""}
className="mt-1.5 w-full rounded-xl border border-[var(--border)] bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--sky)] focus:ring-2 focus:ring-[var(--sky)]/20"
>
<option value=""> Yok </option>
{solutions.map((s) => (
<option key={s.slug} value={s.slug}>
{s.title}
</option>
))}
</select>
<span className="mt-1 block text-xs text-[var(--muted)]">
Bu projenin ait olduğu çözüm çözüm detay sayfasında "ilgili projeler" olarak görünür.
</span>
</label>
<Field label="Müşteri" name="client_name" defaultValue={project?.client_name} /> <Field label="Müşteri" name="client_name" defaultValue={project?.client_name} />
<Field label="Sektör" name="industry" defaultValue={project?.industry} /> <Field label="Sektör" name="industry" defaultValue={project?.industry} />
<Field <Field
+8
View File
@@ -40,6 +40,14 @@ export function SeoPageForm({ row }: { row?: SeoPageRow }) {
rows={3} rows={3}
defaultValue={row?.description} 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 <MediaPicker
label="OG görseli" label="OG görseli"
name="og_image" name="og_image"
+11
View File
@@ -97,6 +97,17 @@ export default async function SeoAdminPage() {
/> />
</div> </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> <FormActions>
<PrimaryButton> <PrimaryButton>
<Save className="size-4" /> Global ayarları kaydet <Save className="size-4" /> Global ayarları kaydet
+129
View File
@@ -12,6 +12,7 @@ import { getSiteSettings } from "@/lib/data";
import { saveSiteSettings } from "@/lib/admin-actions"; import { saveSiteSettings } from "@/lib/admin-actions";
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import type { import type {
AboutValue,
FaqItem, FaqItem,
ProcessStep, ProcessStep,
StatItem, StatItem,
@@ -104,6 +105,21 @@ function faqToText(items?: string[] | null): string {
return parsed.map((it) => `${it.q}\n${it.a}`).join("\n---\n"); 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({ function Section({
title, title,
description, description,
@@ -215,6 +231,29 @@ export default async function SiteSettingsPage() {
</div> </div>
</Section> </Section>
<Section
title="Çözümler bölümü başlığı"
description="Anasayfadaki çözüm kartlarının üstündeki yazı."
>
<div className="grid gap-5 md:grid-cols-3">
<Field
label="Eyebrow"
name="solutions_eyebrow"
defaultValue={s?.solutions_eyebrow}
/>
<Field
label="Başlık"
name="solutions_title"
defaultValue={s?.solutions_title}
/>
<Field
label="Açıklama"
name="solutions_description"
defaultValue={s?.solutions_description}
/>
</div>
</Section>
<Section <Section
title="Projeler bölümü başlığı" title="Projeler bölümü başlığı"
description="Anasayfadaki proje kartlarının üstündeki yazı." description="Anasayfadaki proje kartlarının üstündeki yazı."
@@ -371,6 +410,96 @@ export default async function SiteSettingsPage() {
/> />
</Section> </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 <Section
title="Conversion / reklam optimizasyonu" title="Conversion / reklam optimizasyonu"
description="Trust bandı, mini lead form ve WhatsApp metni." description="Trust bandı, mini lead form ve WhatsApp metni."
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
:root { :root {
--background: #ffffff; --background: #ffffff;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+2 -1
View File
@@ -30,7 +30,8 @@ export const metadata: Metadata = {
locale: "tr_TR", locale: "tr_TR",
type: "website", 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({ export default async function RootLayout({
+96
View File
@@ -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",
},
});
}
+14
View File
@@ -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,
};
}
+53
View File
@@ -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)),
];
}
+4
View File
@@ -8,6 +8,7 @@ import {
Settings, Settings,
Newspaper, Newspaper,
Layers, Layers,
Boxes,
Briefcase, Briefcase,
MessageSquareQuote, MessageSquareQuote,
Search, Search,
@@ -15,6 +16,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
Users as UsersIcon, Users as UsersIcon,
Building2, Building2,
ListOrdered,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -23,8 +25,10 @@ type Item = { href: string; label: string; icon: LucideIcon };
const items: Item[] = [ const items: Item[] = [
{ href: "/admin", label: "Pano", icon: LayoutDashboard }, { href: "/admin", label: "Pano", icon: LayoutDashboard },
{ href: "/admin/site", label: "Site Ayarları", icon: Settings }, { 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/blog", label: "Blog", icon: Newspaper },
{ href: "/admin/hizmetler", label: "Hizmetler", icon: Layers }, { href: "/admin/hizmetler", label: "Hizmetler", icon: Layers },
{ href: "/admin/cozumler", label: "Çözümler", icon: Boxes },
{ href: "/admin/projeler", label: "Projeler", icon: Briefcase }, { href: "/admin/projeler", label: "Projeler", icon: Briefcase },
{ href: "/admin/sektorler", label: "Sektörler", icon: Building2 }, { href: "/admin/sektorler", label: "Sektörler", icon: Building2 },
{ href: "/admin/ekip", label: "Ekip", icon: UsersIcon }, { href: "/admin/ekip", label: "Ekip", icon: UsersIcon },
+5
View File
@@ -57,6 +57,11 @@ export async function Footer() {
</Link> </Link>
</li> </li>
))} ))}
<li className="pt-1">
<Link href="/cozumler" className="font-medium text-white/90 hover:text-white">
Çözümler
</Link>
</li>
</ul> </ul>
</div> </div>
+98 -86
View File
@@ -3,7 +3,10 @@ import Link from "next/link";
import { ChevronDown, Phone } from "lucide-react"; import { ChevronDown, Phone } from "lucide-react";
import { getSiteSettings, listServices } from "@/lib/data"; import { getSiteSettings, listServices } from "@/lib/data";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { resolveNavItems } from "@/lib/nav";
import type { ServiceRow } from "@/lib/types";
import { HeaderScrollEffect } from "@/components/header-scroll"; import { HeaderScrollEffect } from "@/components/header-scroll";
import { MobileMenu } from "@/components/mobile-menu";
export async function Header() { export async function Header() {
const [settings, services] = await Promise.all([ const [settings, services] = await Promise.all([
@@ -21,6 +24,9 @@ export async function Header() {
["seo-dijital-pazarlama", "sosyal-medya-yonetimi", "dijital-reklam"].includes(s.slug), ["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 ( return (
<> <>
<HeaderScrollEffect /> <HeaderScrollEffect />
@@ -49,92 +55,26 @@ export async function Header() {
</span> </span>
</Link> </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"> <div className="hidden items-center gap-0.5 lg:flex">
<Link {navItems.map((item) =>
href="/" item.mega ? (
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" <ServicesMegaMenu
> key={item.key}
Anasayfa label={item.label}
</Link> webServices={webServices}
marketingServices={marketingServices}
{/* Hizmetler mega menu */} />
<div className="group relative"> ) : (
<button <Link
type="button" key={item.key}
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" 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"
Hizmetler >
<ChevronDown className="size-3 transition-transform duration-200 group-hover:rotate-180" /> {item.label}
</button> </Link>
<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>
</div> </div>
{/* Col 3 — CTA */} {/* Col 3 — CTA */}
@@ -162,10 +102,18 @@ export async function Header() {
</a> </a>
<Link <Link
href="/iletisim" href="/iletisim"
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)]" className="hidden h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)] sm:inline-flex"
> >
Ücretsiz Teklif Ücretsiz Teklif
</Link> </Link>
{/* Mobil menü (hamburger) — sadece < lg */}
<MobileMenu
navItems={navItems}
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
phone={phone}
phoneRaw={phoneRaw}
/>
</div> </div>
</nav> </nav>
</header> </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>
);
}
+45
View File
@@ -1,4 +1,5 @@
import type { import type {
BlogPostRow,
ProjectRow, ProjectRow,
ServiceRow, ServiceRow,
SiteSettingsRow, SiteSettingsRow,
@@ -6,6 +7,12 @@ import type {
} from "@/lib/types"; } from "@/lib/types";
import { siteConfig } from "@/lib/site-config"; 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 }) { export function JsonLd({ data }: { data: object }) {
return ( return (
<script <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 }) { export function ArticleLd({ post }: { post: ProjectRow }) {
return ( return (
<JsonLd <JsonLd
+171
View File
@@ -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>
);
}
+12 -4
View File
@@ -2,7 +2,6 @@ import Link from "next/link";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import { Icon } from "@/components/icon"; import { Icon } from "@/components/icon";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import type { ServiceRow } from "@/lib/types";
type ServiceLike = { type ServiceLike = {
slug: string; slug: string;
@@ -11,18 +10,27 @@ type ServiceLike = {
icon?: string | null; icon?: string | null;
}; };
export function ServicesGrid({ services }: { services: ServiceRow[] }) { // Hem Hizmetler hem Çözümler için kullanılır — sadece basePath ve fallback değişir.
export function ServicesGrid({
services,
basePath = "/hizmetler",
fallback,
}: {
services: ServiceLike[];
basePath?: string;
fallback?: readonly ServiceLike[];
}) {
const items: ServiceLike[] = const items: ServiceLike[] =
services.length > 0 services.length > 0
? services ? services
: (siteConfig.fallbackServices as readonly ServiceLike[]).slice(); : ((fallback ?? siteConfig.fallbackServices) as readonly ServiceLike[]).slice();
return ( return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((s) => ( {items.map((s) => (
<Link <Link
key={s.slug} key={s.slug}
href={`/hizmetler/${s.slug}`} href={`${basePath}/${s.slug}`}
id={s.slug} id={s.slug}
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-2 hover:border-[var(--sky)]/40 hover:shadow-2xl hover:shadow-[var(--navy)]/10" className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-2 hover:border-[var(--sky)]/40 hover:shadow-2xl hover:shadow-[var(--navy)]/10"
> >
+209
View File
@@ -0,0 +1,209 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, ArrowRight, MessageCircle, Phone, Sparkles, CheckCircle2 } from "lucide-react";
import { Icon } from "@/components/icon";
import type { SolutionRow, SiteSettingsRow } from "@/lib/types";
import { siteConfig } from "@/lib/site-config";
const QUICK_TRUST = [
"İşletmenize özel kurgu",
"Tek elden uçtan uca",
"Ücretsiz keşif görüşmesi",
"Yerel ekip — Kocaeli",
];
export function SolutionHero({
solution,
settings,
}: {
solution: SolutionRow;
settings?: SiteSettingsRow | null;
}) {
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const wa = phoneRaw.replace(/[^\d]/g, "");
const waMessage = settings?.whatsapp_message ?? `Merhaba, ${solution.title} çözümü hakkında bilgi almak istiyorum.`;
const waHref = `https://wa.me/${wa}?text=${encodeURIComponent(waMessage)}`;
return (
<section className="relative overflow-hidden border-b border-[var(--border)] bg-gradient-to-br from-[var(--navy-50)]/60 via-white to-[var(--sky-50)]/40">
{/* Subtle grid + glow */}
<div className="absolute inset-0 hero-grid opacity-50" aria-hidden />
<div
className="absolute -right-32 top-1/2 size-[520px] -translate-y-1/2 rounded-full bg-gradient-to-br from-[var(--sky)]/15 to-transparent blur-3xl"
aria-hidden
/>
<div className="relative mx-auto max-w-7xl px-6 py-16 lg:py-20">
<Link
href="/cozumler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm çözümler
</Link>
<div className="mt-8 grid items-start gap-12 lg:grid-cols-[1.3fr_1fr]">
{/* Left — content */}
<div>
<div className="flex items-center gap-3">
<div className="relative">
<div
className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-br from-[var(--sky)] to-purple-500 blur-md opacity-50"
aria-hidden
/>
<div className="flex size-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--sky)] to-purple-500 text-white shadow-lg">
<Icon name={solution.icon} className="size-8" />
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--sky)]/30 bg-white px-3 py-1 text-xs font-medium text-[var(--sky-600)]">
<Sparkles className="size-3.5" />
İşletmenize özel çözüm
</span>
</div>
<h1 className="mt-6 text-4xl font-extrabold leading-[1.1] tracking-tight text-[var(--navy)] sm:text-5xl lg:text-6xl">
<span className="gradient-text">{solution.title}</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
{solution.description}
</p>
{/* Quick trust strip */}
<ul className="mt-8 grid max-w-xl grid-cols-2 gap-2">
{QUICK_TRUST.map((it) => (
<li
key={it}
className="flex items-center gap-2 text-sm text-[var(--foreground)]"
>
<CheckCircle2 className="size-4 shrink-0 text-[var(--sky-600)]" />
{it}
</li>
))}
</ul>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
href="/iletisim"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--navy)] px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-[var(--navy)]/20 transition hover:-translate-y-0.5 hover:bg-[var(--navy-700)]"
>
Ücretsiz teklif al
<ArrowRight className="size-4" />
</Link>
<a
href={waHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25d366] px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-[#25d366]/20 transition hover:-translate-y-0.5 hover:bg-[#1ebe5d]"
>
<MessageCircle className="size-4" />
WhatsApp'tan yaz
</a>
<a
href={`tel:${phoneRaw}`}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-white px-6 py-3.5 text-sm font-semibold text-[var(--navy)] transition hover:border-[var(--navy)]"
>
<Phone className="size-4" />
{phone}
</a>
</div>
</div>
{/* Right — hero card */}
<div className="relative">
{solution.hero_image ? (
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl shadow-2xl shadow-[var(--navy)]/10">
<Image
src={solution.hero_image}
alt={solution.title}
fill
sizes="(min-width: 1024px) 480px, 100vw"
className="object-cover"
priority
/>
{/* Floating badge */}
<div className="absolute bottom-4 left-4 right-4 rounded-xl bg-white/95 p-4 backdrop-blur shadow-lg">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--sky-600)]">
Şimdi başla
</p>
<p className="mt-1 text-sm font-bold text-[var(--navy)]">
Ücretsiz keşif görüşmesi
</p>
</div>
</div>
) : (
<DecorativeSolutionCard solution={solution} />
)}
</div>
</div>
</div>
</section>
);
}
function DecorativeSolutionCard({ solution }: { solution: SolutionRow }) {
return (
<div className="relative">
{/* Outer gradient frame */}
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--navy)] via-[var(--sky-600)] to-[var(--sky)] p-px shadow-2xl shadow-[var(--navy)]/20">
<div className="relative rounded-3xl bg-[#0f172a] p-8">
{/* Animated dots */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
backgroundSize: "24px 24px",
}}
aria-hidden
/>
{/* Glow */}
<div className="absolute -right-20 -top-20 size-64 rounded-full bg-[var(--sky)]/30 blur-3xl" aria-hidden />
{/* Card content */}
<div className="relative">
<div className="flex size-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur ring-1 ring-white/20">
<Icon name={solution.icon} className="size-10 text-[var(--sky)]" />
</div>
<div className="mt-8 space-y-2 text-white">
<p className="text-[11px] font-mono uppercase tracking-[0.2em] text-[var(--sky)]">
kovak.yazilim
</p>
<p className="text-2xl font-bold leading-tight">
{solution.title}
</p>
<p className="text-sm leading-relaxed text-white/60">
İşletmenize özel, uçtan uca çözüm.
</p>
</div>
{/* Bottom badges */}
<div className="mt-8 flex flex-wrap gap-2">
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
Hızlı
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
🛡 Garantili
</span>
<span className="rounded-full bg-white/10 px-3 py-1 text-[10px] font-medium text-white/80 ring-1 ring-white/10">
📞 7/24 Destek
</span>
</div>
</div>
</div>
</div>
{/* Floating accent */}
<div className="absolute -right-4 -top-4 rounded-2xl bg-white p-4 shadow-xl ring-1 ring-[var(--border)]">
<p className="text-xs font-medium text-[var(--muted)]">Memnuniyet</p>
<p className="text-2xl font-bold text-[var(--navy)]">100%</p>
</div>
<div className="absolute -bottom-4 -left-4 rounded-2xl bg-white p-4 shadow-xl ring-1 ring-[var(--border)]">
<p className="text-xs font-medium text-[var(--muted)]">Proje</p>
<p className="text-2xl font-bold text-[var(--navy)]">150+</p>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import Link from "next/link";
import { ArrowRight, MessageCircle, Phone, ShieldCheck } from "lucide-react";
import { Icon } from "@/components/icon";
import { getSiteSettings, listSolutions } from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { QuickLeadForm } from "@/components/quick-lead-form";
export async function SolutionSidebar({
currentSlug,
}: {
currentSlug: string;
}) {
const [settings, solutions] = await Promise.all([
getSiteSettings(),
listSolutions(),
]);
const otherSolutions = solutions
.filter((s) => s.slug !== currentSlug)
.slice(0, 6);
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const wa = phoneRaw.replace(/[^\d]/g, "");
const waMessage = settings?.whatsapp_message ?? "";
const waHref = `https://wa.me/${wa}${
waMessage ? `?text=${encodeURIComponent(waMessage)}` : ""
}`;
return (
<aside className="space-y-5 lg:sticky lg:top-24 lg:self-start">
{/* Quick lead form */}
<QuickLeadForm
title="Bu çözüm için teklif"
description="Adınızı ve telefonunuzu bırakın, 24 saat içinde sizi arayalım."
buttonLabel="Beni arayın"
/>
{/* CTA card */}
<div className="overflow-hidden rounded-2xl border border-[var(--border)] bg-gradient-to-br from-[var(--navy)] to-[var(--sky-600)] p-6 text-white">
<h3 className="text-base font-bold">Hızlı iletişim</h3>
<p className="mt-1 text-sm text-white/80">
Telefon veya WhatsApp ile dakikalar içinde konuşalım.
</p>
<div className="mt-4 space-y-2">
<a
href={`tel:${phoneRaw}`}
className="flex items-center justify-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-[var(--navy)] transition hover:bg-blue-50"
>
<Phone className="size-3.5" />
{phone}
</a>
<a
href={waHref}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-xl bg-[#25d366] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-[#1ebe5d]"
>
<MessageCircle className="size-3.5" />
WhatsApp'tan yaz
</a>
</div>
</div>
{/* Guarantee mini */}
<div className="rounded-2xl border border-[var(--sky)]/30 bg-[var(--sky-50)]/50 p-5">
<div className="flex items-center gap-2">
<ShieldCheck className="size-5 text-[var(--sky-600)]" />
<h3 className="text-sm font-bold text-[var(--navy)]">
Risk almazsınız
</h3>
</div>
<ul className="mt-3 space-y-1.5 text-xs text-[var(--foreground)]">
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
Ücretsiz keşif görüşmesi
</li>
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
1 yıl ücretsiz teknik destek
</li>
<li className="flex gap-1.5">
<span className="text-[var(--sky-600)]"></span>
Kaynak kodlar size ait
</li>
</ul>
</div>
{/* Diğer çözümler — full list */}
{otherSolutions.length > 0 && (
<div className="rounded-2xl border border-[var(--border)] bg-white p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold uppercase tracking-wider text-[var(--navy)]">
Diğer çözümler
</h3>
<Link
href="/cozumler"
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümü
</Link>
</div>
<ul className="mt-4 space-y-1">
{otherSolutions.map((s) => (
<li key={s.slug}>
<Link
href={`/cozumler/${s.slug}`}
className="group flex items-center gap-3 rounded-lg px-2 py-2 text-sm transition hover:bg-[var(--navy-50)]"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[var(--navy-50)] text-[var(--navy)] transition group-hover:bg-gradient-to-br group-hover:from-[var(--sky)] group-hover:to-purple-500 group-hover:text-white">
<Icon name={s.icon} className="size-4" />
</div>
<span className="flex-1 font-medium text-[var(--foreground)] group-hover:text-[var(--navy)]">
{s.title}
</span>
<ArrowRight className="size-3 text-[var(--muted)] opacity-0 transition group-hover:opacity-100" />
</Link>
</li>
))}
</ul>
</div>
)}
{/* Site analizi lead magnet */}
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-white p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--sky-600)]">
Ücretsiz fırsat
</p>
<h3 className="mt-1 text-sm font-bold text-[var(--navy)]">
Site analizi raporu
</h3>
<p className="mt-2 text-xs leading-relaxed text-[var(--muted)]">
Mevcut sitenizin SEO, hız ve dönüşüm performansını ücretsiz değerlendirelim.
</p>
<Link
href="/site-analizi"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Hemen başla
<ArrowRight className="size-3" />
</Link>
</div>
</aside>
);
}
+160 -12
View File
@@ -45,6 +45,12 @@ function strArr(v: FormDataEntryValue | null) {
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean); .filter(Boolean);
} }
// Çok satırlı (textarea) alanlar için ham metin. Tarayıcılar textarea
// içeriğini CRLF (\r\n) ile gönderir; satır-tabanlı parser'lar \n beklediği
// için (özellikle "\n---\n" blok ayracı) okurken normalize ediyoruz.
function raw(v: FormDataEntryValue | null) {
return String(v ?? "").replace(/\r\n?/g, "\n");
}
// ─── Media ─────────────────────────────────────────────────────── // ─── Media ───────────────────────────────────────────────────────
@@ -130,7 +136,7 @@ export async function saveService(formData: FormData) {
const slug = str(formData.get("slug")) || slugify(title); const slug = str(formData.get("slug")) || slugify(title);
// FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."} // FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
const faqRaw = String(formData.get("faq") ?? ""); const faqRaw = raw(formData.get("faq"));
const faq = faqRaw const faq = faqRaw
.split("\n---\n") .split("\n---\n")
.map((block) => { .map((block) => {
@@ -178,6 +184,60 @@ export async function deleteService(formData: FormData) {
revalidatePath("/hizmetler"); revalidatePath("/hizmetler");
} }
// ─── Solutions ───────────────────────────────────────────────────
export async function saveSolution(formData: FormData) {
const secret = await requireSessionSecret();
const id = str(formData.get("id"));
const title = str(formData.get("title"));
if (!title) throw new Error("Başlık zorunlu");
const description = str(formData.get("description"));
if (!description) throw new Error("Açıklama zorunlu");
const slug = str(formData.get("slug")) || slugify(title);
// FAQ as JSON-encoded array. Each item: {"q":"...","a":"..."}
const faqRaw = raw(formData.get("faq"));
const faq = faqRaw
.split("\n---\n")
.map((block) => {
const lines = block.trim().split("\n");
const q = lines[0]?.trim();
const a = lines.slice(1).join("\n").trim();
if (!q || !a) return null;
return JSON.stringify({ q, a });
})
.filter((x): x is string => x !== null);
const data = {
slug,
title,
description,
icon: str(formData.get("icon")),
order: num(formData.get("order")) ?? 0,
featured: bool(formData.get("featured")),
content: str(formData.get("content")),
features: strArr(formData.get("features"))?.filter(Boolean) ?? null,
faq: faq.length > 0 ? faq : null,
hero_image: str(formData.get("hero_image")),
};
if (id) {
await tablesDB.updateRow(DATABASE_ID, TABLES.solutions, id, data, secret);
} else {
await tablesDB.createRow(DATABASE_ID, TABLES.solutions, slug, data, secret);
}
revalidatePath("/admin/cozumler");
revalidatePath("/cozumler");
revalidatePath("/");
}
export async function deleteSolution(formData: FormData) {
const secret = await requireSessionSecret();
const id = String(formData.get("id"));
await tablesDB.deleteRow(DATABASE_ID, TABLES.solutions, id, secret);
revalidatePath("/admin/cozumler");
revalidatePath("/cozumler");
}
// ─── Projects ──────────────────────────────────────────────────── // ─── Projects ────────────────────────────────────────────────────
function parseMetricsInput(raw: string): string[] { function parseMetricsInput(raw: string): string[] {
@@ -201,7 +261,7 @@ export async function saveProject(formData: FormData) {
if (!description) throw new Error("Açıklama zorunlu"); if (!description) throw new Error("Açıklama zorunlu");
// Gallery: one URL per line // Gallery: one URL per line
const galleryRaw = String(formData.get("gallery") ?? ""); const galleryRaw = raw(formData.get("gallery"));
const gallery = galleryRaw const gallery = galleryRaw
.split("\n") .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
@@ -223,8 +283,9 @@ export async function saveProject(formData: FormData) {
industry: str(formData.get("industry")), industry: str(formData.get("industry")),
duration: str(formData.get("duration")), duration: str(formData.get("duration")),
service_slug: str(formData.get("service_slug")), service_slug: str(formData.get("service_slug")),
solution_slug: str(formData.get("solution_slug")),
metrics: (() => { metrics: (() => {
const m = parseMetricsInput(String(formData.get("metrics") ?? "")); const m = parseMetricsInput(raw(formData.get("metrics")));
return m.length > 0 ? m : null; return m.length > 0 ? m : null;
})(), })(),
}; };
@@ -302,7 +363,7 @@ export async function saveSiteSettings(formData: FormData) {
const secret = await requireSessionSecret(); const secret = await requireSessionSecret();
// Hero stats: 3 satır halinde "value|label" formatında — JSON array'e çevir // Hero stats: 3 satır halinde "value|label" formatında — JSON array'e çevir
const statsRaw = String(formData.get("hero_stats") ?? ""); const statsRaw = raw(formData.get("hero_stats"));
const stats = statsRaw const stats = statsRaw
.split("\n") .split("\n")
.map((line) => { .map((line) => {
@@ -313,7 +374,7 @@ export async function saveSiteSettings(formData: FormData) {
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
// Trust items: "icon|value|label" satırlar // Trust items: "icon|value|label" satırlar
const trustRaw = String(formData.get("trust_items") ?? ""); const trustRaw = raw(formData.get("trust_items"));
const trust = trustRaw const trust = trustRaw
.split("\n") .split("\n")
.map((line) => { .map((line) => {
@@ -350,19 +411,58 @@ export async function saveSiteSettings(formData: FormData) {
.filter((x): x is string => x !== null); .filter((x): x is string => x !== null);
} }
const whyUs = parseBlocks(String(formData.get("why_us") ?? ""), true); const whyUs = parseBlocks(raw(formData.get("why_us")), true);
const processSteps = parseBlocks( const processSteps = parseBlocks(raw(formData.get("process_steps")), false);
String(formData.get("process_steps") ?? ""),
false,
);
// Client logos: her satıra bir URL // Client logos: her satıra bir URL
const logosRaw = String(formData.get("client_logos") ?? ""); const logosRaw = raw(formData.get("client_logos"));
const logos = logosRaw const logos = logosRaw
.split("\n") .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
// Hakkımızda values: blok '---' ile, ilk satır title, kalanı description
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 = { const data = {
hero_badge: str(formData.get("hero_badge")), hero_badge: str(formData.get("hero_badge")),
hero_title: str(formData.get("hero_title")), hero_title: str(formData.get("hero_title")),
@@ -377,6 +477,10 @@ export async function saveSiteSettings(formData: FormData) {
services_title: str(formData.get("services_title")), services_title: str(formData.get("services_title")),
services_description: str(formData.get("services_description")), services_description: str(formData.get("services_description")),
solutions_eyebrow: str(formData.get("solutions_eyebrow")),
solutions_title: str(formData.get("solutions_title")),
solutions_description: str(formData.get("solutions_description")),
projects_eyebrow: str(formData.get("projects_eyebrow")), projects_eyebrow: str(formData.get("projects_eyebrow")),
projects_title: str(formData.get("projects_title")), projects_title: str(formData.get("projects_title")),
projects_description: str(formData.get("projects_description")), projects_description: str(formData.get("projects_description")),
@@ -409,11 +513,25 @@ export async function saveSiteSettings(formData: FormData) {
trust_items: trust.length > 0 ? trust : null, trust_items: trust.length > 0 ? trust : null,
why_us: whyUs.length > 0 ? whyUs : null, why_us: whyUs.length > 0 ? whyUs : null,
process_steps: processSteps.length > 0 ? processSteps : null, process_steps: processSteps.length > 0 ? processSteps : null,
homepage_faq: homepageFaq.length > 0 ? homepageFaq : null,
guarantee_title: str(formData.get("guarantee_title")),
guarantee_description: str(formData.get("guarantee_description")),
guarantee_items: guaranteeItems.length > 0 ? guaranteeItems : null,
lead_form_title: str(formData.get("lead_form_title")), lead_form_title: str(formData.get("lead_form_title")),
lead_form_description: str(formData.get("lead_form_description")), lead_form_description: str(formData.get("lead_form_description")),
google_review_url: str(formData.get("google_review_url")), google_review_url: str(formData.get("google_review_url")),
google_rating: num(formData.get("google_rating")), google_rating: num(formData.get("google_rating")),
google_review_count: num(formData.get("google_review_count")), 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 { try {
@@ -437,6 +555,34 @@ export async function saveSiteSettings(formData: FormData) {
revalidatePath("/admin/site"); 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 ──────────────────────────────────────────────── // ─── SEO Settings ────────────────────────────────────────────────
export async function saveSeoSettings(formData: FormData) { export async function saveSeoSettings(formData: FormData) {
@@ -444,6 +590,7 @@ export async function saveSeoSettings(formData: FormData) {
const data = { const data = {
site_name: str(formData.get("site_name")), site_name: str(formData.get("site_name")),
site_description: str(formData.get("site_description")), site_description: str(formData.get("site_description")),
default_keywords: str(formData.get("default_keywords")),
default_og_image: str(formData.get("default_og_image")), default_og_image: str(formData.get("default_og_image")),
twitter_handle: str(formData.get("twitter_handle")), twitter_handle: str(formData.get("twitter_handle")),
facebook_url: str(formData.get("facebook_url")), facebook_url: str(formData.get("facebook_url")),
@@ -485,6 +632,7 @@ export async function saveSeoPage(formData: FormData) {
path, path,
title: str(formData.get("title")), title: str(formData.get("title")),
description: str(formData.get("description")), description: str(formData.get("description")),
keywords: str(formData.get("keywords")),
og_image: str(formData.get("og_image")), og_image: str(formData.get("og_image")),
canonical: str(formData.get("canonical")), canonical: str(formData.get("canonical")),
noindex: bool(formData.get("noindex")), noindex: bool(formData.get("noindex")),
@@ -562,7 +710,7 @@ export async function saveIndustry(formData: FormData) {
if (!title) throw new Error("Başlık zorunlu"); if (!title) throw new Error("Başlık zorunlu");
const slug = str(formData.get("slug")) || slugify(title); const slug = str(formData.get("slug")) || slugify(title);
const faqRaw = String(formData.get("faq") ?? ""); const faqRaw = raw(formData.get("faq"));
const faq = faqRaw const faq = faqRaw
.split("\n---\n") .split("\n---\n")
.map((block) => { .map((block) => {
+1
View File
@@ -16,6 +16,7 @@ export const MEDIA_BUCKET_ID =
export const TABLES = { export const TABLES = {
contactMessages: "contact_messages", contactMessages: "contact_messages",
services: "services", services: "services",
solutions: "solutions",
projects: "projects", projects: "projects",
blogPosts: "blog_posts", blogPosts: "blog_posts",
testimonials: "testimonials", testimonials: "testimonials",
+17
View File
@@ -7,6 +7,7 @@ import type {
IndustryRow, IndustryRow,
ProjectRow, ProjectRow,
ServiceRow, ServiceRow,
SolutionRow,
SeoPageRow, SeoPageRow,
SeoSettingsRow, SeoSettingsRow,
SiteSettingsRow, SiteSettingsRow,
@@ -44,14 +45,22 @@ export async function listServices(opts?: { featured?: boolean }) {
return safeList<ServiceRow>(TABLES.services, q); return safeList<ServiceRow>(TABLES.services, q);
} }
export async function listSolutions(opts?: { featured?: boolean }) {
const q = [Q.orderAsc("order"), Q.limit(50)];
if (opts?.featured) q.unshift(Q.equal("featured", true));
return safeList<SolutionRow>(TABLES.solutions, q);
}
export async function listProjects(opts?: { export async function listProjects(opts?: {
featured?: boolean; featured?: boolean;
limit?: number; limit?: number;
serviceSlug?: string; serviceSlug?: string;
solutionSlug?: string;
}) { }) {
const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)]; const q = [Q.orderDesc("year"), Q.limit(opts?.limit ?? 50)];
if (opts?.featured) q.unshift(Q.equal("featured", true)); if (opts?.featured) q.unshift(Q.equal("featured", true));
if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug)); if (opts?.serviceSlug) q.unshift(Q.equal("service_slug", opts.serviceSlug));
if (opts?.solutionSlug) q.unshift(Q.equal("solution_slug", opts.solutionSlug));
return safeList<ProjectRow>(TABLES.projects, q); return safeList<ProjectRow>(TABLES.projects, q);
} }
@@ -63,6 +72,14 @@ export async function getServiceBySlug(slug: string): Promise<ServiceRow | null>
return res[0] ?? null; return res[0] ?? null;
} }
export async function getSolutionBySlug(slug: string): Promise<SolutionRow | null> {
const res = await safeList<SolutionRow>(TABLES.solutions, [
Q.equal("slug", slug),
Q.limit(1),
]);
return res[0] ?? null;
}
export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> { export async function getProjectBySlug(slug: string): Promise<ProjectRow | null> {
const res = await safeList<ProjectRow>(TABLES.projects, [ const res = await safeList<ProjectRow>(TABLES.projects, [
Q.equal("slug", slug), Q.equal("slug", slug),
+119
View File
@@ -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
View File
@@ -19,17 +19,58 @@ export async function buildMetadata(path: string, fallback?: Metadata): Promise<
override?.description ?? override?.description ??
(fallback?.description as string | undefined) ?? (fallback?.description as string | undefined) ??
siteDesc; 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 { return {
title, title,
description, description,
keywords,
metadataBase: new URL(siteConfig.url), metadataBase: new URL(siteConfig.url),
openGraph: { openGraph: {
title, title,
description, description,
images: ogImage ? [{ url: ogImage }] : undefined, images: ogImage ? [{ url: ogImage }] : undefined,
type: "website", type: ogType as "website" | "article",
locale: "tr_TR", locale: "tr_TR",
siteName, siteName,
}, },
+6
View File
@@ -25,4 +25,10 @@ export const siteConfig = {
{ slug: "sosyal-medya-yonetimi", title: "Sosyal Medya Yönetimi", icon: "Share2", description: "Marka diliyle uyumlu içerik üretimi ve topluluk yönetimi." }, { slug: "sosyal-medya-yonetimi", title: "Sosyal Medya Yönetimi", icon: "Share2", description: "Marka diliyle uyumlu içerik üretimi ve topluluk yönetimi." },
{ slug: "dijital-reklam", title: "Dijital Reklam", icon: "Megaphone", description: "Google Ads ve Meta Ads kampanyalarıyla hedefli erişim ve ölçülebilir sonuçlar." }, { slug: "dijital-reklam", title: "Dijital Reklam", icon: "Megaphone", description: "Google Ads ve Meta Ads kampanyalarıyla hedefli erişim ve ölçülebilir sonuçlar." },
], ],
fallbackSolutions: [
{ slug: "kurumsal-dijitallesme", title: "Kurumsal Dijitalleşme", icon: "Layers", description: "Web, mobil ve iç sistemleri tek çatı altında toplayan uçtan uca dijitalleşme paketi." },
{ slug: "online-satis-altyapisi", title: "Online Satış Altyapısı", icon: "ShoppingCart", description: "E-ticaret, ödeme ve stok entegrasyonlarıyla satışa hazır komple altyapı." },
{ slug: "musteri-yonetimi-crm", title: "Müşteri Yönetimi (CRM)", icon: "Users", description: "Satış, destek ve operasyon süreçlerini tek panelde toplayan CRM çözümü." },
{ slug: "buyume-pazarlama", title: "Büyüme & Pazarlama", icon: "TrendingUp", description: "SEO, reklam ve içerikle ölçülebilir müşteri kazanımı sağlayan büyüme paketi." },
],
} as const; } as const;
+40
View File
@@ -15,6 +15,20 @@ export interface ServiceRow extends AwRow {
hero_image?: string | null; hero_image?: string | null;
} }
// İşletmelere sunulan çözümler — Hizmetler ile birebir aynı yapı, ayrı tablo.
export interface SolutionRow extends AwRow {
slug: string;
title: string;
description: string;
icon?: string | null;
order?: number | null;
featured?: boolean | null;
content?: string | null;
features?: string[] | null;
faq?: string[] | null; // each item is JSON: {"q":"...","a":"..."}
hero_image?: string | null;
}
export interface FaqItem { export interface FaqItem {
q: string; q: string;
a: string; a: string;
@@ -36,6 +50,7 @@ export interface ProjectRow extends AwRow {
industry?: string | null; industry?: string | null;
duration?: string | null; duration?: string | null;
service_slug?: string | null; service_slug?: string | null;
solution_slug?: string | null;
metrics?: string[] | null; // JSON {"value":"+150%","label":"Trafik artışı"} metrics?: string[] | null; // JSON {"value":"+150%","label":"Trafik artışı"}
} }
@@ -70,6 +85,7 @@ export interface SeoPageRow extends AwRow {
path: string; path: string;
title?: string | null; title?: string | null;
description?: string | null; description?: string | null;
keywords?: string | null; // virgülle ayrılmış anahtar kelimeler (sayfa override)
og_image?: string | null; og_image?: string | null;
canonical?: string | null; canonical?: string | null;
noindex?: boolean | null; noindex?: boolean | null;
@@ -78,6 +94,7 @@ export interface SeoPageRow extends AwRow {
export interface SeoSettingsRow extends AwRow { export interface SeoSettingsRow extends AwRow {
site_name?: string | null; site_name?: string | null;
site_description?: string | null; site_description?: string | null;
default_keywords?: string | null; // virgülle ayrılmış site geneli anahtar kelimeler
default_og_image?: string | null; default_og_image?: string | null;
twitter_handle?: string | null; twitter_handle?: string | null;
facebook_url?: string | null; facebook_url?: string | null;
@@ -106,6 +123,10 @@ export interface SiteSettingsRow extends AwRow {
services_title?: string | null; services_title?: string | null;
services_description?: string | null; services_description?: string | null;
solutions_eyebrow?: string | null;
solutions_title?: string | null;
solutions_description?: string | null;
projects_eyebrow?: string | null; projects_eyebrow?: string | null;
projects_title?: string | null; projects_title?: string | null;
projects_description?: string | null; projects_description?: string | null;
@@ -133,6 +154,9 @@ export interface SiteSettingsRow extends AwRow {
footer_tagline?: string | null; footer_tagline?: string | null;
// Üst menü düzeni — JSON dizi: [{ key, visible, label? }] sırasıyla
nav_items?: string | null;
whatsapp_message?: string | null; whatsapp_message?: string | null;
client_logos?: string[] | null; client_logos?: string[] | null;
trust_items?: string[] | null; // JSON {"icon":"Star","value":"4.9","label":"..."} 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_title?: string | null;
guarantee_description?: string | null; guarantee_description?: string | null;
guarantee_items?: 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 { export interface TeamMemberRow extends AwRow {
+44 -1
View File
@@ -8,6 +8,7 @@
"name": "kovak-yazilim", "name": "kovak-yazilim",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.23.5", "@tiptap/extension-image": "^3.23.5",
"@tiptap/extension-link": "^3.23.5", "@tiptap/extension-link": "^3.23.5",
"@tiptap/extension-placeholder": "^3.23.5", "@tiptap/extension-placeholder": "^3.23.5",
@@ -1083,6 +1084,18 @@
"tailwindcss": "4.3.0" "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": { "node_modules/@tiptap/core": {
"version": "3.23.5", "version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz",
@@ -1622,6 +1635,18 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2128,6 +2153,19 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prosemirror-changeset": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
@@ -2384,7 +2422,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
@@ -2437,6 +2474,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+1
View File
@@ -8,6 +8,7 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.23.5", "@tiptap/extension-image": "^3.23.5",
"@tiptap/extension-link": "^3.23.5", "@tiptap/extension-link": "^3.23.5",
"@tiptap/extension-placeholder": "^3.23.5", "@tiptap/extension-placeholder": "^3.23.5",