From 1b92cb8ffd786a251e798d3eb598030235477fc2 Mon Sep 17 00:00:00 2001 From: prudentbird Date: Fri, 5 Jun 2026 10:41:39 +0100 Subject: [PATCH] feat(seo): add Open Graph and Twitter social cards Every public page now emits Open Graph and Twitter card tags (title, description, url, image) so links render branded previews on LinkedIn, Slack, WhatsApp and X. - Add a branded 1200x630 social card generated with next/og, served site-wide via app/opengraph-image and reused for app/twitter-image. - Add buildMetadata() helper that produces matching title/description/ canonical plus OG and Twitter card objects for each page. - Route home and auth pages through the helper; add OG/Twitter defaults to the root layout for inheritance. Refs #106 --- .../src/app/(auth)/forgot-password/page.tsx | 9 +-- frontend/src/app/(auth)/login/page.tsx | 9 +-- frontend/src/app/(auth)/register/page.tsx | 9 +-- frontend/src/app/layout.tsx | 15 ++++ frontend/src/app/opengraph-image.tsx | 72 +++++++++++++++++++ frontend/src/app/page.tsx | 10 +-- frontend/src/app/twitter-image.tsx | 1 + frontend/src/lib/metadata.ts | 42 +++++++++++ frontend/src/lib/site.ts | 15 ---- 9 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 frontend/src/app/opengraph-image.tsx create mode 100644 frontend/src/app/twitter-image.tsx create mode 100644 frontend/src/lib/metadata.ts diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx index 68ece78..add9c28 100644 --- a/frontend/src/app/(auth)/forgot-password/page.tsx +++ b/frontend/src/app/(auth)/forgot-password/page.tsx @@ -2,14 +2,15 @@ import { Suspense } from 'react'; import type { Metadata } from 'next'; import Loader from '~/components/loader'; import { ForgotPasswordForm } from './form'; +import { buildMetadata } from '~/lib/metadata'; -export const metadata: Metadata = { +export const metadata: Metadata = buildMetadata({ title: 'Reset Password', description: 'Reset your Retailytics password to regain access to your retail intelligence dashboards and store data.', - alternates: { canonical: '/forgot-password' }, - robots: { index: false, follow: true }, -}; + path: '/forgot-password', + index: false, +}); export default async function ForgotPasswordPage() { return ( diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 289d8bd..b077150 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -4,14 +4,15 @@ import type { Metadata } from 'next'; import { cache, Suspense } from 'react'; import Loader from '~/components/loader'; import { redirect } from 'next/navigation'; +import { buildMetadata } from '~/lib/metadata'; -export const metadata: Metadata = { +export const metadata: Metadata = buildMetadata({ title: 'Sign In', description: 'Sign in to your Retailytics account to access store enumeration dashboards, field data, and market intelligence reports.', - alternates: { canonical: '/login' }, - robots: { index: false, follow: true }, -}; + path: '/login', + index: false, +}); const getSession = cache(() => auth()); diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx index 41b8d1d..61a3387 100644 --- a/frontend/src/app/(auth)/register/page.tsx +++ b/frontend/src/app/(auth)/register/page.tsx @@ -5,14 +5,15 @@ import { RegisterForm } from './form'; import { cache, Suspense } from 'react'; import Loader from '~/components/loader'; import { redirect } from 'next/navigation'; +import { buildMetadata } from '~/lib/metadata'; -export const metadata: Metadata = { +export const metadata: Metadata = buildMetadata({ title: 'Create Account', description: 'Create your Retailytics account to start collecting store data, validating field submissions, and turning local data into market intelligence.', - alternates: { canonical: '/register' }, - robots: { index: false, follow: true }, -}; + path: '/register', + index: false, +}); const getSession = cache(() => auth()); diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a91519d..c86fc7e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -28,6 +28,21 @@ export const metadata: Metadata = { alternates: { canonical: '/', }, + openGraph: { + type: 'website', + siteName: siteConfig.name, + locale: siteConfig.locale, + url: siteConfig.url, + title: `${siteConfig.name} — ${siteConfig.tagline}`, + description: siteConfig.description, + }, + twitter: { + card: 'summary_large_image', + site: siteConfig.twitter.handle, + creator: siteConfig.twitter.handle, + title: `${siteConfig.name} — ${siteConfig.tagline}`, + description: siteConfig.description, + }, }; export default function RootLayout({ diff --git a/frontend/src/app/opengraph-image.tsx b/frontend/src/app/opengraph-image.tsx new file mode 100644 index 0000000..e44994a --- /dev/null +++ b/frontend/src/app/opengraph-image.tsx @@ -0,0 +1,72 @@ +import { ImageResponse } from 'next/og'; +import { siteConfig } from '~/lib/site'; + +export const alt = `${siteConfig.name} — ${siteConfig.tagline}`; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default function OpengraphImage() { + return new ImageResponse( +
+
+ {siteConfig.name} +
+ +
+
+ {siteConfig.tagline} +
+
+ Store enumeration, field data collection, and market analysis in one + platform. +
+
+ +
+ {siteConfig.url.replace(/^https?:\/\//, '')} + by {siteConfig.legalName} +
+
, + { ...size }, + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5578466..ea2cbb7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,16 +1,12 @@ import CTA from '~/components/cta'; import FAQ from '~/components/faq'; import Hero from '~/components/hero'; -import type { Metadata } from 'next'; -import { siteConfig } from '~/lib/site'; import Footer from '~/components/footer'; +import type { Metadata } from 'next'; +import { buildMetadata } from '~/lib/metadata'; import HowItWorks from '~/components/how-it-works'; -export const metadata: Metadata = { - title: { absolute: `${siteConfig.name} — ${siteConfig.tagline}` }, - description: siteConfig.description, - alternates: { canonical: '/' }, -}; +export const metadata: Metadata = buildMetadata({ path: '/' }); export default function Home() { return ( diff --git a/frontend/src/app/twitter-image.tsx b/frontend/src/app/twitter-image.tsx new file mode 100644 index 0000000..587376b --- /dev/null +++ b/frontend/src/app/twitter-image.tsx @@ -0,0 +1 @@ +export { default, alt, size, contentType } from '~/app/opengraph-image'; diff --git a/frontend/src/lib/metadata.ts b/frontend/src/lib/metadata.ts new file mode 100644 index 0000000..2d6e2ee --- /dev/null +++ b/frontend/src/lib/metadata.ts @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; +import { siteConfig } from '~/lib/site'; + +type BuildMetadataOptions = { + title?: string; + description?: string; + path?: string; + index?: boolean; +}; + +export function buildMetadata({ + title, + description = siteConfig.description, + path = '/', + index = true, +}: BuildMetadataOptions = {}): Metadata { + const socialTitle = title + ? `${title} · ${siteConfig.name}` + : `${siteConfig.name} — ${siteConfig.tagline}`; + + return { + title: title ?? { absolute: socialTitle }, + description, + alternates: { canonical: path }, + ...(index ? {} : { robots: { index: false, follow: true } }), + openGraph: { + type: 'website', + siteName: siteConfig.name, + locale: siteConfig.locale, + url: path, + title: socialTitle, + description, + }, + twitter: { + card: 'summary_large_image', + site: siteConfig.twitter.handle, + creator: siteConfig.twitter.handle, + title: socialTitle, + description, + }, + }; +} diff --git a/frontend/src/lib/site.ts b/frontend/src/lib/site.ts index 57164f8..fd14b5b 100644 --- a/frontend/src/lib/site.ts +++ b/frontend/src/lib/site.ts @@ -1,37 +1,23 @@ import { env } from '~/env'; -/** - * Central, single-source-of-truth metadata for the Retailytics marketing - * surface. Reused by page metadata, Open Graph/Twitter cards, robots.txt, - * sitemap.xml, JSON-LD structured data and the llms.txt manifest so the - * product is described consistently everywhere search engines and AI agents - * look. - */ export const siteConfig = { name: 'Retailytics', - /** Parent organisation that builds and operates Retailytics. */ legalName: 'Ajared Research Inc.', - /** Canonical, absolute production URL (no trailing slash). */ url: env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, ''), tagline: 'Turn Local Data into Market Intelligence', description: 'Retailytics turns local store data into market intelligence — enumeration, field data collection, and analysis in one platform. Start free today.', - /** Monitored contact address used on public pages and structured data. */ contactEmail: 'innovation@ajared.ca', - /** Default social/OG share image (1200×630), served from /public. */ - ogImage: '/og.png', locale: 'en_US', twitter: { handle: '@ajaREDiA', }, - /** Profiles used for the Organization `sameAs` graph. */ social: { twitter: 'https://twitter.com/ajaREDiA', github: 'https://github.com/ajared', parent: 'https://www.ajared.ng', parentCa: 'https://www.ajared.ca', }, - /** Keyword targets shared across pages. */ keywords: [ 'retail intelligence', 'store enumeration', @@ -46,7 +32,6 @@ export const siteConfig = { export type SiteConfig = typeof siteConfig; -/** Build an absolute URL from a site-relative path. */ export function absoluteUrl(path = '/'): string { return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`; }