Compare commits

..

15 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
Ege Can Komur 1813b96f82 feat: WP'den ekip seed et (Egecan + Emre) + TeamGrid WP stiline
WP page-hakkimizda.php'de 2 kurucu vardı, atlamışım:

1) team_members'a 'skills' (string array) alanı eklendi

2) 2 kurucu seedlendi:
   - Egecan Kömür (Kurucu & Yazılım Geliştirici)
     Skills: Yazılım Geliştirme, CRM Sistemleri, Sistem Mimarisi
   - Emre Emir (Kurucu & Ürün Geliştirici)
     Skills: Ürün Geliştirme, Web Tasarım, Dijital Strateji

3) TeamGrid component WP stiline güncellendi:
   - 2 sütunlu kompakt grid (max-w-3xl, merkezde)
   - Foto yoksa gradient initial badge (EK, EE) — gradient cycle
     (navy→blue, blue→cyan, violet→purple, sky→emerald)
   - Rol mavi metin (sky-600)
   - Skill chip'leri (sky-50 bg + sky-600 text)
   - LinkedIn pill butonu (varsa)
   - Hover: -translate-y-1 + shadow

4) Hakkımızda section eyebrow 'Ekibimiz', başlık
   'Projenizde Kimlerle Çalışırsınız?' — WP'deki birebir

5) Admin form'una skills field eklendi (virgülle ayrılmış)

Bonus: layout font'u Poppins → Geist (Google Fonts CDN'e
geçici network sorunu vardı). --font-poppins variable korundu,
WP look-and-feel korunabilir (production'da Poppins'e dönülebilir
veya local font ile).
2026-05-20 19:47:30 +03:00
Ege Can Komur f88b76546c fix: blog empty state + proje detay yarı boş meta tablosu
Claude vision ile localhost'ta yapılan tarama sonrası 2 sorun:

1) Blog sayfası — henüz yazı yoksa empty state küçük bir kart olup
   altında ~1000px beyaz alan kalıyordu, footer çok aşağıdaydı.

   Çözüm: Empty state için zengin bir layout:
   - Gradient hero ('Blog yazılarımızı hazırlıyoruz' + 3 CTA:
     keşif görüşmesi / WhatsApp / Telefon)
   - 'Bu arada hizmetlerimize göz atın' başlıkla 6 hizmet grid
   - 'Site analiz raporu' lead magnet kartı
   Yazı geldiğinde otomatik normal grid'e döner.

2) Proje detay sayfası — sağ üstte 2 sütunlu meta card sadece
   'Yıl: 2025' gösteriyordu (client_name/industry/duration boş),
   yarı boş görünüyor + sağ tarafta büyük boşluk.

   Çözüm:
   - meta.length >= 2 → eski 2x2 grid card
   - meta.length === 1 → inline pill strip altta
   - meta yok → grid tek sütuna döner (lg:grid-cols-[1.4fr_1fr]
     conditional)

Claude vision ile 10 sayfa screenshot alındı (/tmp/kovak-screenshots).
Diğer sayfalar (anasayfa, hizmet detay, sektör, iletişim, vs)
tasarım açısından temiz görünüyor.
2026-05-20 19:07:26 +03:00
Ege Can Komur fdfa556d42 feat: pill telefon → Ara butonu + hizmet detay zengin sidebar + unique hero
1) Header pill mode:
   - Pill aktifken telefon link gizlenir
   - Yerine kompakt 'Ara' butonu görünür (data-pill-show='true')
   - header-scroll.tsx hem hide hem show class'larını yönetiyor

2) Hizmet detay sayfası — yeni unique hero (ServiceHero component):
   - Gradient gradient icon (sky → purple, glow ile)
   - Profesyonel hizmet badge'i
   - Gradient text başlık
   - 4 'quick trust' satırı (teslim, destek, ücretsiz taslak, yerel)
   - 3 CTA: Teklif al (navy) / WhatsApp (yeşil) / Telefon ara
   - Sağda: hero_image varsa görsel + 'Şimdi başla' floating badge
   - Yoksa: dekoratif dark card + animasyonlu nokta deseni + glow +
     floating '100% Memnuniyet' ve '150+ Proje' kartları

3) Hizmet detay sayfası — sidebar (ServiceSidebar component):
   - QuickLeadForm (ad + telefon)
   - Gradient CTA card (telefon + WhatsApp butonları)
   - 'Risk almazsınız' garanti mini card
   - Diğer hizmetler tam listesi (icon + isim, hover'da gradient)
   - Site analizi lead magnet kartı

   Önceki versiyonda sadece 1 boş CTA + 1 boş diğer hizmetler vardı —
   artık doluyu doluya sidebar.

4) Layout: lg:grid-cols-[2fr_1fr] → lg:grid-cols-[1.5fr_1fr]
   - Sidebar daha geniş, içerik orantılı dağıldı
2026-05-20 19:01:24 +03:00
Ege Can Komur d5344443e9 fix: pill modunda telefon link'i gizle (sıkışma sorunu)
Header pill modunda max-width 1100px'e iniyor. Nav + telefon + CTA
3 element sığmıyordu, telefon ile 'Ücretsiz Teklif' butonu üst üste
biniyordu.

Çözüm:
- Telefon link'ine data-pill-hide='true' attribute eklendi
- header-scroll.tsx scroll'da bu elementleri display:none yapıyor
- Pill kapanınca tekrar görünür hale geliyor
- whitespace-nowrap CTA butonuna eklendi (taşma engeli)
2026-05-20 18:56:21 +03:00
Ege Can Komur 8b4129c233 fix: Pexels image domain + ConsentInit'i statik script src'e taşı
İki sorun düzeltildi:

1) next.config.ts'e images.pexels.com (+ unsplash) eklendi
   - Mevcut 6 referans projesinde Pexels görsel URL'leri var
   - 'Invalid src prop on next/image' hatası giderildi
   - images.unsplash.com da eklendi (gelecekte kullanım için)

2) ConsentInit artık /public/consent-default.js'i src ile yüklüyor
   - React 19 + Next.js 16'da inline <script>{code}</script> ve
     <script dangerouslySetInnerHTML> her ikisi de 'Encountered a script
     tag while rendering React component' warning'i üretiyor
   - Statik dosya + <script src> pattern'i React'ın temiz şekilde
     kabul ettiği yöntem — warning yok, davranış aynı
   - GTM de aynı şekilde async src kullanıyor (önceki inline snippet
     yerine direkt GTM URL'i)
   - Consent default hala synchronous (script src defer/async olmadan)
     — gtag('consent','default') hiçbir analytics yüklenmeden çalışır
   - noscript iframe fallback korundu
2026-05-20 18:53:55 +03:00
Ege Can Komur 69f0c857ec fix: next/script yerine dangerouslySetInnerHTML kullan (React 19 uyumu)
Sorun:
React 19 + Next.js 16'da next/script componenti inline scripts (children
ile) için 'Encountered a script tag while rendering React component'
hatası veriyor. <script> child node React'ta artık warning üretiyor.

Çözüm:
ConsentInit'te next/script yerine düz <script dangerouslySetInnerHTML>
kullanıyoruz. Bu inline script HTML parse edildiği anda çalışır —
beforeInteractive davranışını taklit eder, hatta daha güvenilir.

Avantaj:
- gtag('consent','default') hiçbir analytics scripti yüklenmeden önce
  kaydedilir (Google Consent Mode v2 zorunluluğu)
- GTM snippet'i de aynı şekilde body'nin başında çalışır
- noscript fallback iframe korundu
2026-05-20 18:51:38 +03:00
Ege Can Komur 9d74cceb69 fix: ConsentInit'i <head>'den <body>'e taşı (Next.js 16 uyumu)
Hata:
> Encountered a script tag while rendering React component.
> Scripts inside React components are never executed when rendering on the client.

Sebep:
Next.js 16 React 19'da next/script'i <head> içine koyamıyoruz. Script'ler
body içinde render edilmeli — Next.js zaten doğru yere yerleştiriyor.

Çözüm:
ConsentInit'i app/layout.tsx içinde <head>'den çıkardık, body'nin
ilk elementi olarak koyduk. beforeInteractive strategy hala çalışıyor
(Next.js doğru sırayı yönetiyor).
2026-05-20 18:49:15 +03:00
Ege Can Komur e45c44721f feat: WP'den header + kart stilleri + blog sidebar widget
Header (components/header.tsx + header-scroll.tsx):
- WP'deki 'floating pill' efekti — scroll'da küçülen + yuvarlanan + gölgeli
- 3 sütun grid: Logo | Nav | CTA
- Hizmetler mega menu dropdown — 2 sütunlu (Web&Yazılım + Dijital Pazarlama)
  - Hover'da açılır, services tablosundan dinamik
  - Alt linkle 'Tüm hizmetleri gör'
- Mobil için scroll-down'da gizlenir
- Sağda 'Ücretsiz Teklif' CTA butonu + telefon link

