From 1b92cb8ffd786a251e798d3eb598030235477fc2 Mon Sep 17 00:00:00 2001 From: prudentbird Date: Fri, 5 Jun 2026 10:41:39 +0100 Subject: [PATCH 1/6] 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}`}`; } From 9f3aca0e847318e60f100d4bd66734dea378f50d Mon Sep 17 00:00:00 2001 From: prudentbird Date: Fri, 5 Jun 2026 10:45:50 +0100 Subject: [PATCH 2/6] fix(seo): replace dead footer links and add contact/legal pages Footer links now resolve to real pages or anchors instead of placeholders that fell back to the homepage, and the previously missing contact and legal pages exist. - Footer: grouped Product/Company/Legal navigation with a real contact email, on-page anchors (#how-it-works, #faq), the parent company site, and links to the new pages. - Add /contact (indexable) with a real, monitored address. - Add /privacy and /terms minimal legal pages (noindex,follow). - Add SitePage shell for consistent standalone-page layout. Refs #112 --- frontend/src/app/contact/page.tsx | 53 +++++++++++++++++ frontend/src/app/privacy/page.tsx | 60 +++++++++++++++++++ frontend/src/app/terms/page.tsx | 59 ++++++++++++++++++ frontend/src/components/footer.tsx | 86 +++++++++++++++++++++++++++ frontend/src/components/site-page.tsx | 55 +++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 frontend/src/app/contact/page.tsx create mode 100644 frontend/src/app/privacy/page.tsx create mode 100644 frontend/src/app/terms/page.tsx create mode 100644 frontend/src/components/site-page.tsx diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx new file mode 100644 index 0000000..920882c --- /dev/null +++ b/frontend/src/app/contact/page.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from 'next'; +import { siteConfig } from '~/lib/site'; +import SitePage from '~/components/site-page'; +import { buildMetadata } from '~/lib/metadata'; + +export const metadata: Metadata = buildMetadata({ + title: 'Contact', + description: + 'Get in touch with the Retailytics team at Ajared Research Inc. for demos, partnerships, support, and questions about retail intelligence.', + path: '/contact', +}); + +export default function ContactPage() { + return ( + +

+ Retailytics is built and operated by {siteConfig.legalName}. The fastest + way to reach us is by email, and we aim to respond within two business + days. +

+ +

Email

+

+ + {siteConfig.contactEmail} + +

+ +

Sales & demos

+

+ Want to see Retailytics in action for your market? Email us with a short + note about your team and the regions you cover, and we'll set up a + walkthrough. +

+ +

Elsewhere

+

+ Learn more about the team at{' '} + + ajared.ng + {' '} + and{' '} + + ajared.ca + + . +

+
+ ); +} diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx new file mode 100644 index 0000000..6bf1262 --- /dev/null +++ b/frontend/src/app/privacy/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from 'next'; +import { siteConfig } from '~/lib/site'; +import SitePage from '~/components/site-page'; +import { buildMetadata } from '~/lib/metadata'; + +export const metadata: Metadata = buildMetadata({ + title: 'Privacy Policy', + description: `How ${siteConfig.legalName} collects, uses, and protects data on Retailytics.`, + path: '/privacy', + index: false, +}); + +export default function PrivacyPage() { + return ( + +

+ This Privacy Policy explains how {siteConfig.legalName} + ("we", "us") handles information in connection with + the Retailytics platform. By using Retailytics you agree to the + practices described here. +

+ +

Information we collect

+

+ We collect account details you provide (such as name and email), + operational data submitted through the platform (such as store and + location records captured by enumerators), and standard technical data + such as device and usage information. +

+ +

How we use information

+

+ We use information to operate and improve the platform, validate and + analyse submitted data, secure accounts, and communicate with you about + the service. +

+ +

Sharing

+

+ We do not sell personal information. We share data only with service + providers who help us operate Retailytics, or where required by law. +

+ +

Data retention & security

+

+ We retain data for as long as needed to provide the service and meet + legal obligations, and we apply appropriate safeguards to protect it. +

+ +

Contact

+

+ For privacy questions or data requests, contact{' '} + + {siteConfig.contactEmail} + + . +

+
+ ); +} diff --git a/frontend/src/app/terms/page.tsx b/frontend/src/app/terms/page.tsx new file mode 100644 index 0000000..485ac8b --- /dev/null +++ b/frontend/src/app/terms/page.tsx @@ -0,0 +1,59 @@ +import type { Metadata } from 'next'; +import { siteConfig } from '~/lib/site'; +import SitePage from '~/components/site-page'; +import { buildMetadata } from '~/lib/metadata'; + +export const metadata: Metadata = buildMetadata({ + title: 'Terms of Service', + description: `The terms governing use of the Retailytics platform from ${siteConfig.legalName}.`, + path: '/terms', + index: false, +}); + +export default function TermsPage() { + return ( + +

+ These Terms of Service ("Terms") govern your access to and use + of the Retailytics platform provided by {siteConfig.legalName} + ("we", "us"). By creating an account or using the + service, you agree to these Terms. +

+ +

Use of the service

+

+ You may use Retailytics only in compliance with these Terms and + applicable law. You are responsible for activity under your account and + for keeping your credentials secure. +

+ +

Data and content

+

+ You retain rights to the data you submit. You grant us the rights needed + to host, process, and analyse that data to provide the service. +

+ +

Acceptable use

+

+ You agree not to misuse the service, attempt to disrupt it, or use it to + collect data unlawfully or without proper authorisation. +

+ +

Availability & disclaimer

+

+ The service is provided on an "as is" basis. We do not warrant + that it will be uninterrupted or error-free, and we are not liable for + indirect or consequential damages to the extent permitted by law. +

+ +

Contact

+

+ Questions about these Terms? Contact{' '} + + {siteConfig.contactEmail} + + . +

+
+ ); +} diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 114f539..cfe4564 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -1,6 +1,92 @@ +import Link from 'next/link'; +import type { Route } from 'next'; +import { siteConfig } from '~/lib/site'; + +type FooterLink = { + label: string; + href: string; + external?: boolean; +}; + +type FooterGroup = { + heading: string; + links: FooterLink[]; +}; + +const footerNav: FooterGroup[] = [ + { + heading: 'Product', + links: [ + { label: 'How it works', href: '/#how-it-works' }, + { label: 'FAQ', href: '/#faq' }, + { label: 'Get started', href: '/register' }, + ], + }, + { + heading: 'Company', + links: [ + { label: 'About', href: siteConfig.social.parent, external: true }, + { label: 'Contact', href: '/contact' }, + ], + }, + { + heading: 'Legal', + links: [ + { label: 'Privacy', href: '/privacy' }, + { label: 'Terms', href: '/terms' }, + ], + }, +]; + export default function Footer() { return (