SEO overhaul: metadata, robots, sitemap, structured data
- Add metadataBase to root layout so OG/Twitter/canonical URLs resolve to absolute https URLs (fixes broken social previews) - New src/lib/seo.ts with SITE_URL + JSON-LD builders - New robots.ts (disallow api/admin/private app paths) and sitemap.ts (marketing pages + blog posts with real lastmod dates) - JSON-LD: Organization/WebSite/SoftwareApplication on home, Blog+Breadcrumb on blog list, BlogPosting+Breadcrumb on posts - Per-page canonical + Open Graph on all marketing pages; article OG + Twitter cards on blog posts; per-post dynamic OG image - noindex on (app) and admin layouts; richer PWA manifest - Fix CSP to allow plausible.io in script-src/connect-src (analytics was silently blocked) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
39b2787484
commit
dad0611350
18 changed files with 500 additions and 15 deletions
|
|
@ -23,9 +23,9 @@ const nextConfig: NextConfig = {
|
|||
{ key: "Content-Security-Policy", value:
|
||||
"default-src 'self'; " +
|
||||
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.io; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"connect-src 'self' https://llm.manohargupta.com; " +
|
||||
"connect-src 'self' https://llm.manohargupta.com https://plausible.io; " +
|
||||
"font-src 'self' data:;"
|
||||
},
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +1,15 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/app/ThemeProvider";
|
||||
import { FamilyProvider } from "@/app/FamilyProvider";
|
||||
import { PageTransition } from "@/components/PageTransition";
|
||||
import { BottomNav } from "@/components/BottomNav";
|
||||
import { InstallPrompt } from "@/components/InstallPrompt";
|
||||
|
||||
// Private, authenticated app — never index these pages.
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@ import type { Metadata } from "next";
|
|||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
title: "About Tia",
|
||||
description: "The story behind Tia — built for Indian families by a parent who wanted something better.",
|
||||
alternates: { canonical: "/about" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/about",
|
||||
title: "About Tia",
|
||||
description: "The story behind Tia — built for Indian families by a parent who wanted something better.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
|
|
|
|||
82
src/app/(marketing)/blog/[slug]/opengraph-image.tsx
Normal file
82
src/app/(marketing)/blog/[slug]/opengraph-image.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { ImageResponse } from "next/og";
|
||||
import { getPost, POSTS } from "../posts";
|
||||
|
||||
export const alt = "Tia Blog";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
// Pre-render an OG image for every known post.
|
||||
export function generateStaticParams() {
|
||||
return POSTS.map((p) => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export default async function BlogOgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = getPost(slug);
|
||||
|
||||
const title = post?.title ?? "The Tia Blog";
|
||||
const category = post?.category ?? "Journal";
|
||||
const emoji = post?.emoji ?? "🌸";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #fdf2f2 0%, #fef3c7 50%, #fdf2f2 100%)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
fontFamily: "sans-serif",
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<span style={{ fontSize: 40 }}>🌸</span>
|
||||
<span style={{ fontSize: 34, fontWeight: 800, color: "#111827" }}>
|
||||
Tia
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: "#f43f5e",
|
||||
background: "#ffe4e6",
|
||||
padding: "6px 18px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ fontSize: 64, marginBottom: 16 }}>{emoji}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 56,
|
||||
fontWeight: 800,
|
||||
color: "#111827",
|
||||
lineHeight: 1.15,
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 24, color: "#6b7280" }}>
|
||||
tia.manohargupta.com/blog
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,11 @@ import Link from "next/link";
|
|||
import { notFound } from "next/navigation";
|
||||
import { POSTS, getPost, formatDate } from "../posts";
|
||||
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
|
||||
import {
|
||||
blogPostingSchema,
|
||||
breadcrumbSchema,
|
||||
jsonLdScript,
|
||||
} from "@/lib/seo";
|
||||
|
||||
// Turn a heading string into a URL-safe anchor ID
|
||||
function slugifyHeading(heading: string): string {
|
||||
|
|
@ -26,9 +31,29 @@ export async function generateMetadata({
|
|||
const { slug } = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {};
|
||||
const url = `/blog/${post.slug}`;
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
url,
|
||||
siteName: "Tia",
|
||||
locale: "en_IN",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
publishedTime: post.date,
|
||||
modifiedTime: post.date,
|
||||
authors: [post.author],
|
||||
section: post.category,
|
||||
// Uses the per-post opengraph-image.tsx in this segment.
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +77,18 @@ export default async function BlogPostPage({
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Structured data — BlogPosting + breadcrumb trail */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([
|
||||
blogPostingSchema(post),
|
||||
breadcrumbSchema([
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog", path: "/blog" },
|
||||
{ name: post.title, path: `/blog/${post.slug}` },
|
||||
]),
|
||||
])}
|
||||
/>
|
||||
{/* Post hero / header */}
|
||||
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
|
||||
<div className="max-w-6xl mx-auto px-5 pt-10 pb-10">
|
||||
|
|
|
|||
|
|
@ -2,13 +2,46 @@ import type { Metadata } from "next";
|
|||
import Link from "next/link";
|
||||
import { POSTS, formatDate } from "./posts";
|
||||
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
|
||||
import {
|
||||
SITE_NAME,
|
||||
absoluteUrl,
|
||||
blogPostingSchema,
|
||||
breadcrumbSchema,
|
||||
jsonLdScript,
|
||||
} from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
title: "Blog — Baby Feeding, Health & Vaccination Guides",
|
||||
description:
|
||||
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
|
||||
alternates: { canonical: "/blog" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/blog",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_IN",
|
||||
title: `Blog | ${SITE_NAME}`,
|
||||
description:
|
||||
"Guides on baby feeding, health milestones, vaccination schedules, and how to make the most of Tia — written for Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
// Blog (with each post) + breadcrumb structured data.
|
||||
const blogSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Blog",
|
||||
"@id": `${absoluteUrl("/blog")}#blog`,
|
||||
name: `${SITE_NAME} Blog`,
|
||||
url: absoluteUrl("/blog"),
|
||||
inLanguage: "en-IN",
|
||||
blogPost: POSTS.map((p) => blogPostingSchema(p)),
|
||||
};
|
||||
|
||||
const blogBreadcrumb = breadcrumbSchema([
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog", path: "/blog" },
|
||||
]);
|
||||
|
||||
// Derive unique categories + counts from posts
|
||||
const CATEGORIES = Object.values(
|
||||
POSTS.reduce<Record<string, { name: string; color: string; count: number }>>((acc, p) => {
|
||||
|
|
@ -23,6 +56,10 @@ const CATEGORIES = Object.values(
|
|||
export default function BlogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([blogSchema, blogBreadcrumb])}
|
||||
/>
|
||||
{/* Page header */}
|
||||
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
|
||||
<div className="max-w-6xl mx-auto px-5 py-12 text-center">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,38 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PhoneMockup } from "@/components/marketing/PhoneMockup";
|
||||
import {
|
||||
jsonLdScript,
|
||||
organizationSchema,
|
||||
websiteSchema,
|
||||
softwareApplicationSchema,
|
||||
} from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tia — Your baby's digital heirloom",
|
||||
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
|
||||
description:
|
||||
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit.",
|
||||
"Tia is a digital heirloom for your baby — not just a tracker. Log daily moments, track the IAP vaccination schedule with Telegram alerts, and build a living archive your child will one day inherit. Free during early access.",
|
||||
keywords: [
|
||||
"baby tracker",
|
||||
"baby tracker app India",
|
||||
"IAP vaccination schedule",
|
||||
"baby vaccination tracker",
|
||||
"newborn feed tracker",
|
||||
"baby milestone tracker",
|
||||
"digital baby book",
|
||||
"parenting app India",
|
||||
"baby memory keeper",
|
||||
],
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/",
|
||||
siteName: "Tia",
|
||||
locale: "en_IN",
|
||||
title: "Tia — Baby Tracker & Digital Heirloom for Indian Families",
|
||||
description:
|
||||
"Log every feed, milestone, and memory. Track the IAP vaccination schedule with Telegram alerts. Built for Indian families. Privacy-first. Free during early access.",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Google G icon (reusable) ────────────────────────────────────
|
||||
|
|
@ -373,6 +400,15 @@ function FinalCTA() {
|
|||
export default function MarketingHomePage() {
|
||||
return (
|
||||
<>
|
||||
{/* Structured data — Organization, WebSite, and SoftwareApplication */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={jsonLdScript([
|
||||
organizationSchema(),
|
||||
websiteSchema(),
|
||||
softwareApplicationSchema(),
|
||||
])}
|
||||
/>
|
||||
<Hero />
|
||||
<TheProblem />
|
||||
<Features />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import Link from "next/link";
|
|||
export const metadata: Metadata = {
|
||||
title: "Partners",
|
||||
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
|
||||
alternates: { canonical: "/partners" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/partners",
|
||||
title: "Partners | Tia",
|
||||
description: "Clinics, pediatricians, and organisations partnering with Tia to support Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PartnersPage() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ import Link from "next/link";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description: "Founder pricing — early families keep their terms for life.",
|
||||
description: "Founder pricing — early families keep their terms for life. Free during early access.",
|
||||
alternates: { canonical: "/pricing" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/pricing",
|
||||
title: "Pricing | Tia",
|
||||
description: "Founder pricing — early families keep their terms for life. Free during early access.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ import type { Metadata } from "next";
|
|||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
|
||||
alternates: { canonical: "/privacy" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/privacy",
|
||||
title: "Privacy Policy | Tia",
|
||||
description: "How Tia handles your family's data. We don't sell it — we preserve it.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import type { Metadata } from "next";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service",
|
||||
description: "Terms of service for Tia.",
|
||||
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
|
||||
alternates: { canonical: "/terms" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/terms",
|
||||
title: "Terms of Service | Tia",
|
||||
description: "Terms of service for Tia — the baby tracker and digital heirloom for Indian families.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { verifyAdminSession } from "@/lib/admin-auth";
|
||||
import AdminSidebar from "./AdminSidebar";
|
||||
|
||||
// Admin panel — never index.
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const auth = await verifyAdminSession();
|
||||
if (!auth.success) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono, Caveat } from "next/font/google";
|
||||
import { SITE_URL, SITE_NAME } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
|
|
@ -18,17 +19,41 @@ const caveat = Caveat({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tia — Baby Tracker",
|
||||
description: "Track feeds, sleep, milestones and memories.",
|
||||
// Resolves all relative metadata URLs (OG/Twitter images, canonicals) to
|
||||
// absolute URLs — required for social previews to work.
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Tia — Baby Tracker & Digital Heirloom",
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description:
|
||||
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families.",
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: "Tia" }],
|
||||
creator: "Tia",
|
||||
publisher: "Tia",
|
||||
category: "parenting",
|
||||
formatDetection: { telephone: false, email: false, address: false },
|
||||
// apple-touch-icon is provided by the src/app/apple-icon.png file convention.
|
||||
icons: {
|
||||
icon: "/icon.svg",
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Tia",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
|
@ -41,7 +66,7 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="en-IN" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,18 @@ import type { MetadataRoute } from "next";
|
|||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Tia — Baby Tracker",
|
||||
name: "Tia — Baby Tracker & Digital Heirloom",
|
||||
short_name: "Tia",
|
||||
description: "Track feeds, sleep, milestones and memories.",
|
||||
description:
|
||||
"Track feeds, sleep, milestones, and memories. Tia is a privacy-first baby tracker and digital heirloom built for Indian families — with the IAP vaccination schedule and Telegram alerts.",
|
||||
id: "/home",
|
||||
start_url: "/home?source=pwa",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "portrait",
|
||||
lang: "en-IN",
|
||||
dir: "ltr",
|
||||
categories: ["health", "lifestyle", "parenting", "medical"],
|
||||
background_color: "#fdf2f2",
|
||||
theme_color: "#fb7185",
|
||||
icons: [
|
||||
|
|
|
|||
41
src/app/robots.ts
Normal file
41
src/app/robots.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// Private app, auth, and admin areas — keep them out of the index.
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/admin",
|
||||
"/admin-login",
|
||||
"/login",
|
||||
"/verify",
|
||||
"/invite/",
|
||||
"/onboarding",
|
||||
"/home",
|
||||
"/activity",
|
||||
"/ai",
|
||||
"/circle",
|
||||
"/family",
|
||||
"/growth",
|
||||
"/medical",
|
||||
"/memories",
|
||||
"/menu",
|
||||
"/milestones",
|
||||
"/notifications",
|
||||
"/profile",
|
||||
"/settings",
|
||||
"/wardrobe",
|
||||
"/dev",
|
||||
"/m/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
38
src/app/sitemap.ts
Normal file
38
src/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
import { POSTS } from "@/app/(marketing)/blog/posts";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const now = new Date();
|
||||
|
||||
// Static marketing routes with curated priorities.
|
||||
const staticRoutes: {
|
||||
path: string;
|
||||
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"];
|
||||
priority: number;
|
||||
}[] = [
|
||||
{ path: "/", changeFrequency: "weekly", priority: 1.0 },
|
||||
{ path: "/pricing", changeFrequency: "monthly", priority: 0.8 },
|
||||
{ path: "/about", changeFrequency: "monthly", priority: 0.7 },
|
||||
{ path: "/blog", changeFrequency: "weekly", priority: 0.7 },
|
||||
{ path: "/partners", changeFrequency: "monthly", priority: 0.5 },
|
||||
{ path: "/privacy", changeFrequency: "yearly", priority: 0.3 },
|
||||
{ path: "/terms", changeFrequency: "yearly", priority: 0.3 },
|
||||
];
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = staticRoutes.map((r) => ({
|
||||
url: `${SITE_URL}${r.path}`,
|
||||
lastModified: now,
|
||||
changeFrequency: r.changeFrequency,
|
||||
priority: r.priority,
|
||||
}));
|
||||
|
||||
const blogEntries: MetadataRoute.Sitemap = POSTS.map((post) => ({
|
||||
url: `${SITE_URL}/blog/${post.slug}`,
|
||||
lastModified: new Date(post.date),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...blogEntries];
|
||||
}
|
||||
135
src/lib/seo.ts
Normal file
135
src/lib/seo.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Central SEO constants and JSON-LD structured-data builders.
|
||||
// Used across the marketing site so titles, canonicals, Open Graph,
|
||||
// and schema.org markup all stay consistent.
|
||||
|
||||
/** Canonical production origin. Falls back to the known prod URL. */
|
||||
export const SITE_URL = (
|
||||
process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
export const SITE_NAME = "Tia";
|
||||
export const SITE_TAGLINE = "Your baby's digital heirloom";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Tia is a digital heirloom for your baby — not just a tracker. Log every feed, milestone, and memory, track the IAP vaccination schedule with Telegram alerts, and build a living, private archive your child will one day inherit. Built for Indian families.";
|
||||
|
||||
/** Absolute URL helper — keeps canonical/OG urls consistent. */
|
||||
export function absoluteUrl(path = "/"): string {
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
// ── JSON-LD builders ─────────────────────────────────────────────
|
||||
|
||||
/** Organization — publisher identity, reused as the publisher of articles. */
|
||||
export function organizationSchema() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: absoluteUrl("/icons/512.png"),
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
foundingDate: "2026",
|
||||
areaServed: "IN",
|
||||
email: "hello@tia.baby",
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
telephone: "+91-95548-81799",
|
||||
contactType: "customer support",
|
||||
email: "hello@tia.baby",
|
||||
areaServed: "IN",
|
||||
availableLanguage: ["English", "Hindi"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** WebSite — enables sitelinks/search treatment. */
|
||||
export function websiteSchema() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}/#website`,
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description: SITE_DESCRIPTION,
|
||||
inLanguage: "en-IN",
|
||||
publisher: { "@id": `${SITE_URL}/#organization` },
|
||||
};
|
||||
}
|
||||
|
||||
/** SoftwareApplication — tells search engines this is an app, free to use. */
|
||||
export function softwareApplicationSchema() {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: `${SITE_NAME} — Baby Tracker & Digital Heirloom`,
|
||||
operatingSystem: "Web, iOS, Android",
|
||||
applicationCategory: "HealthApplication",
|
||||
description: SITE_DESCRIPTION,
|
||||
url: SITE_URL,
|
||||
image: absoluteUrl("/icons/512.png"),
|
||||
inLanguage: "en-IN",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "INR",
|
||||
availability: "https://schema.org/InStock",
|
||||
},
|
||||
publisher: { "@id": `${SITE_URL}/#organization` },
|
||||
};
|
||||
}
|
||||
|
||||
/** BreadcrumbList from an ordered list of { name, path }. */
|
||||
export function breadcrumbSchema(items: { name: string; path: string }[]) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: item.name,
|
||||
item: absoluteUrl(item.path),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** BlogPosting / Article schema for a single post. */
|
||||
export function blogPostingSchema(post: {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
date: string;
|
||||
author: string;
|
||||
}) {
|
||||
const url = absoluteUrl(`/blog/${post.slug}`);
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"@id": `${url}#article`,
|
||||
headline: post.title,
|
||||
description: post.excerpt,
|
||||
datePublished: post.date,
|
||||
dateModified: post.date,
|
||||
inLanguage: "en-IN",
|
||||
image: absoluteUrl(`/blog/${post.slug}/opengraph-image`),
|
||||
author: { "@type": "Organization", name: post.author, url: SITE_URL },
|
||||
publisher: { "@id": `${SITE_URL}/#organization` },
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": url },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `dangerouslySetInnerHTML` payload for a JSON-LD <script>.
|
||||
* Pass one schema object or an array. Set `type="application/ld+json"` on
|
||||
* the <script> element itself.
|
||||
*/
|
||||
export function jsonLdScript(data: object | object[]): { __html: string } {
|
||||
// JSON.stringify is XSS-safe here because all inputs are our own constants.
|
||||
return { __html: JSON.stringify(data) };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue