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/contact/page.tsx b/frontend/src/app/contact/page.tsx new file mode 100644 index 0000000..316f367 --- /dev/null +++ b/frontend/src/app/contact/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; +import { siteConfig } from '~/lib/site'; +import SitePage from '~/components/site-page'; +import { buildMetadata } from '~/lib/metadata'; +import { TrackedMailto } from '~/components/analytics/tracked-mailto'; + +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/layout.tsx b/frontend/src/app/layout.tsx index a91519d..c2de941 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,12 +1,19 @@ import './globals.css'; +import { env } from '~/env'; import Providers from './providers'; import type { Metadata } from 'next'; import { Suspense } from 'react'; import Loader from '~/components/loader'; import { Outfit } from 'next/font/google'; import { siteConfig } from '~/lib/site'; +import { JsonLd } from '~/components/json-ld'; import { Toaster } from '~/components/ui/sonner'; +import { siteGraph } from '~/lib/structured-data'; import { Analytics } from '@vercel/analytics/next'; +import { + GoogleTagManager, + GoogleTagManagerNoScript, +} from '~/components/analytics/google-tag-manager'; const outfit = Outfit({ variable: '--font-outfit', @@ -28,6 +35,24 @@ export const metadata: Metadata = { alternates: { canonical: '/', }, + verification: env.GOOGLE_SITE_VERIFICATION + ? { google: env.GOOGLE_SITE_VERIFICATION } + : undefined, + 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({ @@ -43,8 +68,11 @@ export default function RootLayout({ crossOrigin="anonymous" src="https://tweakcn.com/live-preview.min.js" /> + + + }> {children} diff --git a/frontend/src/app/llms.txt/route.ts b/frontend/src/app/llms.txt/route.ts new file mode 100644 index 0000000..fbe9dda --- /dev/null +++ b/frontend/src/app/llms.txt/route.ts @@ -0,0 +1,40 @@ +import { absoluteUrl, siteConfig } from '~/lib/site'; + +const features = [ + 'Assign enumerators to specific geographic areas', + 'Capture detailed retail store data in the field', + 'Real-time submission with quality-controlled validation', + 'Market analytics and reporting', + 'Location and market intelligence', +]; + +export function GET() { + const body = `# ${siteConfig.name} + +> ${siteConfig.description} + +${siteConfig.name} is a retail intelligence platform built by ${siteConfig.legalName} — it helps teams assign enumerators to geographic areas, capture detailed retail store data in the field, validate submissions through quality control, and analyse the results to answer market questions. + +## Core pages +- [Home](${absoluteUrl('/')}): Product overview, how it works, and FAQ. +- [How it works](${absoluteUrl('/#how-it-works')}): The four-step enumeration-to-insight workflow. +- [FAQ](${absoluteUrl('/#faq')}): Common questions about the platform and the data it collects. +- [Contact](${absoluteUrl('/contact')}): Reach the team for demos, partnerships, and support. + +## Company +${siteConfig.name} is a product of ${siteConfig.legalName} (${siteConfig.social.parent}, ${siteConfig.social.parentCa}), an AI research and product studio that builds information systems and data products. Contact: ${siteConfig.contactEmail}. + +## Key features +${features.map((feature) => `- ${feature}`).join('\n')} + +## Legal +- [Privacy Policy](${absoluteUrl('/privacy')}) +- [Terms of Service](${absoluteUrl('/terms')}) +`; + + return new Response(body, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + }, + }); +} 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..73e0795 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,20 +1,18 @@ 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'; +import { EngagementTracker } from '~/components/analytics/engagement-tracker'; -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/privacy/page.tsx b/frontend/src/app/privacy/page.tsx new file mode 100644 index 0000000..7072950 --- /dev/null +++ b/frontend/src/app/privacy/page.tsx @@ -0,0 +1,64 @@ +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/robots.ts b/frontend/src/app/robots.ts new file mode 100644 index 0000000..71dca96 --- /dev/null +++ b/frontend/src/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from 'next'; +import { siteConfig } from '~/lib/site'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + disallow: ['/api/', '/admin', '/user'], + }, + sitemap: `${siteConfig.url}/sitemap.xml`, + host: siteConfig.url, + }; +} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts new file mode 100644 index 0000000..873df31 --- /dev/null +++ b/frontend/src/app/sitemap.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from 'next'; +import { absoluteUrl, publicRoutes } from '~/lib/site'; + +export default function sitemap(): MetadataRoute.Sitemap { + const lastModified = new Date(); + + return publicRoutes.map((route) => ({ + url: absoluteUrl(route.path), + lastModified, + changeFrequency: route.changeFrequency, + priority: route.priority, + })); +} diff --git a/frontend/src/app/terms/page.tsx b/frontend/src/app/terms/page.tsx new file mode 100644 index 0000000..d0739b2 --- /dev/null +++ b/frontend/src/app/terms/page.tsx @@ -0,0 +1,63 @@ +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/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/components/analytics/engagement-tracker.tsx b/frontend/src/components/analytics/engagement-tracker.tsx new file mode 100644 index 0000000..5904f41 --- /dev/null +++ b/frontend/src/components/analytics/engagement-tracker.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { sendGTMEvent } from '~/lib/analytics'; + +const ENGAGEMENT_DELAY_MS = 30_000; +const ENGAGEMENT_SCROLL_RATIO = 0.5; + +export function EngagementTracker() { + const firedRef = useRef(false); + + useEffect(() => { + const fire = (trigger: 'time' | 'scroll') => { + if (firedRef.current) return; + firedRef.current = true; + sendGTMEvent('engaged', { trigger }); + window.removeEventListener('scroll', onScroll); + window.clearTimeout(timer); + }; + + const onScroll = () => { + const scrolled = + window.scrollY / (document.body.scrollHeight - window.innerHeight || 1); + if (scrolled >= ENGAGEMENT_SCROLL_RATIO) fire('scroll'); + }; + + const timer = window.setTimeout(() => fire('time'), ENGAGEMENT_DELAY_MS); + window.addEventListener('scroll', onScroll, { passive: true }); + + return () => { + window.removeEventListener('scroll', onScroll); + window.clearTimeout(timer); + }; + }, []); + + return null; +} diff --git a/frontend/src/components/analytics/google-tag-manager.tsx b/frontend/src/components/analytics/google-tag-manager.tsx new file mode 100644 index 0000000..3c0d7e7 --- /dev/null +++ b/frontend/src/components/analytics/google-tag-manager.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Script from 'next/script'; +import { env } from '~/env'; + +export function GoogleTagManager() { + const gtmId = env.NEXT_PUBLIC_GTM_ID; + + if (!gtmId) return null; + + return ( + + ); +} + +export function GoogleTagManagerNoScript() { + const gtmId = env.NEXT_PUBLIC_GTM_ID; + + if (!gtmId) return null; + + return ( +