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}`}`; }