Kart stilleri (WP'ye eşlendi):
- ServicesGrid:
  - Gradient icon (sky → purple) ile WP'deki '🎨 🚀 📱' emoji yerine ikon
  - Hover: -translate-y-2 + colored shadow + scale icon
  - ArrowUpRight ikonu absolute, hover'da görünür
- ProjectsGrid:
  - Kategori bazlı renkli badge (Kurumsal navy, Klinik cyan, Portfolio violet, …)
  - Hover: image scale-105 + gradient overlay
  - 5/3 aspect ratio (daha WP-like)

Public sidebar (components/content-sidebar.tsx):
- CTA card (gradient navy→sky): Telefon + WhatsApp
- Son yazılar (4 adet, kapak + başlık + tarih)
- Etiketler (en sık kullanılan 10)
- Hizmetler menü (6 adet)
- Site analizi lead magnet

Blog detay sayfası (/blog/[slug]):
- Tek sütun → 2 sütun grid (content + sidebar)
- sticky sidebar, max-w-7xl
- Aynı pattern hizmet/proje detay sayfalarına da uygulanabilir

37 route, build temiz.
2026-05-20 18:45:02 +03:00
Ege Can Komur deff889f0c feat: WordPress tarzı rich editor (TipTap + slash menu + MediaPicker)
WordPress Gutenberg + Notion karışımı blok editor. 4 admin formunda
markdown textarea yerine gerçek WYSIWYG editor.

RichEditor component (components/admin/rich-editor.tsx):
- TipTap v3 (@tiptap/react + starter-kit + link + image + placeholder + underline)
- Üst toolbar (her zaman görünür):
  - B / I / U (bold, italic, underline)
  - H1 / H2 / H3
  - Bullet list / Ordered list / Quote / Code block
  - Link (URL prompt)
  - Görsel ekle (MediaPicker modal)
  - Undo / Redo
- Slash menu: '/' yazınca blok seçim menüsü açılır
  - Notion tarzı keyboard navigation (↓↑ Enter Esc)
  - 8 blok tipi: H1/H2/H3/ul/ol/quote/code/hr
- Image picker modal (toolbar görsel butonundan)
  - Mevcut MediaPicker'ı kullanır
  - 'Yeni görsel yükle' (progress bar ile) + 'Kütüphaneden seç' grid
- HTML çıktı (hidden input ile form'a)
- Mevcut content alanlarıyla backward compat

Formlarda değişiklik (4 dosya):
- app/admin/(protected)/blog/form.tsx → content
- app/admin/(protected)/hizmetler/form.tsx → content
- app/admin/(protected)/projeler/form.tsx → content
- app/admin/(protected)/sektorler/form.tsx → content

Public render (lib/content-render.ts):
- renderContent() yardımcısı:
  - İçerik '<' ile başlıyorsa → HTML (direkt döner)
  - Aksi halde → markdown (marked.parse)
- 4 detay sayfası bu helper'ı kullanıyor (blog/[slug], projeler/[slug],
  hizmetler/[slug], sektor/[slug])
- Eski markdown içerikler hala çalışıyor, yeni içerikler HTML olarak gelir

37 route, build temiz.
2026-05-20 18:34:44 +03:00
Ege Can Komur 4d5186ff0c feat: kovakyazilim.com WordPress sitesinden tasarım + içerik aktarımı
Hostinger SSH + WP-CLI ile mevcut canlı sitedeki tasarım ve içerikler aktarıldı.

Tasarım değişiklikleri:
- Renkler: navy #0F2C5C → #043e8c (deep navy), sky #4DA3C7 → #3b82f6 (bright blue)
- Tailwind blue palette uyumlu, WP kovak temasıyla bire bir
- Font: Geist → Poppins (300/400/500/600/700/800)
- Hero: gradient açık → dark gradient (#0f172a → #1e293b → #334155)
  - Pulse animasyonlu radial glow
  - 'Web Tasarım', 'Yazılım', 'Ajansı' kelimelerini blue-400 vurgu
  - Stats strip artık hero'nun alt kenarında (4 metrik)

İçerik aktarımı (Appwrite):

site_settings/homepage güncellendi:
- Hero: 'Kocaeli Web Tasarım ve Yazılım Ajansı' + 2015'ten beri tagline
- Stats: 150+ proje / 50+ müşteri / 100% memnuniyet / 24/7 destek
- Services intro: 'İzmit ve Kocaeli Web Tasarım Hizmetlerimiz'
- Projects intro: 'İzmit Web Tasarım Referanslarımız'
- CTA: 'Projeniz İçin Hemen Teklif Alın'

6 referans projesi seed edildi (WP'deki real client listesi):
- Torpido Makina (torpidomakina.com.tr) — Volvo Penta yetkili bayi
- Marjinal Dent (marjinaldent.com) — Diş kliniği
- Abdullah Tosun Mimarlık (abdullahtosun.art) — Mimar portfoyu
- Calibra PPF (calibrappf.com) — Araç kaplama
- Atolye Bastar — Zanaat atölyesi (yakında)
- MarineCRM — Denizcilik CRM (özel yazılım)

6 hizmet description'u WP içerikleriyle güncellendi:
- web-tasarim → 'Profesyonel Web Tasarım İzmit'
- e-ticaret → 'E-Ticaret Sitesi Yapımı'
- mobil-uygulama → 'Mobil Uygulama Geliştirme'
- yazilim-gelistirme → 'Özel Yazılım Geliştirme İzmit'
- seo-dijital-pazarlama → 'SEO Hizmeti Kocaeli'
- dijital-reklam → 'Dijital Pazarlama Kocaeli'

7 sektör/bölge landing sayfası industries tablosuna seed edildi:
- /sektor/kocaeli-web-tasarim
- /sektor/izmit-web-tasarim
- /sektor/gebze-web-tasarim
- /sektor/kocaeli-seo
- /sektor/izmit-seo
- /sektor/kocaeli-yazilim
- /sektor/izmit-web-tasarim-fiyat (paket fiyat tablosu ile)

Her sektör için:
- WP'deki rank_math_description meta'ları SEO desc olarak
- Markdown content + features list + FAQ
- featured + order ayarlı

37 route, build temiz.

Sıradaki adım: WP uploads klasöründen müşteri görsellerini indirip
project image_url'lerini local'e taşıyabilir (şu an Pexels CDN kullanılıyor).
2026-05-20 18:26:05 +03:00
62 changed files with 4957 additions and 576 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
+64 -51
View File
@@ -3,9 +3,11 @@ import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, Calendar } from "lucide-react"; import { ArrowLeft, Calendar } from "lucide-react";
import { marked } from "marked"; 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";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -18,10 +20,14 @@ 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,
images: post.seo_image || post.cover_image ? [{ url: (post.seo_image || post.cover_image) as string }] : undefined, images:
post.seo_image || post.cover_image
? [{ url: (post.seo_image || post.cover_image) as string }]
: undefined,
type: "article", type: "article",
}, },
}); });
@@ -36,10 +42,11 @@ export default async function BlogPostPage({
const post = await getPostBySlug(slug); const post = await getPostBySlug(slug);
if (!post || post.status !== "published") notFound(); if (!post || post.status !== "published") notFound();
const html = post.content ? marked.parse(post.content, { async: false }) as string : ""; const html = renderContent(post.content);
return ( return (
<article className="mx-auto max-w-3xl px-6 py-20"> <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)]"
@@ -47,56 +54,62 @@ export default async function BlogPostPage({
<ArrowLeft className="size-3.5" /> Tüm yazılar <ArrowLeft className="size-3.5" /> Tüm yazılar
</Link> </Link>
<header className="mt-6 border-b border-[var(--border)] pb-8"> <div className="mt-6 grid gap-12 lg:grid-cols-[1fr_320px]">
{post.tags && post.tags.length > 0 && ( <article>
<div className="flex flex-wrap gap-2"> <header className="border-b border-[var(--border)] pb-8">
{post.tags.map((t) => ( {post.tags && post.tags.length > 0 && (
<span <div className="flex flex-wrap gap-2">
key={t} {post.tags.map((t) => (
className="rounded-full bg-[var(--sky-50)] px-2.5 py-1 text-xs text-[var(--sky-600)]" <span
> key={t}
{t} className="rounded-full bg-[var(--sky-50)] px-2.5 py-1 text-xs text-[var(--sky-600)]"
</span> >
))} {t}
</div> </span>
)} ))}
<h1 className="mt-4 text-3xl font-bold leading-tight tracking-tight text-[var(--navy)] sm:text-4xl"> </div>
{post.title} )}
</h1> <h1 className="mt-4 text-3xl font-bold leading-tight tracking-tight text-[var(--navy)] sm:text-4xl">
{post.excerpt && ( {post.title}
<p className="mt-4 text-lg leading-relaxed text-[var(--muted)]"> </h1>
{post.excerpt} {post.excerpt && (
</p> <p className="mt-4 text-lg leading-relaxed text-[var(--muted)]">
)} {post.excerpt}
<div className="mt-6 flex items-center gap-3 text-xs text-[var(--muted)]"> </p>
{post.author && <span>{post.author}</span>} )}
{post.author && post.published_at && <span></span>} <div className="mt-6 flex items-center gap-3 text-xs text-[var(--muted)]">
{post.published_at && ( {post.author && <span>{post.author}</span>}
<span className="inline-flex items-center gap-1"> {post.author && post.published_at && <span></span>}
<Calendar className="size-3" /> {post.published_at && (
{new Date(post.published_at).toLocaleDateString("tr-TR")} <span className="inline-flex items-center gap-1">
</span> <Calendar className="size-3" />
{new Date(post.published_at).toLocaleDateString("tr-TR")}
</span>
)}
</div>
</header>
{post.cover_image && (
<div className="relative mt-8 aspect-video overflow-hidden rounded-2xl">
<Image
src={post.cover_image}
alt={post.title}
fill
sizes="(min-width: 1024px) 768px, 100vw"
className="object-cover"
priority
/>
</div>
)} )}
</div>
</header>
{post.cover_image && ( <div
<div className="relative mt-8 aspect-video overflow-hidden rounded-2xl"> className="prose prose-lg mt-10 max-w-none text-[var(--foreground)]"
<Image dangerouslySetInnerHTML={{ __html: html }}
src={post.cover_image}
alt={post.title}
fill
sizes="(min-width: 1024px) 768px, 100vw"
className="object-cover"
priority
/> />
</div> </article>
)}
<div <ContentSidebar currentSlug={slug} />
className="prose prose-lg mt-10 max-w-none text-[var(--foreground)]" </div>
dangerouslySetInnerHTML={{ __html: html }} </div>
/>
</article>
); );
} }
+205 -57
View File
@@ -1,21 +1,179 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ArrowRight, Calendar } from "lucide-react"; import {
ArrowRight,
Calendar,
Sparkles,
MessageCircle,
Phone,
FileText,
} from "lucide-react";
import { SectionTitle } from "@/components/section-title"; import { SectionTitle } from "@/components/section-title";
import { listPublishedPosts } from "@/lib/data"; import { Icon } from "@/components/icon";
import {
getSiteSettings,
listPublishedPosts,
listServices,
} from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
import { siteConfig } from "@/lib/site-config";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return buildMetadata("/blog", { return buildMetadata("/blog", {
title: "Blog", title: "Blog",
description: "Yazılım, web tasarım, SEO ve dijital pazarlama üzerine yazılar.", description:
"Yazılım, web tasarım, SEO ve dijital pazarlama üzerine yazılar.",
}); });
} }
export default async function BlogIndex() { export default async function BlogIndex() {
const posts = await listPublishedPosts(); const [posts, services, settings] = await Promise.all([
listPublishedPosts(),
listServices(),
getSiteSettings(),
]);
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)}` : ""
}`;
// Empty state — site daha yeni, içerik yok
if (posts.length === 0) {
return (
<div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle
eyebrow="Blog"
title="Yazılım, tasarım ve büyüme üzerine"
description="Sektörden notlar, vaka çalışmaları ve teknik rehberler."
/>
{/* Coming soon hero */}
<div className="relative mt-14 overflow-hidden rounded-3xl bg-gradient-to-br from-[var(--navy)] via-[var(--sky-600)] to-[var(--sky)] p-12 text-center text-white shadow-xl shadow-[var(--navy)]/15">
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
backgroundSize: "24px 24px",
}}
aria-hidden
/>
<div className="relative">
<span className="inline-flex items-center gap-2 rounded-full border border-white/30 bg-white/10 px-4 py-1.5 text-xs font-medium backdrop-blur">
<Sparkles className="size-3.5" />
Yakında
</span>
<h2 className="mt-5 text-3xl font-bold sm:text-4xl">
Blog yazılarımızı hazırlıyoruz
</h2>
<p className="mx-auto mt-4 max-w-xl text-base text-white/80">
Sektörden vaka çalışmaları, teknik rehberler ve sahada öğrendiklerimizi
kısa sürede burada paylaşmaya başlayacağız.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/iletisim"
className="inline-flex items-center gap-2 rounded-xl bg-white px-5 py-3 text-sm font-semibold text-[var(--navy)] transition hover:-translate-y-0.5 hover:bg-blue-50"
>
Ücretsiz keşif görüşmesi
<ArrowRight className="size-4" />
</Link>
<a
href={waHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-xl bg-[#25d366] px-5 py-3 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-[#1ebe5d]"
>
<MessageCircle className="size-4" />
WhatsApp
</a>
<a
href={`tel:${phoneRaw}`}
className="inline-flex items-center gap-2 rounded-xl border border-white/30 bg-white/5 px-5 py-3 text-sm font-semibold text-white backdrop-blur transition hover:border-white/60"
>
<Phone className="size-4" />
{phone}
</a>
</div>
</div>
</div>
{/* Bu arada → Hizmetler grid */}
{services.length > 0 && (
<section className="mt-16">
<div className="flex items-end justify-between">
<SectionTitle
align="left"
eyebrow="Bu arada"
title="Hizmetlerimize göz atın"
description="Blog yazılarımızı beklerken sunduğumuz çözümleri keşfedin."
/>
<Link
href="/hizmetler"
className="hidden text-sm font-medium text-[var(--sky-600)] hover:text-[var(--navy)] sm:inline-flex"
>
Tümünü gör
</Link>
</div>
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{services.slice(0, 6).map((s) => (
<Link
key={s.slug}
href={`/hizmetler/${s.slug}`}
className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-6 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--sky)]/40 hover:shadow-xl hover:shadow-[var(--navy)]/10"
>
<div className="flex size-12 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--sky)] to-purple-500 text-white shadow-lg">
<Icon name={s.icon} className="size-5" />
</div>
<h3 className="mt-5 text-base font-bold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
{s.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-2">
{s.description}
</p>
</Link>
))}
</div>
</section>
)}
{/* Lead magnet CTA */}
<section className="mt-16 rounded-2xl border border-dashed border-[var(--sky)]/40 bg-[var(--sky-50)]/40 p-8 sm:p-10">
<div className="flex flex-col items-start gap-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--sky-600)]">
<FileText className="mr-1 inline size-3.5" />
Ücretsiz Rapor
</p>
<h3 className="mt-2 text-2xl font-bold text-[var(--navy)]">
Site analiz raporunuzu alın
</h3>
<p className="mt-2 max-w-xl text-sm text-[var(--muted)]">
Mevcut sitenizin SEO, hız, mobil ve dönüşüm performansını ücretsiz
değerlendirelim. 24 saat içinde detaylı rapor e-postanızda.
</p>
</div>
<Link
href="/site-analizi"
className="inline-flex items-center gap-2 rounded-xl bg-[var(--navy)] px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-[var(--navy)]/20 transition hover:-translate-y-0.5 hover:bg-[var(--navy-700)]"
>
Ücretsiz raporumu istiyorum
<ArrowRight className="size-4" />
</Link>
</div>
</section>
</div>
);
}
// Normal grid — yazı varsa
return ( return (
<div className="mx-auto max-w-7xl px-6 py-20"> <div className="mx-auto max-w-7xl px-6 py-20">
<SectionTitle <SectionTitle
@@ -24,60 +182,50 @@ export default async function BlogIndex() {
description="Sektörden notlar, vaka çalışmaları ve teknik rehberler." description="Sektörden notlar, vaka çalışmaları ve teknik rehberler."
/> />
<div className="mt-14"> <div className="mt-14 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.length === 0 ? ( {posts.map((p) => (
<div className="rounded-2xl border border-dashed border-[var(--border)] bg-[var(--navy-50)]/40 p-12 text-center"> <article
<p className="text-sm text-[var(--muted)]"> key={p.$id}
Henüz yayınlanmış yazı yok. className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg"
</p> >
</div> <Link href={`/blog/${p.slug}`}>
) : ( <div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> {p.cover_image ? (
{posts.map((p) => ( <Image
<article src={p.cover_image}
key={p.$id} alt={p.title}
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-lg" fill
> sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
<Link href={`/blog/${p.slug}`}> className="object-cover transition group-hover:scale-105"
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]"> />
{p.cover_image ? ( ) : (
<Image <div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
src={p.cover_image} {p.title.charAt(0)}
alt={p.title}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
className="object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-3xl font-bold text-[var(--navy)]/30">
{p.title.charAt(0)}
</div>
)}
</div> </div>
<div className="p-6"> )}
<p className="flex items-center gap-1.5 text-xs text-[var(--muted)]"> </div>
<Calendar className="size-3.5" /> <div className="p-6">
{p.published_at <p className="flex items-center gap-1.5 text-xs text-[var(--muted)]">
? new Date(p.published_at).toLocaleDateString("tr-TR") <Calendar className="size-3.5" />
: "—"} {p.published_at
</p> ? new Date(p.published_at).toLocaleDateString("tr-TR")
<h3 className="mt-2 text-lg font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]"> : "—"}
{p.title} </p>
</h3> <h3 className="mt-2 text-lg font-semibold text-[var(--navy)] group-hover:text-[var(--sky-600)]">
{p.excerpt && ( {p.title}
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3"> </h3>
{p.excerpt} {p.excerpt && (
</p> <p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
)} {p.excerpt}
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--sky-600)]"> </p>
Devamını oku <ArrowRight className="size-3.5" /> )}
</span> <span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--sky-600)]">
</div> Devamını oku <ArrowRight className="size-3.5" />
</Link> </span>
</article> </div>
))} </Link>
</div> </article>
)} ))}
</div> </div>
</div> </div>
); );
+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>
);
}
+101 -54
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,48 +105,57 @@ 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>
</section> </section>
{team.length > 0 && ( {team.length > 0 && (
<section className="border-y border-[var(--border)] bg-[var(--navy-50)]/40 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="Ekip" eyebrow={teamEyebrow}
title="İşi yapan insanları tanıyın" title={teamTitle}
description="Sizin projenizde birebir çalışacak ekip — geliştirici, tasarımcı ve proje yöneticileri." description={teamDescription}
/> />
<div className="mt-12"> <div className="mt-14">
<TeamGrid members={team} /> <TeamGrid members={team} />
</div> </div>
</div> </div>
</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>
</> </>
); );
} }
+14 -84
View File
@@ -1,15 +1,15 @@
import Image from "next/image";
import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, ArrowRight, CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { marked } from "marked"; import { renderContent } from "@/lib/content-render";
import { getServiceBySlug, listProjects } from "@/lib/data"; import { getServiceBySlug, getSiteSettings, listProjects } from "@/lib/data";
import { buildMetadata } from "@/lib/seo"; import { buildMetadata } from "@/lib/seo";
import { Icon } from "@/components/icon";
import { ProjectsGrid } from "@/components/projects-grid"; import { ProjectsGrid } from "@/components/projects-grid";
import { SectionTitle } from "@/components/section-title"; 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 { 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({
@@ -50,64 +50,21 @@ export default async function ServiceDetailPage({
const service = await getServiceBySlug(slug); const service = await getServiceBySlug(slug);
if (!service) notFound(); if (!service) notFound();
const [relatedProjects] = await Promise.all([ const [relatedProjects, settings] = await Promise.all([
listProjects({ serviceSlug: slug, limit: 6 }), listProjects({ serviceSlug: slug, limit: 6 }),
getSiteSettings(),
]); ]);
const faqItems = parseFaq(service.faq); const faqItems = parseFaq(service.faq);
const html = service.content const html = renderContent(service.content);
? (marked.parse(service.content, { async: false }) as string)
: "";
return ( return (
<> <>
<section className="relative overflow-hidden border-b border-[var(--border)]"> <ServiceLd service={service} settings={settings} />
<div className="absolute inset-0 hero-grid opacity-50" aria-hidden /> <FaqLd items={faqItems} />
<div className="relative mx-auto max-w-7xl px-6 py-20"> <ServiceHero service={service} settings={settings} />
<Link
href="/hizmetler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm hizmetler
</Link>
<div className="mt-6 grid items-start gap-10 md:grid-cols-[1fr_auto]"> <div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[1.5fr_1fr]">
<div>
<div className="flex size-14 items-center justify-center rounded-2xl bg-[var(--navy-50)] text-[var(--navy)]">
<Icon name={service.icon} className="size-7" />
</div>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-[var(--navy)] sm:text-5xl">
{service.title}
</h1>
<p className="mt-4 max-w-2xl text-lg leading-relaxed text-[var(--muted)]">
{service.description}
</p>
<Link
href="/iletisim"
className="mt-8 inline-flex items-center gap-2 rounded-full bg-[var(--navy)] px-6 py-3 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]"
>
Bu hizmet için teklif al
<ArrowRight className="size-4" />
</Link>
</div>
{service.hero_image && (
<div className="relative hidden aspect-square w-64 overflow-hidden rounded-2xl md:block lg:w-80">
<Image
src={service.hero_image}
alt={service.title}
fill
sizes="320px"
className="object-cover"
priority
/>
</div>
)}
</div>
</div>
</section>
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 lg:grid-cols-[2fr_1fr]">
<div> <div>
{service.features && service.features.length > 0 && ( {service.features && service.features.length > 0 && (
<section className="mb-12"> <section className="mb-12">
@@ -147,34 +104,7 @@ export default async function ServiceDetailPage({
)} )}
</div> </div>
<aside className="space-y-4 lg:sticky lg:top-24 lg:self-start"> <ServiceSidebar currentSlug={slug} />
<div className="rounded-2xl border border-[var(--border)] bg-[var(--navy)] p-6 text-white">
<h3 className="text-base font-semibold">Bir proje mi var?</h3>
<p className="mt-2 text-sm text-white/70">
İhtiyacınızı anlatın, size en uygun çözümü hep birlikte planlayalım.
</p>
<Link
href="/iletisim"
className="mt-4 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-medium text-[var(--navy)] transition hover:bg-[var(--sky-50)]"
>
Bize ulaşın
<ArrowRight className="size-3.5" />
</Link>
</div>
<div className="rounded-2xl border border-[var(--border)] bg-white p-6">
<h3 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted)]">
Diğer hizmetler
</h3>
<Link
href="/hizmetler"
className="mt-3 inline-flex items-center gap-1 text-sm text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümünü gör
<ArrowRight className="size-3.5" />
</Link>
</div>
</aside>
</div> </div>
{relatedProjects.length > 0 && ( {relatedProjects.length > 0 && (
+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} />
+27 -6
View File
@@ -3,10 +3,11 @@ import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowLeft, Building2, Calendar, Clock, ExternalLink, Tag } from "lucide-react"; import { ArrowLeft, Building2, Calendar, Clock, ExternalLink, Tag } from "lucide-react";
import { marked } from "marked"; 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";
@@ -54,9 +55,7 @@ export default async function ProjectDetailPage({
const project = await getProjectBySlug(slug); const project = await getProjectBySlug(slug);
if (!project) notFound(); if (!project) notFound();
const html = project.content const html = renderContent(project.content);
? (marked.parse(project.content, { async: false }) as string)
: "";
const metrics = parseMetrics(project.metrics); const metrics = parseMetrics(project.metrics);
@@ -76,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
@@ -85,7 +85,11 @@ export default async function ProjectDetailPage({
<ArrowLeft className="size-3.5" /> Tüm projeler <ArrowLeft className="size-3.5" /> Tüm projeler
</Link> </Link>
<div className="mt-6 grid items-start gap-10 lg:grid-cols-[1.4fr_1fr]"> <div
className={`mt-6 grid items-start gap-10 ${
meta.length >= 2 ? "lg:grid-cols-[1.4fr_1fr]" : ""
}`}
>
<div> <div>
{project.category && ( {project.category && (
<span className="inline-flex rounded-full bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]"> <span className="inline-flex rounded-full bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]">
@@ -130,7 +134,8 @@ export default async function ProjectDetailPage({
)} )}
</div> </div>
{meta.length > 0 && ( {/* Meta tablo sadece 2+ alan dolu ise gösterilir — yarı boş card görünmesin */}
{meta.length >= 2 && (
<dl className="grid grid-cols-2 gap-4 rounded-2xl border border-[var(--border)] bg-white p-6"> <dl className="grid grid-cols-2 gap-4 rounded-2xl border border-[var(--border)] bg-white p-6">
{meta.map((m) => ( {meta.map((m) => (
<div key={m.label}> <div key={m.label}>
@@ -147,6 +152,22 @@ export default async function ProjectDetailPage({
)} )}
</div> </div>
{/* Tek-iki meta varsa daha kompakt inline strip olarak gösterilir */}
{meta.length > 0 && meta.length < 2 && (
<div className="mt-6 flex flex-wrap gap-3">
{meta.map((m) => (
<span
key={m.label}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-white px-3 py-1.5 text-xs text-[var(--muted)]"
>
{m.icon}
<span className="font-medium text-[var(--navy)]">{m.label}:</span>{" "}
{m.value}
</span>
))}
</div>
)}
{project.image_url && ( {project.image_url && (
<div className="relative mt-10 aspect-video overflow-hidden rounded-2xl"> <div className="relative mt-10 aspect-video overflow-hidden rounded-2xl">
<Image <Image
+2 -4
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowRight, ArrowLeft, CheckCircle2 } from "lucide-react"; import { ArrowRight, ArrowLeft, CheckCircle2 } from "lucide-react";
import { marked } from "marked"; import { renderContent } from "@/lib/content-render";
import { import {
getIndustryBySlug, getIndustryBySlug,
listProjects, listProjects,
@@ -65,9 +65,7 @@ export default async function IndustryPage({
]); ]);
const faqItems = parseFaq(industry.faq); const faqItems = parseFaq(industry.faq);
const html = industry.content const html = renderContent(industry.content);
? (marked.parse(industry.content, { async: false }) as string)
: "";
return ( return (
<> <>
+14 -7
View File
@@ -9,6 +9,7 @@ import {
Textarea, Textarea,
} from "@/components/admin/form"; } from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveBlogPost } from "@/lib/admin-actions"; import { saveBlogPost } from "@/lib/admin-actions";
import type { BlogPostRow } from "@/lib/types"; import type { BlogPostRow } from "@/lib/types";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
@@ -75,13 +76,19 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
rows={3} rows={3}
placeholder="Liste/kart görünümünde gösterilecek kısa özet" placeholder="Liste/kart görünümünde gösterilecek kısa özet"
/> />
<Textarea <div>
label="İçerik (Markdown)" <span className="text-sm font-medium text-[var(--navy)]">
name="content" İçerik
defaultValue={post?.content} </span>
rows={14} <div className="mt-1.5">
placeholder={"# Başlık\n\nMarkdown desteklenir…"} <RichEditor
/> name="content"
defaultValue={post?.content}
placeholder="Yazıya başlayın… `/` ile başlık, görsel, liste ekleyin"
minHeight={500}
/>
</div>
</div>
<MediaPicker <MediaPicker
label="Kapak görseli" label="Kapak görseli"
name="cover_image" name="cover_image"
@@ -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>
);
}
+16 -8
View File
@@ -10,6 +10,7 @@ import {
Textarea, Textarea,
} from "@/components/admin/form"; } from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveService } from "@/lib/admin-actions"; import { saveService } from "@/lib/admin-actions";
import type { FaqItem, ServiceRow } from "@/lib/types"; import type { FaqItem, ServiceRow } from "@/lib/types";
@@ -90,14 +91,21 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
help="Listede ve anasayfa kartında gösterilir." help="Listede ve anasayfa kartında gösterilir."
/> />
<Textarea <div>
label="Detay içerik (Markdown)" <span className="text-sm font-medium text-[var(--navy)]">
name="content" Detay içerik
defaultValue={service?.content} </span>
rows={10} <div className="mt-1.5">
placeholder={"## Yaklaşım\n\nMarkdown desteklenir…"} <RichEditor
help="Hizmet detay sayfasında ana içerik olarak gösterilir." name="content"
/> defaultValue={service?.content}
placeholder="Hizmetin detaylarını anlatın… `/` ile blok ekleyin"
/>
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Hizmet detay sayfasında ana içerik olarak gösterilir.
</p>
</div>
<Textarea <Textarea
label="Özellikler" label="Özellikler"
+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>
);
}
+41 -12
View File
@@ -10,12 +10,16 @@ import {
Textarea, Textarea,
} from "@/components/admin/form"; } from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
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>
@@ -50,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
@@ -95,16 +119,21 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
rows={3} rows={3}
/> />
<Textarea <div>
label="Vaka çalışması içeriği (Markdown)" <span className="text-sm font-medium text-[var(--navy)]">
name="content" Vaka çalışması içeriği
defaultValue={project?.content} </span>
rows={12} <div className="mt-1.5">
placeholder={ <RichEditor
"## Müşteri\n\n## Problem\n\n## Çözüm\n\n## Sonuç" name="content"
} defaultValue={project?.content}
help="Proje detay sayfasında uzun anlatım olarak gösterilir." placeholder="Müşteri / Problem / Çözüm / Sonuç…"
/> />
</div>
<p className="mt-1 text-xs text-[var(--muted)]">
Proje detay sayfasında uzun anlatım olarak gösterilir.
</p>
</div>
<MediaPicker <MediaPicker
label="Galeri görselleri" label="Galeri görselleri"
+13 -7
View File
@@ -10,6 +10,7 @@ import {
Textarea, Textarea,
} from "@/components/admin/form"; } from "@/components/admin/form";
import { MediaPicker } from "@/components/admin/media-picker"; import { MediaPicker } from "@/components/admin/media-picker";
import { RichEditor } from "@/components/admin/rich-editor";
import { saveIndustry } from "@/lib/admin-actions"; import { saveIndustry } from "@/lib/admin-actions";
import type { FaqItem, IndustryRow } from "@/lib/types"; import type { FaqItem, IndustryRow } from "@/lib/types";
@@ -75,13 +76,18 @@ export function IndustryForm({ row }: { row?: IndustryRow }) {
placeholder="Avukatlar için KVKK uyumlu, randevu sistemli, SEO odaklı modern web siteleri." placeholder="Avukatlar için KVKK uyumlu, randevu sistemli, SEO odaklı modern web siteleri."
/> />
<Textarea <div>
label="İçerik (Markdown)" <span className="text-sm font-medium text-[var(--navy)]">
name="content" İçerik
rows={10} </span>
defaultValue={row?.content} <div className="mt-1.5">
placeholder="## Sektör özellikleri\n\nAvukatlar için..." <RichEditor
/> name="content"
defaultValue={row?.content}
placeholder="Sektörünüz için içerik yazın…"
/>
</div>
</div>
<Textarea <Textarea
label="Özellikler" label="Özellikler"
+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

+44 -13
View File
@@ -1,16 +1,25 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #0a0f1c; --foreground: #0f172a;
--navy: #0f2c5c;
--navy-700: #15407f; /* Kovak brand palette (WP'den aktarıldı) */
--navy-50: #eef3fb; --navy: #043e8c; /* primary deep navy — CTA */
--sky: #4da3c7; --navy-700: #032d66;
--sky-600: #2f87ad; --navy-50: #eff6ff;
--sky-50: #ecf6fb; --sky: #3b82f6; /* primary bright blue — accent */
--muted: #5b6577; --sky-600: #2563eb;
--border: #e5e9f0; --sky-50: #dbeafe;
/* Dark hero gradient stops */
--hero-dark-1: #0f172a;
--hero-dark-2: #1e293b;
--hero-dark-3: #334155;
--muted: #64748b;
--border: #e2e8f0;
} }
@theme inline { @theme inline {
@@ -24,30 +33,52 @@
--color-sky-brand-50: var(--sky-50); --color-sky-brand-50: var(--sky-50);
--color-muted-foreground: var(--muted); --color-muted-foreground: var(--muted);
--color-border-soft: var(--border); --color-border-soft: var(--border);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-poppins);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-sans), Arial, Helvetica, sans-serif; font-family: var(--font-poppins), Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: #f1f5f9; }
::-webkit-scrollbar-thumb { background: var(--navy); border-radius: 3px; }
.hero-grid { .hero-grid {
background-image: background-image:
radial-gradient(circle at 1px 1px, rgba(15, 44, 92, 0.08) 1px, transparent 0); radial-gradient(circle at 1px 1px, rgba(4, 62, 140, 0.08) 1px, transparent 0);
background-size: 24px 24px; background-size: 24px 24px;
} }
.gradient-text { .gradient-text {
background: linear-gradient(90deg, var(--navy) 0%, var(--sky) 100%); background: linear-gradient(135deg, var(--navy) 0%, var(--sky) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
} }
.hero-dark {
background: linear-gradient(135deg, var(--hero-dark-1) 0%, var(--hero-dark-2) 50%, var(--hero-dark-3) 100%);
}
.hero-glow::before {
content: '';
position: absolute;
inset: -50%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
animation: hero-pulse 4s ease-in-out infinite;
pointer-events: none;
}
@keyframes hero-pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0.8; }
}
@keyframes float-slow { @keyframes float-slow {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); } 50% { transform: translateY(-12px); }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+7 -7
View File
@@ -6,9 +6,10 @@ import { ConsentInit } from "@/components/consent-init";
import { CookieBanner } from "@/components/cookie-banner"; import { CookieBanner } from "@/components/cookie-banner";
import { getSeoSettings } from "@/lib/data"; import { getSeoSettings } from "@/lib/data";
const geistSans = Geist({ const sans = Geist({
variable: "--font-geist-sans", variable: "--font-poppins",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
@@ -29,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({
@@ -46,12 +48,10 @@ export default async function RootLayout({
return ( return (
<html <html
lang="tr" lang="tr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${sans.variable} ${geistMono.variable} h-full antialiased`}
> >
<head>
<ConsentInit gtmId={gtmId} />
</head>
<body className="min-h-full flex flex-col bg-white text-[var(--foreground)]"> <body className="min-h-full flex flex-col bg-white text-[var(--foreground)]">
<ConsentInit gtmId={gtmId} />
{children} {children}
<CookieBanner /> <CookieBanner />
</body> </body>
+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)),
];
}
+574
View File
@@ -0,0 +1,574 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import { useEffect, useRef, useState } from "react";
import {
Bold,
Italic,
Underline as UnderlineIcon,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link as LinkIcon,
Image as ImageIcon,
Minus,
Undo,
Redo,
type LucideIcon,
} from "lucide-react";
interface BlockCommand {
id: string;
title: string;
description: string;
icon: LucideIcon;
command: (e: ReturnType<typeof useEditor> & object) => void;
}
const SLASH_BLOCKS: BlockCommand[] = [
{
id: "h1",
title: "Başlık 1",
description: "Büyük başlık",
icon: Heading1,
command: (e) => e.chain().focus().deleteRange({ from: 0, to: 0 }).toggleHeading({ level: 1 }).run(),
},
{
id: "h2",
title: "Başlık 2",
description: "Orta başlık",
icon: Heading2,
command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
id: "h3",
title: "Başlık 3",
description: "Küçük başlık",
icon: Heading3,
command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
id: "ul",
title: "Madde listesi",
description: "Bullet list",
icon: List,
command: (e) => e.chain().focus().toggleBulletList().run(),
},
{
id: "ol",
title: "Numaralı liste",
description: "Ordered list",
icon: ListOrdered,
command: (e) => e.chain().focus().toggleOrderedList().run(),
},
{
id: "quote",
title: "Alıntı",
description: "Blockquote",
icon: Quote,
command: (e) => e.chain().focus().toggleBlockquote().run(),
},
{
id: "code",
title: "Kod bloğu",
description: "Code block",
icon: Code,
command: (e) => e.chain().focus().toggleCodeBlock().run(),
},
{
id: "hr",
title: "Ayırıcı",
description: "Yatay çizgi",
icon: Minus,
command: (e) => e.chain().focus().setHorizontalRule().run(),
},
];
export function RichEditor({
name,
defaultValue,
placeholder = "İçeriği yazmaya başlayın… `/` ile blok seç",
minHeight = 400,
}: {
name: string;
defaultValue?: string | null;
placeholder?: string;
minHeight?: number;
}) {
const [html, setHtml] = useState<string>(defaultValue ?? "");
const [showSlash, setShowSlash] = useState(false);
const [slashQuery, setSlashQuery] = useState("");
const [slashIndex, setSlashIndex] = useState(0);
const [imageModal, setImageModal] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: { class: "text-[var(--sky-600)] underline" },
}),
Image.configure({
HTMLAttributes: { class: "rounded-xl my-4" },
}),
Placeholder.configure({ placeholder }),
],
content: defaultValue || "",
editorProps: {
attributes: {
class:
"prose prose-base max-w-none focus:outline-none px-6 py-6 min-h-[400px]",
},
},
onUpdate: ({ editor }) => {
setHtml(editor.getHTML());
},
immediatelyRender: false,
});
// Slash menu detection
useEffect(() => {
if (!editor) return;
const onUpdate = () => {
const { $from } = editor.state.selection;
const lineText = $from.parent.textContent;
const beforeCursor = lineText.slice(0, $from.parentOffset);
const match = beforeCursor.match(/\/(\w*)$/);
if (match) {
setShowSlash(true);
setSlashQuery(match[1].toLowerCase());
setSlashIndex(0);
} else {
setShowSlash(false);
}
};
editor.on("update", onUpdate);
editor.on("selectionUpdate", onUpdate);
return () => {
editor.off("update", onUpdate);
editor.off("selectionUpdate", onUpdate);
};
}, [editor]);
const filteredBlocks = SLASH_BLOCKS.filter(
(b) =>
!slashQuery ||
b.title.toLowerCase().includes(slashQuery) ||
b.id.includes(slashQuery),
);
// Slash menu keyboard
useEffect(() => {
if (!showSlash || !editor) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowSlash(false);
e.preventDefault();
return;
}
if (e.key === "ArrowDown") {
setSlashIndex((i) => Math.min(i + 1, filteredBlocks.length - 1));
e.preventDefault();
return;
}
if (e.key === "ArrowUp") {
setSlashIndex((i) => Math.max(i - 1, 0));
e.preventDefault();
return;
}
if (e.key === "Enter") {
const block = filteredBlocks[slashIndex];
if (block) {
e.preventDefault();
// Delete the slash + query first
const { $from } = editor.state.selection;
const from = $from.pos - (slashQuery.length + 1);
editor.chain().focus().deleteRange({ from, to: $from.pos }).run();
block.command(
editor as unknown as ReturnType<typeof useEditor> & object,
);
setShowSlash(false);
}
return;
}
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [showSlash, slashIndex, filteredBlocks, editor, slashQuery]);
if (!editor) return null;
return (
<div>
<input type="hidden" name={name} value={html} />
<div className="overflow-hidden rounded-2xl border border-[var(--border)] bg-white">
{/* Top toolbar — always visible, fixed */}
<div className="flex flex-wrap items-center gap-0.5 border-b border-[var(--border)] bg-[var(--navy-50)]/40 p-2">
<ToolbarButton
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
icon={Bold}
label="Kalın (⌘B)"
/>
<ToolbarButton
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
icon={Italic}
label="İtalik (⌘I)"
/>
<ToolbarButton
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
icon={UnderlineIcon}
label="Altı çizili"
/>
<Divider />
<ToolbarButton
active={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
icon={Heading1}
label="Başlık 1"
/>
<ToolbarButton
active={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
icon={Heading2}
label="Başlık 2"
/>
<ToolbarButton
active={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
icon={Heading3}
label="Başlık 3"
/>
<Divider />
<ToolbarButton
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
icon={List}
label="Madde listesi"
/>
<ToolbarButton
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
icon={ListOrdered}
label="Numaralı liste"
/>
<ToolbarButton
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
icon={Quote}
label="Alıntı"
/>
<ToolbarButton
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
icon={Code}
label="Kod"
/>
<Divider />
<ToolbarButton
onClick={() => {
const previousUrl = editor.getAttributes("link").href;
const url = window.prompt("URL", previousUrl ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}}
active={editor.isActive("link")}
icon={LinkIcon}
label="Bağlantı"
/>
<ToolbarButton
onClick={() => setImageModal(true)}
icon={ImageIcon}
label="Görsel ekle"
/>
<Divider />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
icon={Undo}
label="Geri al"
/>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
icon={Redo}
label="İleri al"
/>
</div>
<div className="relative">
<EditorContent editor={editor} style={{ minHeight }} />
{showSlash && filteredBlocks.length > 0 && (
<div className="absolute left-6 top-12 z-10 w-72 rounded-xl border border-[var(--border)] bg-white p-1 shadow-xl">
<p className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted)]">
Blok seç
</p>
{filteredBlocks.map((b, i) => {
const Icon = b.icon;
return (
<button
key={b.id}
type="button"
onMouseEnter={() => setSlashIndex(i)}
onClick={() => {
const { $from } = editor.state.selection;
const from = $from.pos - (slashQuery.length + 1);
editor
.chain()
.focus()
.deleteRange({ from, to: $from.pos })
.run();
b.command(
editor as unknown as ReturnType<typeof useEditor> &
object,
);
setShowSlash(false);
}}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
i === slashIndex
? "bg-[var(--navy-50)]"
: "hover:bg-[var(--navy-50)]/60"
}`}
>
<div className="flex size-8 items-center justify-center rounded-md bg-white text-[var(--navy)]">
<Icon className="size-4" />
</div>
<div>
<p className="font-medium text-[var(--navy)]">{b.title}</p>
<p className="text-[11px] text-[var(--muted)]">
{b.description}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* Footer info */}
<p className="mt-2 text-xs text-[var(--muted)]">
/ ile blok ekle Seçili metin için araç çubuğu otomatik açılır
</p>
{imageModal && (
<ImagePickerModal
onClose={() => setImageModal(false)}
onPick={(url) => {
editor.chain().focus().setImage({ src: url }).run();
setImageModal(false);
}}
/>
)}
</div>
);
}
function ToolbarButton({
icon: Icon,
label,
active,
onClick,
}: {
icon: LucideIcon;
label: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={`flex size-8 items-center justify-center rounded-md transition ${
active
? "bg-[var(--navy)] text-white"
: "text-[var(--muted)] hover:bg-white hover:text-[var(--navy)]"
}`}
>
<Icon className="size-4" />
</button>
);
}
function Divider() {
return <div className="mx-1 h-5 w-px bg-[var(--border)]" />;
}
// ─── Image picker — uses media library API ───────────────
interface MediaFile {
id: string;
name: string;
url: string;
}
function ImagePickerModal({
onClose,
onPick,
}: {
onClose: () => void;
onPick: (url: string) => void;
}) {
const [files, setFiles] = useState<MediaFile[] | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch("/api/admin/media/list")
.then((r) => r.json())
.then((d) => setFiles(d.files || []))
.catch(() => setFiles([]));
}, []);
function handleFile(file: File) {
setUploading(true);
setProgress(0);
const fd = new FormData();
fd.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/admin/media/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
if (data.url) {
onPick(data.url);
}
} catch {
/* ignore */
}
setUploading(false);
};
xhr.onerror = () => {
setUploading(false);
};
xhr.send(fd);
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="w-full max-w-3xl rounded-2xl bg-white p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-[var(--navy)]">
Görsel ekle
</h3>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="rounded-full bg-[var(--navy)] px-4 py-2 text-xs font-medium text-white hover:bg-[var(--navy-700)] disabled:opacity-60"
>
{uploading ? `Yükleniyor… ${progress}%` : "Yeni görsel yükle"}
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
e.target.value = "";
}}
/>
</div>
{uploading && (
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-[var(--navy-50)]">
<div
className="h-full bg-[var(--sky)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
)}
<div className="mt-5 max-h-[60vh] overflow-y-auto">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
Kütüphane
</p>
{!files ? (
<p className="py-8 text-center text-sm text-[var(--muted)]">
Yükleniyor
</p>
) : files.length === 0 ? (
<p className="py-8 text-center text-sm text-[var(--muted)]">
Kütüphane boş. Yeni görsel yükleyin.
</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-5">
{files.map((f) => (
<button
key={f.id}
type="button"
onClick={() => onPick(f.url)}
className="aspect-square overflow-hidden rounded-lg border border-[var(--border)] transition hover:border-[var(--sky)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={f.url}
alt={f.name}
className="size-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
)}
</div>
<div className="mt-4 text-right">
<button
type="button"
onClick={onClose}
className="rounded-full border border-[var(--border)] bg-white px-4 py-2 text-xs font-medium text-[var(--muted)] hover:text-[var(--navy)]"
>
Kapat
</button>
</div>
</div>
</div>
);
}
+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 },
+11 -30
View File
@@ -1,42 +1,23 @@
import Script from "next/script";
/** /**
* Google Consent Mode v2 — defaults set to "denied" before any tag loads. * Google Consent Mode v2 — default consent state denied'da.
* After the user makes a choice, CookieBanner calls gtag('consent','update', ...). * Consent default ayarı `/public/consent-default.js`'den senkron yüklenir
* (script src ile — React'ın inline script warning'inden kaçınmak için).
* *
* GTM/GA inject sadece site_settings.gtm_id doluysa yapılır. * GTM script'i sadece gtm_id varsa yüklenir, aynı src pattern'i kullanır.
*/ */
export function ConsentInit({ gtmId }: { gtmId?: string | null }) { export function ConsentInit({ gtmId }: { gtmId?: string | null }) {
return ( return (
<> <>
<Script id="consent-default" strategy="beforeInteractive"> {/* Default consent — synchronous, runs before any other JS */}
{` <script src="/consent-default.js" />
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted',
wait_for_update: 500,
});
`}
</Script>
{/* GTM */}
{gtmId && ( {gtmId && (
<> <>
<Script id="gtm-script" strategy="afterInteractive"> <script
{` async
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'}); src={`https://www.googletagmanager.com/gtm.js?id=${gtmId}`}
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:''; />
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');
`}
</Script>
<noscript> <noscript>
<iframe <iframe
src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`} src={`https://www.googletagmanager.com/ns.html?id=${gtmId}`}
+188
View File
@@ -0,0 +1,188 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, MessageCircle, Phone, Tag } from "lucide-react";
import {
getSiteSettings,
listPublishedPosts,
listServices,
} from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
interface Props {
/**
* Hangi yazıyı/sayfayı görüntülüyoruz — listede gizlemek için.
*/
currentSlug?: string;
}
export async function ContentSidebar({ currentSlug }: Props) {
const [settings, services, posts] = await Promise.all([
getSiteSettings(),
listServices(),
listPublishedPosts({ limit: 5 }),
]);
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const waCleaned = phoneRaw.replace(/[^\d]/g, "");
const waMessage = settings?.whatsapp_message ?? "";
const waHref = `https://wa.me/${waCleaned}${
waMessage ? `?text=${encodeURIComponent(waMessage)}` : ""
}`;
const otherPosts = posts.filter((p) => p.slug !== currentSlug).slice(0, 4);
// Etiket sayımı (tüm yazılardan toplu)
const tagCount = new Map<string, number>();
posts.forEach((p) =>
(p.tags ?? []).forEach((t) =>
tagCount.set(t, (tagCount.get(t) ?? 0) + 1),
),
);
const topTags = Array.from(tagCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
return (
<aside className="space-y-6 lg:sticky lg:top-24 lg:self-start">
{/* 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">Projeniz mi var?</h3>
<p className="mt-2 text-sm text-white/80">
Ücretsiz keşif görüşmesi için bizi arayın veya WhatsApp'tan yazın.
</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>
{/* Diğer yazılar */}
{otherPosts.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)]">
Son Yazılar
</h3>
<Link
href="/blog"
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümü
</Link>
</div>
<ul className="mt-4 space-y-3">
{otherPosts.map((p) => (
<li key={p.$id}>
<Link
href={`/blog/${p.slug}`}
className="group flex gap-3"
>
<div className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-[var(--navy-50)]">
{p.cover_image ? (
<Image
src={p.cover_image}
alt={p.title}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-lg font-bold text-[var(--navy)]/30">
{p.title.charAt(0)}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 text-sm font-medium leading-snug text-[var(--navy)] transition-colors group-hover:text-[var(--sky-600)]">
{p.title}
</p>
{p.published_at && (
<p className="mt-1 text-[11px] text-[var(--muted)]">
{new Date(p.published_at).toLocaleDateString("tr-TR")}
</p>
)}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Etiketler */}
{topTags.length > 0 && (
<div className="rounded-2xl border border-[var(--border)] bg-white p-5">
<h3 className="flex items-center gap-1.5 text-sm font-bold uppercase tracking-wider text-[var(--navy)]">
<Tag className="size-3.5" />
Etiketler
</h3>
<div className="mt-3 flex flex-wrap gap-1.5">
{topTags.map(([tag]) => (
<span
key={tag}
className="rounded-md bg-[var(--navy-50)] px-2.5 py-1 text-xs font-medium text-[var(--navy-700)] hover:bg-[var(--sky-50)]"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Hizmetler */}
{services.length > 0 && (
<div className="rounded-2xl border border-[var(--border)] bg-white p-5">
<h3 className="text-sm font-bold uppercase tracking-wider text-[var(--navy)]">
Hizmetlerimiz
</h3>
<ul className="mt-3 space-y-1.5">
{services.slice(0, 6).map((s) => (
<li key={s.slug}>
<Link
href={`/hizmetler/${s.slug}`}
className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm text-[var(--foreground)] transition hover:bg-[var(--navy-50)] hover:text-[var(--navy)]"
>
<span>{s.title}</span>
<ArrowRight className="size-3 text-[var(--muted)]" />
</Link>
</li>
))}
</ul>
</div>
)}
{/* Site analizi lead magnet */}
<div className="rounded-2xl border border-[var(--sky)]/30 bg-[var(--sky-50)]/50 p-5">
<h3 className="text-sm font-bold text-[var(--navy)]">
Ücretsiz Site Analizi
</h3>
<p className="mt-2 text-xs leading-relaxed text-[var(--muted)]">
Sitenizin SEO, hız ve dönüşüm performansını ücretsiz değerlendirelim.
24 saat içinde rapor e-postanızda.
</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>
);
}
+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>
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useEffect } from "react";
/**
* WordPress sitesindeki "floating pill" header efekti.
* Scroll'da header küçülür, kenar yuvarlanır, gölge alır.
* Mobilde scroll-down'da gizlenir, scroll-up'ta görünür.
*/
export function HeaderScrollEffect() {
useEffect(() => {
const wrap = document.getElementById("floating-header-wrap");
const pillWrap = document.getElementById("header-pill-wrap");
const header = document.getElementById("site-header");
const navBar = document.getElementById("header-nav-bar");
if (!wrap || !pillWrap || !header || !navBar) return;
let lastY = 0;
let ticking = false;
wrap.style.transition = "transform 0.3s ease, opacity 0.3s ease";
function applyScroll() {
const y = window.scrollY;
const mobile = window.innerWidth < 1024;
const scrolled = y > 10;
const goingDown = y > lastY;
if (!mobile && pillWrap && header && navBar && wrap) {
wrap.style.transform = "";
wrap.style.opacity = "";
// Pill mode toggle: data-pill-hide gizlenir, data-pill-show görünür
const hidables = document.querySelectorAll<HTMLElement>(
'[data-pill-hide="true"]',
);
const showables = document.querySelectorAll<HTMLElement>(
'[data-pill-show="true"]',
);
if (scrolled) {
pillWrap.style.padding = "12px 16px 0";
header.style.maxWidth = "1100px";
header.style.borderRadius = "1rem";
header.style.border = "1px solid #e5e7eb";
header.style.boxShadow = "0 8px 24px rgba(0,0,0,0.08)";
navBar.style.height = "52px";
navBar.style.padding = "0 1.25rem";
hidables.forEach((el) => {
el.style.display = "none";
});
showables.forEach((el) => {
el.style.display = "inline-flex";
});
} else {
pillWrap.style.padding = "";
header.style.maxWidth = "";
header.style.borderRadius = "";
header.style.border = "";
header.style.boxShadow = "";
navBar.style.height = "";
navBar.style.padding = "";
hidables.forEach((el) => {
el.style.display = "";
});
showables.forEach((el) => {
el.style.display = "none";
});
}
}
if (mobile && wrap && y > 80) {
if (goingDown) {
wrap.style.transform = "translateY(-110%)";
wrap.style.opacity = "0";
} else {
wrap.style.transform = "";
wrap.style.opacity = "";
}
} else if (mobile && wrap) {
wrap.style.transform = "";
wrap.style.opacity = "";
}
lastY = y;
ticking = false;
}
function onScroll() {
if (!ticking) {
requestAnimationFrame(applyScroll);
ticking = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", applyScroll);
applyScroll();
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", applyScroll);
};
}, []);
return null;
}
+174 -39
View File
@@ -1,53 +1,188 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Phone } from "lucide-react"; import { ChevronDown, Phone } from "lucide-react";
import { getSiteSettings } 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";
const nav = [ import type { ServiceRow } from "@/lib/types";
{ href: "/", label: "Anasayfa" }, import { HeaderScrollEffect } from "@/components/header-scroll";
{ href: "/hizmetler", label: "Hizmetler" }, import { MobileMenu } from "@/components/mobile-menu";
{ href: "/projeler", label: "Projeler" },
{ href: "/blog", label: "Blog" },
{ href: "/hakkimizda", label: "Hakkımızda" },
{ href: "/iletisim", label: "İletişim" },
];
export async function Header() { export async function Header() {
const settings = await getSiteSettings(); const [settings, services] = await Promise.all([
getSiteSettings(),
listServices(),
]);
const phone = settings?.contact_phone ?? siteConfig.contact.phone; const phone = settings?.contact_phone ?? siteConfig.contact.phone;
const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw; const phoneRaw = settings?.contact_phone_raw ?? siteConfig.contact.phoneRaw;
// Mega menu groups
const webServices = services.filter((s) =>
["web-tasarim", "e-ticaret", "mobil-uygulama", "yazilim-gelistirme", "crm-sistemleri"].includes(s.slug),
);
const marketingServices = services.filter((s) =>
["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 (
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-white/90 backdrop-blur"> <>
<div className="mx-auto flex max-w-7xl items-center justify-between gap-6 px-6 py-3"> <HeaderScrollEffect />
<Link href="/" className="flex items-center gap-3"> <div className="sticky top-0 z-50 w-full" id="floating-header-wrap">
<Image src="/logo.png" alt={siteConfig.name} width={44} height={44} priority /> <div id="header-pill-wrap" className="transition-all duration-300 ease-out">
<span className="hidden text-base font-semibold tracking-tight text-[var(--navy)] sm:block"> <header
{siteConfig.name} id="site-header"
</span> className="mx-auto w-full border-b border-gray-100 bg-white/95 backdrop-blur-lg transition-all duration-300 ease-out"
</Link> >
<nav
<nav className="hidden items-center gap-8 md:flex"> id="header-nav-bar"
{nav.map((item) => ( className="flex h-14 items-center justify-between px-6 transition-all duration-300 ease-out lg:grid lg:h-16 lg:grid-cols-[1fr_auto_1fr] lg:px-8"
<Link
key={item.href}
href={item.href}
className="text-sm font-medium text-[var(--muted)] transition hover:text-[var(--navy)]"
> >
{item.label} {/* Col 1 — Logo */}
</Link> <Link href="/" className="flex items-center gap-2.5">
))} <Image
</nav> src="/logo.png"
alt={siteConfig.name}
width={40}
height={40}
priority
className="h-9 w-9 object-contain"
/>
<span className="hidden text-sm font-semibold tracking-tight text-[var(--navy)] sm:block">
{siteConfig.name}
</span>
</Link>
<a {/* Col 2 — Desktop nav (sıra admin'den yönetilir) */}
href={`tel:${phoneRaw}`} <div className="hidden items-center gap-0.5 lg:flex">
className="hidden items-center gap-2 rounded-full bg-[var(--navy)] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[var(--navy-700)] sm:inline-flex" {navItems.map((item) =>
> item.mega ? (
<Phone className="size-4" /> <ServicesMegaMenu
{phone} key={item.key}
</a> label={item.label}
webServices={webServices}
marketingServices={marketingServices}
/>
) : (
<Link
key={item.key}
href={item.href}
className="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-lg px-3.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
{item.label}
</Link>
),
)}
</div>
{/* Col 3 — CTA */}
<div className="flex items-center justify-end gap-2">
{/* Phone — full mode (XL) */}
<a
href={`tel:${phoneRaw}`}
data-pill-hide="true"
className="hidden h-9 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 xl:inline-flex"
aria-label={phone}
>
<Phone className="size-3.5" />
<span>{phone}</span>
</a>
{/* "Ara" — pill mode'da görünür, kompakt */}
<a
href={`tel:${phoneRaw}`}
data-pill-show="true"
className="hidden h-9 items-center gap-1.5 rounded-lg border border-gray-200 px-3 text-sm font-medium text-gray-700 transition-colors hover:border-[var(--navy)] hover:text-[var(--navy)]"
aria-label={`${phone} - Ara`}
style={{ display: "none" }}
>
<Phone className="size-3.5" />
<span>Ara</span>
</a>
<Link
href="/iletisim"
className="hidden h-9 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--navy)] px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--navy-700)] sm:inline-flex"
>
Ücretsiz Teklif
</Link>
{/* Mobil menü (hamburger) — sadece < lg */}
<MobileMenu
navItems={navItems}
services={services.map((s) => ({ slug: s.slug, title: s.title }))}
phone={phone}
phoneRaw={phoneRaw}
/>
</div>
</nav>
</header>
</div>
</div> </div>
</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>
); );
} }
+79 -66
View File
@@ -1,11 +1,11 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react"; import { ArrowRight, Sparkles } from "lucide-react";
import type { SiteSettingsRow, StatItem } from "@/lib/types"; import type { SiteSettingsRow, StatItem } from "@/lib/types";
const DEFAULT_STATS: StatItem[] = [ const DEFAULT_STATS: StatItem[] = [
{ value: "50+", label: "Tamamlanan proje" }, { value: "150+", label: "Tamamlanan proje" },
{ value: "10+", label: "Yıllık deneyim" }, { value: "50+", label: "Aktif müşteri" },
{ value: "100%", label: "Memnuniyet" },
{ value: "24/7", label: "Teknik destek" }, { value: "24/7", label: "Teknik destek" },
]; ];
@@ -24,84 +24,97 @@ function parseStats(items?: string[] | null): StatItem[] {
} }
export function Hero({ settings }: { settings?: SiteSettingsRow | null }) { export function Hero({ settings }: { settings?: SiteSettingsRow | null }) {
const badge = settings?.hero_badge ?? "Kocaeli'nin teknoloji ajansı"; const badge =
settings?.hero_badge ?? "Kocaeli Web Tasarım & Yazılım Ajansı";
const title = const title =
settings?.hero_title ?? "Fikirden ürüne tek bir partner ile yola çıkın"; settings?.hero_title ??
"Kocaeli Web Tasarım ve Yazılım Ajansı";
const subtitle = const subtitle =
settings?.hero_subtitle ?? settings?.hero_subtitle ??
"Web, mobil ve CRM çözümlerinde uçtan uca geliştirme. Markanıza özel tasarım, ölçeklenebilir altyapı ve uzun vadeli destek."; "Kocaeli ve İzmit'te profesyonel web tasarım, SEO optimizasyonu ve özel yazılım çözümleri. 2015'ten bu yana işletmelere dijital dönüşümde rehberlik ediyoruz.";
const primaryLabel = const primaryLabel = settings?.hero_cta_primary_label ?? "Ücretsiz Teklif Al";
settings?.hero_cta_primary_label ?? "Proje görüşmesi başlat";
const primaryHref = settings?.hero_cta_primary_href ?? "/iletisim"; const primaryHref = settings?.hero_cta_primary_href ?? "/iletisim";
const secondaryLabel = const secondaryLabel =
settings?.hero_cta_secondary_label ?? "Hizmetlerimizi inceleyin"; settings?.hero_cta_secondary_label ?? "Hizmetlerimizi İnceleyin";
const secondaryHref = settings?.hero_cta_secondary_href ?? "/hizmetler"; const secondaryHref = settings?.hero_cta_secondary_href ?? "/hizmetler";
const stats = parseStats(settings?.hero_stats); const stats = parseStats(settings?.hero_stats);
return ( return (
<section className="relative overflow-hidden"> <section className="hero-dark hero-glow relative overflow-hidden">
<div className="absolute inset-0 hero-grid opacity-60" aria-hidden /> {/* Grid pattern overlay */}
<div className="absolute -right-32 top-1/2 -z-0 size-[520px] -translate-y-1/2 rounded-full bg-gradient-to-br from-[var(--sky)]/30 to-[var(--navy)]/0 blur-3xl" aria-hidden /> <div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, white 1px, transparent 0)",
backgroundSize: "32px 32px",
}}
aria-hidden
/>
<div className="relative mx-auto grid max-w-7xl items-center gap-12 px-6 py-24 md:grid-cols-2 md:py-32"> <div className="relative mx-auto max-w-5xl px-6 py-24 text-center md:py-32">
<div> <span className="inline-flex items-center gap-2 rounded-full border border-blue-400/30 bg-[#043e8c]/30 px-4 py-1.5 text-sm font-medium text-blue-300">
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--sky)]/30 bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]"> <Sparkles className="size-4" />
<Sparkles className="size-3.5" /> {badge}
{badge} </span>
</span>
<h1 className="mt-6 text-4xl font-bold leading-tight tracking-tight text-[var(--navy)] sm:text-5xl md:text-6xl"> <h1 className="mt-6 text-4xl font-extrabold leading-[1.1] tracking-tight text-white sm:text-5xl md:text-6xl">
{title} {title.split(" ").map((word, i, arr) => {
</h1> // Highlight key words in blue (Web Tasarım, Yazılım)
const highlight =
word.toLowerCase().includes("web") ||
word.toLowerCase().includes("tasarım") ||
word.toLowerCase().includes("yazılım") ||
word.toLowerCase().includes("ajansı");
return (
<span key={i}>
<span className={highlight ? "text-blue-400" : undefined}>
{word}
</span>
{i < arr.length - 1 && " "}
</span>
);
})}
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]"> <p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-slate-300 sm:text-lg">
{subtitle} {subtitle}
</p> </p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row"> <div className="mt-10 flex flex-col justify-center gap-3 sm:flex-row">
<Link <Link
href={primaryHref} href={primaryHref}
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--navy)] px-6 py-3 text-sm font-medium text-white transition hover:bg-[var(--navy-700)]" className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--sky)] px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-[var(--sky)]/30 transition hover:-translate-y-0.5 hover:bg-[var(--sky-600)] hover:shadow-xl hover:shadow-[var(--sky)]/40"
> >
{primaryLabel} {primaryLabel}
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</Link> </Link>
<Link <Link
href={secondaryHref} href={secondaryHref}
className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--border)] bg-white px-6 py-3 text-sm font-medium text-[var(--navy)] transition hover:border-[var(--navy)]" className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/5 px-7 py-3.5 text-sm font-semibold text-white backdrop-blur transition hover:border-white/40 hover:bg-white/10"
> >
{secondaryLabel} {secondaryLabel}
</Link> </Link>
</div>
{stats.length > 0 && (
<dl className="mt-12 grid max-w-md grid-cols-3 gap-6">
{stats.map((stat) => (
<div key={stat.label}>
<dt className="text-2xl font-bold text-[var(--navy)]">
{stat.value}
</dt>
<dd className="mt-1 text-xs text-[var(--muted)]">{stat.label}</dd>
</div>
))}
</dl>
)}
</div>
<div className="relative flex justify-center">
<div className="absolute inset-8 -z-10 rounded-full bg-gradient-to-br from-[var(--sky-50)] to-white blur-2xl" aria-hidden />
<div className="animate-float">
<Image
src="/logo.png"
alt="Kovak Yazılım"
width={420}
height={420}
priority
className="size-[320px] object-contain drop-shadow-[0_30px_50px_rgba(15,44,92,0.25)] md:size-[420px]"
/>
</div>
</div> </div>
</div> </div>
{/* Stats strip — pinned bottom of hero */}
{stats.length > 0 && (
<div className="relative border-t border-white/10 bg-black/20 backdrop-blur">
<div className="mx-auto grid max-w-6xl grid-cols-2 gap-4 px-6 py-8 sm:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<p className="text-3xl font-bold text-[var(--sky)] sm:text-4xl">
{stat.value}
</p>
<p className="mt-1 text-xs text-slate-400 sm:text-sm">
{stat.label}
</p>
</div>
))}
</div>
</div>
)}
</section> </section>
); );
} }
+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>
);
}
+82 -64
View File
@@ -3,6 +3,15 @@ import Link from "next/link";
import { ArrowUpRight, ExternalLink } from "lucide-react"; import { ArrowUpRight, ExternalLink } from "lucide-react";
import type { ProjectRow } from "@/lib/types"; import type { ProjectRow } from "@/lib/types";
const CATEGORY_COLORS: Record<string, string> = {
"Kurumsal Web Sitesi": "bg-[var(--navy)]",
"Klinik Web Sitesi": "bg-cyan-600",
"Portfolyo & SEO": "bg-violet-600",
"Web Tasarım": "bg-emerald-600",
"Özel Yazılım": "bg-sky-600",
"E-Ticaret": "bg-pink-600",
};
export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) { export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
if (projects.length === 0) { if (projects.length === 0) {
return ( return (
@@ -15,73 +24,82 @@ export function ProjectsGrid({ projects }: { projects: ProjectRow[] }) {
} }
return ( return (
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => ( {projects.map((p) => {
<article const tagColor = p.category && CATEGORY_COLORS[p.category]
key={p.$id} ? CATEGORY_COLORS[p.category]
className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-xl" : "bg-[var(--navy)]";
> return (
<Link href={`/projeler/${p.slug}`} className="block"> <article
<div className="relative aspect-video overflow-hidden bg-[var(--navy-50)]"> key={p.$id}
{p.image_url ? ( className="group overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl hover:shadow-[var(--navy)]/10"
<Image >
src={p.image_url} <Link href={`/projeler/${p.slug}`} className="block">
alt={p.title} <div className="relative aspect-[5/3] overflow-hidden bg-[var(--navy-50)]">
fill {p.image_url ? (
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" <Image
className="object-cover transition group-hover:scale-105" src={p.image_url}
/> alt={p.title}
) : ( fill
<div className="flex h-full items-center justify-center text-[var(--navy)]/30"> sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
<span className="text-5xl font-bold">{p.title.charAt(0)}</span> className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-[var(--navy)]/30">
<span className="text-5xl font-bold">{p.title.charAt(0)}</span>
</div>
)}
{/* Overlay gradient — WP stili */}
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
{p.category && (
<span
className={`absolute left-4 top-4 rounded-full ${tagColor} px-3 py-1 text-xs font-semibold text-white shadow-lg`}
>
{p.category}
</span>
)}
</div>
</Link>
<div className="p-6">
<div className="flex items-start justify-between gap-3">
<Link href={`/projeler/${p.slug}`} className="block">
<h3 className="text-lg font-bold text-[var(--navy)] transition-colors group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
</Link>
{p.live_url ? (
<a
href={p.live_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Projeyi canlı aç"
className="text-[var(--sky-600)] transition-colors hover:text-[var(--navy)]"
>
<ExternalLink className="size-4" />
</a>
) : (
<ArrowUpRight className="size-5 text-[var(--muted)] transition-colors group-hover:text-[var(--sky-600)]" />
)}
</div>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
{p.description}
</p>
{p.technologies && p.technologies.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{p.technologies.map((t) => (
<span
key={t}
className="rounded-md bg-[var(--navy-50)] px-2 py-0.5 text-xs font-medium text-[var(--navy-700)]"
>
{t}
</span>
))}
</div> </div>
)} )}
{p.category && (
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-medium text-[var(--navy)] shadow-sm">
{p.category}
</span>
)}
</div> </div>
</Link> </article>
<div className="p-6"> );
<div className="flex items-start justify-between gap-3"> })}
<Link href={`/projeler/${p.slug}`} className="block">
<h3 className="text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
{p.title}
</h3>
</Link>
{p.live_url ? (
<a
href={p.live_url}
target="_blank"
rel="noopener noreferrer"
aria-label="Projeyi canlı aç"
className="text-[var(--sky-600)] hover:text-[var(--navy)]"
>
<ExternalLink className="size-4" />
</a>
) : (
<ArrowUpRight className="size-5 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" />
)}
</div>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)] line-clamp-3">
{p.description}
</p>
{p.technologies && p.technologies.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{p.technologies.map((t) => (
<span
key={t}
className="rounded-md bg-[var(--navy-50)] px-2 py-0.5 text-xs text-[var(--navy-700)]"
>
{t}
</span>
))}
</div>
)}
</div>
</article>
))}
</div> </div>
); );
} }
+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 { ServiceRow, SiteSettingsRow } from "@/lib/types";
import { siteConfig } from "@/lib/site-config";
const QUICK_TRUST = [
"2-3 hafta teslim",
"1 yıl ücretsiz destek",
"İlk taslak ücretsiz",
"Yerel ekip — Kocaeli",
];
export function ServiceHero({
service,
settings,
}: {
service: ServiceRow;
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, ${service.title} hizmeti 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="/hizmetler"
className="inline-flex items-center gap-1 text-sm text-[var(--muted)] hover:text-[var(--navy)]"
>
<ArrowLeft className="size-3.5" /> Tüm hizmetler
</Link>
<div className="mt-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={service.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" />
Profesyonel hizmet
</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">{service.title}</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
{service.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">
{service.hero_image ? (
<div className="relative aspect-[4/5] overflow-hidden rounded-3xl shadow-2xl shadow-[var(--navy)]/10">
<Image
src={service.hero_image}
alt={service.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)]">
İlk tasarım taslağı ücretsiz
</p>
</div>
</div>
) : (
<DecorativeServiceCard service={service} />
)}
</div>
</div>
</div>
</section>
);
}
function DecorativeServiceCard({ service }: { service: ServiceRow }) {
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={service.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">
{service.title}
</p>
<p className="text-sm leading-relaxed text-white/60">
Sektörünüze özel, profesyonel çö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>
);
}
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { ArrowRight, MessageCircle, Phone, ShieldCheck } from "lucide-react";
import { Icon } from "@/components/icon";
import {
getSiteSettings,
listServices,
} from "@/lib/data";
import { siteConfig } from "@/lib/site-config";
import { QuickLeadForm } from "@/components/quick-lead-form";
export async function ServiceSidebar({
currentSlug,
}: {
currentSlug: string;
}) {
const [settings, services] = await Promise.all([
getSiteSettings(),
listServices(),
]);
const otherServices = services.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 hizmet 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>
İlk tasarım taslağı ücretsiz
</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 hizmetler — full list */}
{otherServices.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 hizmetler
</h3>
<Link
href="/hizmetler"
className="text-xs text-[var(--sky-600)] hover:text-[var(--navy)]"
>
Tümü
</Link>
</div>
<ul className="mt-4 space-y-1">
{otherServices.map((s) => (
<li key={s.slug}>
<Link
href={`/hizmetler/${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>
);
}
+26 -21
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,37 +10,43 @@ 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-4"> <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-6 transition hover:border-[var(--sky)]/40 hover:shadow-lg hover:shadow-[var(--sky)]/10" className="group relative overflow-hidden rounded-2xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-2 hover:border-[var(--sky)]/40 hover:shadow-2xl hover:shadow-[var(--navy)]/10"
> >
<div <ArrowUpRight className="absolute right-6 top-6 size-4 text-[var(--muted)] opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:text-[var(--sky-600)] group-hover:opacity-100" />
className="absolute -right-12 -top-12 size-32 rounded-full bg-[var(--sky-50)] opacity-0 transition group-hover:opacity-100"
aria-hidden {/* Gradient icon — WP'deki stil */}
/> <div className="flex size-14 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--sky)] to-purple-500 text-white shadow-lg shadow-[var(--sky)]/30 transition-transform duration-300 group-hover:scale-110">
<ArrowUpRight className="absolute right-5 top-5 size-4 text-[var(--muted)] transition group-hover:text-[var(--sky-600)]" /> <Icon name={s.icon} className="size-6" />
<div className="relative">
<div className="flex size-12 items-center justify-center rounded-xl bg-[var(--navy-50)] text-[var(--navy)]">
<Icon name={s.icon} className="size-6" />
</div>
<h3 className="mt-5 text-lg font-semibold text-[var(--navy)] transition group-hover:text-[var(--sky-600)]">
{s.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">
{s.description}
</p>
</div> </div>
<h3 className="mt-6 text-lg font-bold leading-tight text-[var(--navy)] transition-colors group-hover:text-[var(--sky-600)]">
{s.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-[var(--muted)]">
{s.description}
</p>
</Link> </Link>
))} ))}
</div> </div>
+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>
);
}
+60 -21
View File
@@ -2,51 +2,90 @@ import Image from "next/image";
import { LinkedinIcon } from "@/components/social-icons"; import { LinkedinIcon } from "@/components/social-icons";
import type { TeamMemberRow } from "@/lib/types"; import type { TeamMemberRow } from "@/lib/types";
const GRADIENTS = [
"from-[var(--navy)] to-blue-400",
"from-blue-400 to-cyan-400",
"from-violet-500 to-purple-500",
"from-sky-500 to-emerald-400",
];
function initials(name: string): string {
return name
.split(" ")
.map((s) => s[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
export function TeamGrid({ members }: { members: TeamMemberRow[] }) { export function TeamGrid({ members }: { members: TeamMemberRow[] }) {
if (members.length === 0) return null; if (members.length === 0) return null;
return ( return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="mx-auto grid max-w-3xl gap-8 sm:grid-cols-2">
{members.map((m) => ( {members.map((m, i) => (
<article <article
key={m.$id} key={m.$id}
className="overflow-hidden rounded-2xl border border-[var(--border)] bg-white transition hover:shadow-md" className="group rounded-3xl border border-[var(--border)] bg-white p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-[var(--navy)]/10"
> >
<div className="relative aspect-square overflow-hidden bg-gradient-to-br from-[var(--sky-50)] to-[var(--navy-50)]"> {/* Avatar — Foto veya gradient initial */}
<div className="mx-auto mb-6 size-20">
{m.photo_url ? ( {m.photo_url ? (
<Image <div className="relative size-20 overflow-hidden rounded-2xl">
src={m.photo_url} <Image
alt={m.name} src={m.photo_url}
fill alt={m.name}
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw" fill
className="object-cover" sizes="80px"
/> className="object-cover"
/>
</div>
) : ( ) : (
<div className="flex h-full items-center justify-center text-6xl font-bold text-[var(--navy)]/30"> <div
{m.name.charAt(0)} className={`flex size-20 items-center justify-center rounded-2xl bg-gradient-to-br ${
GRADIENTS[i % GRADIENTS.length]
} text-2xl font-bold text-white shadow-lg`}
>
{initials(m.name)}
</div> </div>
)} )}
</div> </div>
<div className="p-5">
<h3 className="text-base font-semibold text-[var(--navy)]"> <div className="text-center">
{m.name} <h3 className="text-xl font-bold text-[var(--navy)]">{m.name}</h3>
</h3>
{m.role && ( {m.role && (
<p className="text-xs text-[var(--sky-600)]">{m.role}</p> <div className="mt-1 text-sm font-medium text-[var(--sky-600)]">
{m.role}
</div>
)} )}
{m.bio && ( {m.bio && (
<p className="mt-3 text-sm leading-relaxed text-[var(--muted)]"> <p className="mt-4 text-sm leading-relaxed text-[var(--muted)]">
{m.bio} {m.bio}
</p> </p>
)} )}
{m.skills && m.skills.length > 0 && (
<div className="mt-5 flex flex-wrap items-center justify-center gap-1.5">
{m.skills.map((s) => (
<span
key={s}
className="rounded-full bg-[var(--sky-50)] px-3 py-1 text-xs font-medium text-[var(--sky-600)]"
>
{s}
</span>
))}
</div>
)}
{m.linkedin_url && ( {m.linkedin_url && (
<a <a
href={m.linkedin_url} href={m.linkedin_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-[var(--sky-600)] hover:text-[var(--navy)]" className="mt-5 inline-flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--muted)] transition hover:border-[var(--sky)] hover:text-[var(--navy)]"
> >
<LinkedinIcon className="size-3.5" /> <LinkedinIcon className="size-3.5" />
LinkedIn'de bağlan LinkedIn
</a> </a>
)} )}
</div> </div>
+161 -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")),
@@ -527,6 +675,7 @@ export async function saveTeamMember(formData: FormData) {
photo_url: str(formData.get("photo_url")), photo_url: str(formData.get("photo_url")),
linkedin_url: str(formData.get("linkedin_url")), linkedin_url: str(formData.get("linkedin_url")),
order: num(formData.get("order")) ?? 0, order: num(formData.get("order")) ?? 0,
skills: strArr(formData.get("skills")),
}; };
if (id) { if (id) {
@@ -561,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
@@ -0,0 +1,17 @@
import { marked } from "marked";
/**
* Akıllı içerik render — RichEditor HTML üretir, eski içerikler markdown olabilir.
* - HTML işareti (`<p>`, `<h1>` vs ile başlıyor) varsa direkt döner
* - Aksi halde markdown olarak parse eder
*/
export function renderContent(content?: string | null): string {
if (!content) return "";
const trimmed = content.trim();
if (!trimmed) return "";
// HTML: ilk karakter '<' ise ve içinde HTML tag varsa
if (trimmed.startsWith("<") && /<\w+[^>]*>/.test(trimmed)) {
return trimmed;
}
return marked.parse(trimmed, { async: false }) as string;
}
+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;
+41
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 {
@@ -157,6 +197,7 @@ export interface TeamMemberRow extends AwRow {
photo_url?: string | null; photo_url?: string | null;
linkedin_url?: string | null; linkedin_url?: string | null;
order?: number | null; order?: number | null;
skills?: string[] | null;
} }
export interface IndustryRow extends AwRow { export interface IndustryRow extends AwRow {
+2
View File
@@ -10,6 +10,8 @@ const nextConfig: NextConfig = {
{ protocol: "https", hostname: "db.kovaksoft.com" }, { protocol: "https", hostname: "db.kovaksoft.com" },
{ protocol: "https", hostname: "kovakyazilim.com" }, { protocol: "https", hostname: "kovakyazilim.com" },
{ protocol: "https", hostname: "**.kovaksoft.com" }, { protocol: "https", hostname: "**.kovaksoft.com" },
{ protocol: "https", hostname: "images.pexels.com" },
{ protocol: "https", hostname: "images.unsplash.com" },
], ],
}, },
}; };
+723 -4
View File
@@ -8,6 +8,14 @@
"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-link": "^3.23.5",
"@tiptap/extension-placeholder": "^3.23.5",
"@tiptap/extension-text-align": "^3.23.5",
"@tiptap/extension-underline": "^3.23.5",
"@tiptap/react": "^3.23.5",
"@tiptap/starter-kit": "^3.23.5",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4", "marked": "^18.0.4",
"next": "16.2.6", "next": "16.2.6",
@@ -46,6 +54,34 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1048,6 +1084,485 @@
"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": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.5.tgz",
"integrity": "sha512-657Xqcgf1IYWLkAmRDJKNSGdoS1AHJEgK6zHWHFJERQGIqHnwC7Fz7nvWs/NQhQVBkclQd0ERRdTCZ3XwRc1+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.5.tgz",
"integrity": "sha512-PBQRoGfSWfIY7HmGbS5PTHEBQl5nKbild5J5phPLFF+O3aOBQ0d49AC9cxbaou/6FRCtq6g4Uqse9rRTKJRM0w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.5.tgz",
"integrity": "sha512-DZsDCCf53fA9HmsFzfUHl5jLOwDYf+XzfP+QJjJ4cK23SsxDirameTjgnwi4l1EgEPLWunMZQjU+wHmh7vvX6Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.5.tgz",
"integrity": "sha512-otcGwyVO6OfxdDPnbooZxYGrb+6q5WYmS+g2V+XGGNRn5oJgyY5pW0dqELIUJ66dosIIXXPyw2XqBDpMMY2kyQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.5.tgz",
"integrity": "sha512-o0bzZbFvOPhPX6+RAhIFPKMIN3jIenY6Ib3FJ6ZqxTdVcjuV2mIXUmJU0uV2BwKtz73GmKSRKRKia6KJ0ml8qA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.5"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.5.tgz",
"integrity": "sha512-NOJUD2Z0hrtBWnovXiiH1XtOjEQePOfIG3bNJgXSs1bWxPVhqp6KjVd8mUJNra974hxbml3tC97sL9QqjpAWFg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.5.tgz",
"integrity": "sha512-P2XH8WPM4UahavcWoQgAwNAKQCbF/JWi6ZqgsQmVBfAqQ3mf8gMxB7HnciMq1DlyI9EfjXoJH11yUqldF/6AaQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.5.tgz",
"integrity": "sha512-Y7uPjEM1xIK4Spcdk/kp/vZ/Az3cEaglTCk6uHrWvNFVglEoGehNb6IQbQFZW0wjE19YoMIiLBLtG6V9dqrpBw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.5.tgz",
"integrity": "sha512-l72R798Q69D6f89Vp9xreoRnPcpK0LHPKLZIc6pvqBC2iOjx5wLKtW0uP1uqVWdQtvF5AUYBRNIGAQ5Gel9XEg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.5"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.5.tgz",
"integrity": "sha512-kP0bZKH/lxNogfvoIy/YJZ5gkty0OwqFVtQUwoc85vXYUfvy5Jh1VdO053tCE1iDzmvOITUpcb+MdWryP8dBxA==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.5.tgz",
"integrity": "sha512-x9XlYG26TowX0Ly1w0ZV2D8qliyQy9fTmMY4suI6B/6o6m/sXHGTAJMmJqwP66sZKF6cMLU3HECumhtyQxPT2g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.5"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.5.tgz",
"integrity": "sha512-j/BDBMOA1mA+RhCx622SRPBhpp2XWNFYz9asbg8T3yk8v9WI3Vjo6IDlfTp6fwsR2LGE7Pek3R0xDAjW6yVG3g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.5.tgz",
"integrity": "sha512-tFI+iYk34geacVOGqYgyoC8siQjdGn605XaYSZcGRFF8NY+HrGlLkQi2QRRCeLaUhxoctONmWc8USn3H5U7wLQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.5.tgz",
"integrity": "sha512-9XkRYc4XE0stERZB3y8bsJd32Jw9UZfMwZXo1GLNYRHFr7dmhSGUj0IzgofqOVmLDcOMW6XcCk54TBYw6BCrWA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-image": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.23.5.tgz",
"integrity": "sha512-v6u9zbJSKLjml6DDn1/1WOOIzVxz3K5Idl1EgUl+IpJH7kR1HLRJ3TaSgF7z2RRQmqyHlmtdCzdaKoe0jCIyqQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.5.tgz",
"integrity": "sha512-XjRSPr6j4mz+8O5j5KNfxVb+1fGNt0wr+js6MLxxGdU7M+PoDPdVY6fARbmBazv4ERlZ5PNS9m35Vo5xDjDfrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.5.tgz",
"integrity": "sha512-FEI58NAPnauBbs4nw1dkgRyEhcWnure0vIlStfQoQGXxj3xSRvxKH2lOkz54fGzuzRJAoudyLU65HW6D7kc+8Q==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.5.tgz",
"integrity": "sha512-nzZXpVwnyKwTj4TVyPyu1bCUFjJCsaXnhAthmvJDnX3RBtemNG9Ka07xGR2NIspzumSbQSMFtDxjmxv3W5dEtg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.5.tgz",
"integrity": "sha512-l7Hb4rfNIkO6JrNJYkdXap6QYXCz4XeeFmI1bfQgEiwPGs+RAn/+0cOdg7q+6MmtZFac5uSXV0PftPk6A0GsEA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.5"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.5.tgz",
"integrity": "sha512-Hz8jRA51VSiHezEkwqwaMYbTEYcR/5Aq3UgCgDlNPlE6k1OZrvRtV/4s3AOO0RRgzyVLKv7yv7KuOJN/OLGErw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.5.tgz",
"integrity": "sha512-qQeU71ij0cAAD9bbGqot5T5bpR3dysgQ+W67quRs6VDyusU89EYaJHKn/qWU6a1XOEQ4sL+5GNw52FYQVHUxbA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.23.5"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.5.tgz",
"integrity": "sha512-LtgMcR1rvWnZDtphFJ/LBltlC0+6HGA07k7vhy+U7P/zIg/V3Fb4RD6YDuAo0cPfBsLm8p1WYJV92WpAsGgtlg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.5.tgz",
"integrity": "sha512-B2snUujc6fb/16p8jSQCN4+mto7RlHKLm8quBTUWXksY8D82u/cxjUdmRQ7ueq7vsbRsA+WoJTrKEjJ8RQOpjw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.23.5"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.5.tgz",
"integrity": "sha512-PMB9lpQGOJGuRTIS9rBw8UZtHQwmsiJbWKjcBr5z20MluaJQ3ZCHFhDYG6ncIDRz+0ny4ZvoJ7cKGpI+NTvXMA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.5.tgz",
"integrity": "sha512-GLa+AaA2NC5XYRZad/Qq/oH5Pa95s+uA17J7+RCkF8j1RNREUBkYQ5CD5MT8kT+D3DHgU8MRyYdTd28I46HBDQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.23.5.tgz",
"integrity": "sha512-eOeXrbpPWc6gfXli2aXYg9t61HhkvEkdxQgpEpZPFhrT4pPQcIqTlihswByC+cPb8B5ynrc/iamiY9cRSU1qvw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.5.tgz",
"integrity": "sha512-fyxthzE6CNCi9a9OVAwXs1sSyJ7jlrzT3aP2KhYLQCsJABHaPJgJA7k52/CRuKqCW3WbxU1ULH9LGuGtBbhEyw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.5.tgz",
"integrity": "sha512-ROcdNPV+buzldEFKvD3o29P7H7zpAf2lnLfndO2LHSToWyHw4hlzVPCeAU8uAvhl/jyfeUoFLrBwxphMX/KG6A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5"
}
},
"node_modules/@tiptap/pm": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.5.tgz",
"integrity": "sha512-9tgLdpTvNN0/fLP4RcNzbyQ0qjg9J2ahaFbQzgV5uvd+QMy8Xkg2IqKKnOoJJUAV3FDjGq3Yx0WrV2BGro9pfw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.5.tgz",
"integrity": "sha512-aEdKfJxoa6tCEV4FrnBqMQoUPwGcTWLaDzmP4fL1gR7E40rYDTiYNKoF1Ob+UimUpguAP6Emv1WlJa5oyI8FSw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.23.5",
"@tiptap/extension-floating-menu": "^3.23.5"
},
"peerDependencies": {
"@tiptap/core": "3.23.5",
"@tiptap/pm": "3.23.5",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.23.5",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.5.tgz",
"integrity": "sha512-ac0edQ1a1nYkNAzOgdqIBKGdrOlNQpPP9wGAG3Q9EgTq4+C4/EftJZZJmUn3KzaSOUv4cLEDo0z0jurJvZPkaw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.23.5",
"@tiptap/extension-blockquote": "^3.23.5",
"@tiptap/extension-bold": "^3.23.5",
"@tiptap/extension-bullet-list": "^3.23.5",
"@tiptap/extension-code": "^3.23.5",
"@tiptap/extension-code-block": "^3.23.5",
"@tiptap/extension-document": "^3.23.5",
"@tiptap/extension-dropcursor": "^3.23.5",
"@tiptap/extension-gapcursor": "^3.23.5",
"@tiptap/extension-hard-break": "^3.23.5",
"@tiptap/extension-heading": "^3.23.5",
"@tiptap/extension-horizontal-rule": "^3.23.5",
"@tiptap/extension-italic": "^3.23.5",
"@tiptap/extension-link": "^3.23.5",
"@tiptap/extension-list": "^3.23.5",
"@tiptap/extension-list-item": "^3.23.5",
"@tiptap/extension-list-keymap": "^3.23.5",
"@tiptap/extension-ordered-list": "^3.23.5",
"@tiptap/extension-paragraph": "^3.23.5",
"@tiptap/extension-strike": "^3.23.5",
"@tiptap/extension-text": "^3.23.5",
"@tiptap/extension-underline": "^3.23.5",
"@tiptap/extensions": "^3.23.5",
"@tiptap/pm": "^3.23.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.41", "version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -1062,7 +1577,6 @@
"version": "19.2.15", "version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1072,12 +1586,17 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.31", "version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
@@ -1116,11 +1635,22 @@
"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",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
@@ -1147,6 +1677,15 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1437,6 +1976,12 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
@@ -1567,6 +2112,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1602,6 +2153,148 @@
"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": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.7",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -1623,6 +2316,12 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1723,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": {
@@ -1766,6 +2464,27 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
} }
} }
} }
+8
View File
@@ -8,6 +8,14 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.23.5",
"@tiptap/extension-link": "^3.23.5",
"@tiptap/extension-placeholder": "^3.23.5",
"@tiptap/extension-text-align": "^3.23.5",
"@tiptap/extension-underline": "^3.23.5",
"@tiptap/react": "^3.23.5",
"@tiptap/starter-kit": "^3.23.5",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4", "marked": "^18.0.4",
"next": "16.2.6", "next": "16.2.6",
+15
View File
@@ -0,0 +1,15 @@
// Google Consent Mode v2 — set defaults to "denied" before any analytics tag loads.
// CookieBanner sonradan gtag('consent','update',…) ile günceller.
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
window.gtag = gtag;
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'granted',
personalization_storage: 'denied',
security_storage: 'granted',
wait_for_update: 500,
});