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
This commit is contained in:
@@ -555,6 +555,34 @@ export async function saveSiteSettings(formData: FormData) {
|
||||
revalidatePath("/admin/site");
|
||||
}
|
||||
|
||||
// ─── Navigation Menu ─────────────────────────────────────────────
|
||||
|
||||
export async function saveNavMenu(formData: FormData) {
|
||||
const secret = await requireSessionSecret();
|
||||
// Client form, sıralı menüyü JSON string olarak nav_items'a koyar.
|
||||
const navItems = str(formData.get("nav_items"));
|
||||
const data = { nav_items: navItems };
|
||||
try {
|
||||
await tablesDB.updateRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
} catch {
|
||||
await tablesDB.createRow(
|
||||
DATABASE_ID,
|
||||
TABLES.siteSettings,
|
||||
"homepage",
|
||||
data,
|
||||
secret,
|
||||
);
|
||||
}
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
}
|
||||
|
||||
// ─── SEO Settings ────────────────────────────────────────────────
|
||||
|
||||
export async function saveSeoSettings(formData: FormData) {
|
||||
@@ -562,6 +590,7 @@ export async function saveSeoSettings(formData: FormData) {
|
||||
const data = {
|
||||
site_name: str(formData.get("site_name")),
|
||||
site_description: str(formData.get("site_description")),
|
||||
default_keywords: str(formData.get("default_keywords")),
|
||||
default_og_image: str(formData.get("default_og_image")),
|
||||
twitter_handle: str(formData.get("twitter_handle")),
|
||||
facebook_url: str(formData.get("facebook_url")),
|
||||
@@ -603,6 +632,7 @@ export async function saveSeoPage(formData: FormData) {
|
||||
path,
|
||||
title: str(formData.get("title")),
|
||||
description: str(formData.get("description")),
|
||||
keywords: str(formData.get("keywords")),
|
||||
og_image: str(formData.get("og_image")),
|
||||
canonical: str(formData.get("canonical")),
|
||||
noindex: bool(formData.get("noindex")),
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Üst menü (header + mobil) düzeni. Öğeler sabit bir kayıttan gelir; admin
|
||||
// panelinden yalnızca SIRA, GÖRÜNÜRLÜK ve (opsiyonel) ETİKET düzenlenir.
|
||||
// Bu modül hem sunucu (header) hem istemci (admin formu) tarafında import
|
||||
// edilebilir — bu yüzden "server-only" YOK.
|
||||
|
||||
export type NavKey =
|
||||
| "home"
|
||||
| "services"
|
||||
| "solutions"
|
||||
| "projects"
|
||||
| "blog"
|
||||
| "about"
|
||||
| "contact";
|
||||
|
||||
export interface NavRegistryEntry {
|
||||
key: NavKey;
|
||||
label: string; // varsayılan etiket
|
||||
href: string;
|
||||
mega?: boolean; // Hizmetler — mega menü olarak render edilir
|
||||
}
|
||||
|
||||
export const NAV_REGISTRY: Record<NavKey, NavRegistryEntry> = {
|
||||
home: { key: "home", label: "Anasayfa", href: "/" },
|
||||
services: { key: "services", label: "Hizmetler", href: "/hizmetler", mega: true },
|
||||
solutions: { key: "solutions", label: "Çözümler", href: "/cozumler" },
|
||||
projects: { key: "projects", label: "Projeler", href: "/projeler" },
|
||||
blog: { key: "blog", label: "Blog", href: "/blog" },
|
||||
about: { key: "about", label: "Hakkımızda", href: "/hakkimizda" },
|
||||
contact: { key: "contact", label: "İletişim", href: "/iletisim" },
|
||||
};
|
||||
|
||||
export const DEFAULT_NAV_ORDER: NavKey[] = [
|
||||
"home",
|
||||
"services",
|
||||
"solutions",
|
||||
"projects",
|
||||
"blog",
|
||||
"about",
|
||||
"contact",
|
||||
];
|
||||
|
||||
export interface NavItem {
|
||||
key: NavKey;
|
||||
label: string; // çözülmüş etiket (override veya varsayılan)
|
||||
href: string;
|
||||
mega: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface StoredNavItem {
|
||||
key: string;
|
||||
visible?: boolean;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* site_settings.nav_items içindeki JSON'ı kayıt ile birleştirir.
|
||||
* - Kayıtlı sıra önceliklidir, geçersiz/silinmiş key'ler atlanır.
|
||||
* - Kayıtta olmayan (yeni eklenen) öğeler varsayılan sırayla sona eklenir.
|
||||
* - JSON yoksa/bozuksa tam varsayılan menü döner.
|
||||
*/
|
||||
export function resolveNavItems(navItemsJson?: string | null): NavItem[] {
|
||||
let stored: StoredNavItem[] = [];
|
||||
if (navItemsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(navItemsJson);
|
||||
if (Array.isArray(parsed)) stored = parsed as StoredNavItem[];
|
||||
} catch {
|
||||
/* bozuk JSON — varsayılanlara düş */
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<NavKey>();
|
||||
const ordered: NavItem[] = [];
|
||||
|
||||
for (const item of stored) {
|
||||
const reg = NAV_REGISTRY[item.key as NavKey];
|
||||
if (!reg || seen.has(reg.key)) continue;
|
||||
seen.add(reg.key);
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: item.label?.trim() || reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: item.visible !== false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of DEFAULT_NAV_ORDER) {
|
||||
if (seen.has(key)) continue;
|
||||
const reg = NAV_REGISTRY[key];
|
||||
ordered.push({
|
||||
key: reg.key,
|
||||
label: reg.label,
|
||||
href: reg.href,
|
||||
mega: !!reg.mega,
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** Admin formundan gelen düzeni depolanacak kompakt JSON'a çevirir. */
|
||||
export function serializeNavItems(
|
||||
items: { key: NavKey; visible: boolean; label?: string }[],
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
items.map((i) => {
|
||||
const reg = NAV_REGISTRY[i.key];
|
||||
const out: StoredNavItem = { key: i.key, visible: i.visible };
|
||||
// Sadece varsayılandan farklıysa etiketi sakla
|
||||
if (i.label && i.label.trim() && i.label.trim() !== reg.label) {
|
||||
out.label = i.label.trim();
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
);
|
||||
}
|
||||
+43
-2
@@ -19,17 +19,58 @@ export async function buildMetadata(path: string, fallback?: Metadata): Promise<
|
||||
override?.description ??
|
||||
(fallback?.description as string | undefined) ??
|
||||
siteDesc;
|
||||
const ogImage = override?.og_image || ogDefault;
|
||||
|
||||
// Sayfanın kendi OG bilgisi (blog kapağı, type:"article" vb.) — fallback'ten
|
||||
// oku. Öncelik: sayfa SEO override > sayfanın fallback OG görseli > varsayılan.
|
||||
const fbOg = fallback?.openGraph as
|
||||
| { images?: unknown; type?: string }
|
||||
| undefined;
|
||||
const fbOgImage = (() => {
|
||||
const imgs = fbOg?.images;
|
||||
if (typeof imgs === "string") return imgs;
|
||||
if (Array.isArray(imgs) && imgs.length) {
|
||||
const first = imgs[0];
|
||||
if (typeof first === "string") return first;
|
||||
if (first && typeof first === "object" && "url" in first)
|
||||
return String((first as { url: unknown }).url);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const ogImage = override?.og_image || fbOgImage || ogDefault;
|
||||
const ogType = fbOg?.type ?? "website";
|
||||
|
||||
// Anahtar kelimeler: sayfa override + site geneli varsayılan + sayfanın kendi
|
||||
// keyword'leri (örn. blog etiketleri) birleştirilir, tekrarlar ayıklanır.
|
||||
const fbKeywords = fallback?.keywords;
|
||||
const fbKeywordsStr = Array.isArray(fbKeywords)
|
||||
? fbKeywords.join(",")
|
||||
: typeof fbKeywords === "string"
|
||||
? fbKeywords
|
||||
: "";
|
||||
const keywordsRaw = [override?.keywords, settings?.default_keywords, fbKeywordsStr]
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
const keywords = keywordsRaw
|
||||
? Array.from(
|
||||
new Set(
|
||||
keywordsRaw
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: ogImage ? [{ url: ogImage }] : undefined,
|
||||
type: "website",
|
||||
type: ogType as "website" | "article",
|
||||
locale: "tr_TR",
|
||||
siteName,
|
||||
},
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface SeoPageRow extends AwRow {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
keywords?: string | null; // virgülle ayrılmış anahtar kelimeler (sayfa override)
|
||||
og_image?: string | null;
|
||||
canonical?: string | null;
|
||||
noindex?: boolean | null;
|
||||
@@ -93,6 +94,7 @@ export interface SeoPageRow extends AwRow {
|
||||
export interface SeoSettingsRow extends AwRow {
|
||||
site_name?: string | null;
|
||||
site_description?: string | null;
|
||||
default_keywords?: string | null; // virgülle ayrılmış site geneli anahtar kelimeler
|
||||
default_og_image?: string | null;
|
||||
twitter_handle?: string | null;
|
||||
facebook_url?: string | null;
|
||||
@@ -152,6 +154,9 @@ export interface SiteSettingsRow extends AwRow {
|
||||
|
||||
footer_tagline?: string | null;
|
||||
|
||||
// Üst menü düzeni — JSON dizi: [{ key, visible, label? }] sırasıyla
|
||||
nav_items?: string | null;
|
||||
|
||||
whatsapp_message?: string | null;
|
||||
client_logos?: string[] | null;
|
||||
trust_items?: string[] | null; // JSON {"icon":"Star","value":"4.9","label":"..."}
|
||||
|
||||
Reference in New Issue
Block a user