feat(seo): coming-soon industry pages and round-2 title fixes #2

Merged
mohammad merged 1 commits from seo-round-2 into main 2026-10-19 09:52:53 +00:00
10 changed files with 227 additions and 27 deletions
Showing only changes of commit bcd7bd2c7b - Show all commits
+1 -1
View File
@@ -3,7 +3,7 @@ import { pageMetadata } from '@/lib/seo';
// The page itself is a client component and cannot export metadata. // The page itself is a client component and cannot export metadata.
export const metadata: Metadata = pageMetadata({ export const metadata: Metadata = pageMetadata({
title: 'Unitree G1 3D Configurator', title: 'Configure Your Robot and Request a UAE Quotation',
description: description:
'Build your Unitree G1 in 3D. Choose persona, attire, colours and accessories, preview it live, then request a UAE quotation from YS Lootah Robotics.', 'Build your Unitree G1 in 3D. Choose persona, attire, colours and accessories, preview it live, then request a UAE quotation from YS Lootah Robotics.',
path: '/configure/', path: '/configure/',
+171 -8
View File
@@ -24,9 +24,10 @@ import { Navbar } from '@/components/Navbar';
import { FooterAndContact } from '@/components/FooterAndContact'; import { FooterAndContact } from '@/components/FooterAndContact';
import { MotionSection } from '@/components/ui/MotionSection'; import { MotionSection } from '@/components/ui/MotionSection';
import { CTAButton } from '@/components/ui/CTAButton'; import { CTAButton } from '@/components/ui/CTAButton';
import { INDUSTRIES, getIndustryBySlug } from '@/data/industries'; import { INDUSTRIES, UPCOMING_INDUSTRIES, getIndustryBySlug, getUpcomingIndustryBySlug, type Industry } from '@/data/industries';
import { ROBOTS } from '@/data/robots';
import { INDUSTRY_PAGES, type IndustrySolution } from '@/data/industry-pages'; import { INDUSTRY_PAGES, type IndustrySolution } from '@/data/industry-pages';
import { pageMetadata } from '@/lib/seo'; import { displayName, pageMetadata } from '@/lib/seo';
type Params = { slug: string }; type Params = { slug: string };
@@ -50,11 +51,21 @@ const ICONS: Record<IndustrySolution['iconKey'], React.ComponentType<{ size?: nu
export const dynamicParams = false; export const dynamicParams = false;
export function generateStaticParams() { export function generateStaticParams() {
return INDUSTRIES.map((i) => ({ slug: i.slug })); return [...INDUSTRIES, ...UPCOMING_INDUSTRIES].map((i) => ({ slug: i.slug }));
} }
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const soon = getUpcomingIndustryBySlug(slug);
if (soon?.upcoming) {
return pageMetadata({
title: soon.upcoming.seoTitle,
description: soon.upcoming.seoDescription,
path: `/industries/${soon.slug}`,
image: soon.image,
imageAlt: soon.imageAlt,
});
}
const industry = getIndustryBySlug(slug); const industry = getIndustryBySlug(slug);
if (!industry) return {}; if (!industry) return {};
const content = INDUSTRY_PAGES[industry.id]; const content = INDUSTRY_PAGES[industry.id];
@@ -77,6 +88,8 @@ const TRUST_DEFAULT = [
export default async function IndustryDetailPage({ params }: { params: Promise<Params> }) { export default async function IndustryDetailPage({ params }: { params: Promise<Params> }) {
const { slug } = await params; const { slug } = await params;
const upcoming = getUpcomingIndustryBySlug(slug);
if (upcoming) return <ComingSoonIndustry industry={upcoming} />;
const industry = getIndustryBySlug(slug); const industry = getIndustryBySlug(slug);
if (!industry) notFound(); if (!industry) notFound();
const content = INDUSTRY_PAGES[industry.id]; const content = INDUSTRY_PAGES[industry.id];
@@ -441,7 +454,160 @@ export default async function IndustryDetailPage({ params }: { params: Promise<P
<FooterAndContact /> <FooterAndContact />
<style>{` <style>{IP_STYLES}</style>
</>
);
}
// Short page for a sector whose full content is not written yet. Same layout
// and styles as the full page, built from the card copy and matching robots.
function ComingSoonIndustry({ industry }: { industry: Industry }) {
const accent = industry.accent;
const robots = ROBOTS.filter((r) => r.categories.some((c) => industry.matchCategories.includes(c))).slice(0, 6);
return (
<>
<Navbar />
<main className="ip-main">
<div className="container-wide ip-stack">
<nav aria-label="Breadcrumb" className="ip-breadcrumbs">
<ol>
<li><Link href="/">Home</Link></li>
<li aria-hidden><ChevronRight size={12} /></li>
<li><Link href="/industries/">Industries</Link></li>
<li aria-hidden><ChevronRight size={12} /></li>
<li aria-current="page">{industry.name}</li>
</ol>
</nav>
<MotionSection>
<section className="ip-hero" style={{ ['--acc' as string]: accent }}>
<div className="ip-hero-glow" aria-hidden />
<div className="ip-hero-glow-b" aria-hidden />
<div className="ip-hero-grid" aria-hidden />
<div className="ip-hero-inner">
<div className="ip-hero-copy">
<span className="eyebrow ip-hero-eyebrow">
<span className="ip-dot" />
Industry Solution · Coming soon
</span>
<h1 className="ip-hero-title">
<span className="text-gradient">Robots for {industry.name}</span>
</h1>
<p className="ip-hero-sub">
{industry.solution} A full guide for this sector is coming soon. Our Dubai team can already
advise on robots and arrange a demo.
</p>
<div className="ip-hero-actions">
<CTAButton href="/book-demo/" variant="primary" size="lg" arrow="up-right">
Book a Demo
</CTAButton>
<CTAButton href="/contact/" variant="secondary" size="lg" arrow="right">
Talk to Our Team
</CTAButton>
</div>
</div>
<aside className="ip-hero-visual">
<Image
src={industry.image}
alt={industry.imageAlt}
fill
sizes="(max-width: 920px) 100vw, 520px"
className="ip-hero-visual-img"
style={{ objectFit: 'cover', objectPosition: 'center' }}
priority
/>
<span className="ip-hero-visual-overlay" aria-hidden />
<div className="ip-hero-visual-meta">
<span className="ip-mono ip-live">
<span className="ip-live-dot" />Coming soon · UAE
</span>
<span className="ip-mono">Dubai Showroom · Live Demo</span>
</div>
</aside>
</div>
</section>
</MotionSection>
<MotionSection>
<section className="ip-block">
<header className="ip-block-head">
<span className="eyebrow">At a glance</span>
<h2 className="ip-block-title">What robots change for {industry.name.toLowerCase()}.</h2>
</header>
<ul className="ip-problems" role="list">
{[industry.problem, industry.solution, industry.benefit].map((p, i) => (
<li key={i} className="ip-problem">
<span className="ip-problem-num">{String(i + 1).padStart(2, '0')}</span>
<p>{p}</p>
</li>
))}
</ul>
</section>
</MotionSection>
{robots.length > 0 && (
<MotionSection>
<section className="ip-block" style={{ ['--acc' as string]: accent }}>
<header className="ip-block-head">
<span className="eyebrow">Suggested robots</span>
<h2 className="ip-block-title">Robots we would start with.</h2>
</header>
<div className="ip-robots">
{robots.map((r) => (
<article key={r.slug} className="ip-robot">
<div className="ip-robot-head">
<span className="ip-robot-brand">{r.brandLabel}</span>
<span className="ip-robot-arrow"><ArrowRight size={14} strokeWidth={1.8} /></span>
</div>
<h3>{displayName(r.brandLabel, r.name)}</h3>
<p>{r.tagline}</p>
<Link href={`/robots/${r.slug}/`} className="ip-robot-link">
View robot <ArrowRight size={13} strokeWidth={2} />
</Link>
</article>
))}
</div>
</section>
</MotionSection>
)}
<MotionSection>
<section className="ip-cta">
<div className="ip-cta-glow" aria-hidden />
<div className="ip-cta-grid" aria-hidden />
<div className="ip-cta-inner">
<div className="ip-cta-text">
<span className="eyebrow">Next step</span>
<h2 className="ip-cta-title">Planning robots for {industry.name.toLowerCase()}?</h2>
<p>Tell us about your venue and goals. Our Dubai team will recommend robots and arrange a live demo or quotation.</p>
</div>
<div className="ip-cta-actions">
<CTAButton href="/book-demo/" variant="primary" size="lg" arrow="up-right">
Book a Live Demo
</CTAButton>
<CTAButton href="/contact/" variant="secondary" size="lg" arrow="right">
Request UAE Quotation
</CTAButton>
</div>
</div>
</section>
</MotionSection>
</div>
</main>
<FooterAndContact />
<style>{IP_STYLES}</style>
</>
);
}
const IP_STYLES = `
.ip-main { .ip-main {
padding-top: clamp(5rem, 9vw, 7rem); padding-top: clamp(5rem, 9vw, 7rem);
padding-bottom: clamp(2rem, 5vw, 4rem); padding-bottom: clamp(2rem, 5vw, 4rem);
@@ -1098,7 +1264,4 @@ export default async function IndustryDetailPage({ params }: { params: Promise<P
.ip-cta-actions .cta-btn { width: 100%; justify-content: space-between; } .ip-cta-actions .cta-btn { width: 100%; justify-content: space-between; }
.ip-hero-actions .cta-btn { width: 100%; justify-content: space-between; } .ip-hero-actions .cta-btn { width: 100%; justify-content: space-between; }
} }
`}</style> `;
</>
);
}
+7 -2
View File
@@ -16,11 +16,17 @@ import { FounderSection } from '@/components/robotics/FounderSection';
import { BuSunaidahSection } from '@/components/robotics/BuSunaidahSection'; import { BuSunaidahSection } from '@/components/robotics/BuSunaidahSection';
import { MotionSection } from '@/components/ui/MotionSection'; import { MotionSection } from '@/components/ui/MotionSection';
import { FEATURED_ROBOTS } from '@/data/robots'; import { FEATURED_ROBOTS } from '@/data/robots';
import { pageMetadata } from '@/lib/seo';
// `absolute` keeps the layout template from adding the brand a second time.
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'YS Lootah Robotics UAE Destination for Unitree & Pudu Robotics', ...pageMetadata({
title: 'Robot Shop and Configurator in the UAE',
description: description:
'YS Lootah Robotics is the UAE sales destination for selected Unitree and Pudu Robotics solutions. Explore, configure, book demos, and deploy advanced robots across Dubai and the UAE.', 'YS Lootah Robotics is the UAE sales destination for selected Unitree and Pudu Robotics solutions. Explore, configure, book demos, and deploy advanced robots across Dubai and the UAE.',
path: '/',
}),
title: { absolute: 'Robot Shop and Configurator in the UAE | YS Lootah Robotics' },
keywords: [ keywords: [
'YS Lootah Robotics', 'YS Lootah Robotics',
'robotics Dubai', 'robotics Dubai',
@@ -35,7 +41,6 @@ export const metadata: Metadata = {
'robotics configuration UAE', 'robotics configuration UAE',
'robot sales and demo UAE', 'robot sales and demo UAE',
], ],
alternates: { canonical: '/' },
}; };
export default function HomePage() { export default function HomePage() {
+2 -2
View File
@@ -1,6 +1,6 @@
import { MetadataRoute } from 'next'; import { MetadataRoute } from 'next';
import { ROBOTS } from '@/data/robots'; import { ROBOTS } from '@/data/robots';
import { INDUSTRIES } from '@/data/industries'; import { INDUSTRIES, UPCOMING_INDUSTRIES } from '@/data/industries';
import { ACCESSORIES } from '@/data/accessories'; import { ACCESSORIES } from '@/data/accessories';
import { SITE_URL } from '@/lib/seo'; import { SITE_URL } from '@/lib/seo';
@@ -30,7 +30,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.7, priority: 0.7,
})); }));
const industryRoutes: MetadataRoute.Sitemap = INDUSTRIES.map((i) => ({ const industryRoutes: MetadataRoute.Sitemap = [...INDUSTRIES, ...UPCOMING_INDUSTRIES].map((i) => ({
url: u(`/industries/${i.slug}/`), url: u(`/industries/${i.slug}/`),
lastModified: now, lastModified: now,
changeFrequency: 'monthly', changeFrequency: 'monthly',
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { ACCESSORIES, GROUP_LABELS, type AccessoryGroup, type Accessory } from '@/data/accessories'; import { ACCESSORIES, GROUP_LABELS, type AccessoryGroup, type Accessory } from '@/data/accessories';
import { displayName } from '@/lib/seo';
type Filter = AccessoryGroup | 'all'; type Filter = AccessoryGroup | 'all';
@@ -97,7 +98,7 @@ function AccessoryCard({ accessory }: { accessory: Accessory }) {
> >
<Link <Link
href={detailHref} href={detailHref}
aria-label={`View details for ${accessory.brandLabel} ${accessory.name}`} aria-label={`View details for ${displayName(accessory.brandLabel, accessory.name)}`}
style={{ style={{
position: 'relative', position: 'relative',
display: 'block', display: 'block',
@@ -123,7 +124,7 @@ function AccessoryCard({ accessory }: { accessory: Accessory }) {
/> />
<Image <Image
src={accessory.image} src={accessory.image}
alt={`${accessory.brandLabel} ${accessory.name}`} alt={displayName(accessory.brandLabel, accessory.name)}
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ style={{
+2 -1
View File
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { PremiumButton } from '@/components/ui/PremiumButton'; import { PremiumButton } from '@/components/ui/PremiumButton';
import { CursorSpotlight } from '@/components/ui/CursorSpotlight'; import { CursorSpotlight } from '@/components/ui/CursorSpotlight';
import { FEATURED_ROBOTS } from '@/data/robots'; import { FEATURED_ROBOTS } from '@/data/robots';
import { displayName } from '@/lib/seo';
const HERO_STATS = [ const HERO_STATS = [
{ value: 'UAE', label: 'Access' }, { value: 'UAE', label: 'Access' },
@@ -341,7 +342,7 @@ export function Hero3DRobotics() {
<div style={{ position: 'relative', width: '100%', height: '100%' }}> <div style={{ position: 'relative', width: '100%', height: '100%' }}>
<Image <Image
src={r.image} src={r.image}
alt={`${r.brandLabel} ${r.name}`} alt={displayName(r.brandLabel, r.name)}
fill fill
sizes="(max-width: 768px) 90vw, 620px" sizes="(max-width: 768px) 90vw, 620px"
style={{ objectFit: 'contain', objectPosition: 'center bottom' }} style={{ objectFit: 'contain', objectPosition: 'center bottom' }}
+3 -3
View File
@@ -15,7 +15,7 @@ import Sparkles from 'lucide-react/dist/esm/icons/sparkles';
import Landmark from 'lucide-react/dist/esm/icons/landmark'; import Landmark from 'lucide-react/dist/esm/icons/landmark';
import ArrowRight from 'lucide-react/dist/esm/icons/arrow-right'; import ArrowRight from 'lucide-react/dist/esm/icons/arrow-right';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { INDUSTRIES, type Industry } from '@/data/industries'; import { INDUSTRIES, UPCOMING_INDUSTRIES, type Industry } from '@/data/industries';
const ease = [0.16, 1, 0.3, 1] as const; const ease = [0.16, 1, 0.3, 1] as const;
@@ -53,7 +53,7 @@ const STATS = [
] as const; ] as const;
export function IndustryUseCases({ limit }: { limit?: number }) { export function IndustryUseCases({ limit }: { limit?: number }) {
const list = limit ? INDUSTRIES.slice(0, limit) : INDUSTRIES; const list = limit ? INDUSTRIES.slice(0, limit) : [...INDUSTRIES, ...UPCOMING_INDUSTRIES];
return ( return (
<section className="iu" aria-label="Industries served"> <section className="iu" aria-label="Industries served">
@@ -232,7 +232,7 @@ function IndustryCard({ industry, delay }: { industry: Industry; delay: number }
<h3 className="ic-title">{industry.name}</h3> <h3 className="ic-title">{industry.name}</h3>
<p className="ic-desc">{blurb}</p> <p className="ic-desc">{blurb}</p>
<span className="ic-cta"> <span className="ic-cta">
<span>Learn more</span> <span>{industry.upcoming ? 'Coming soon' : 'Learn more'}</span>
<span className="ic-cta-arrow" aria-hidden> <span className="ic-cta-arrow" aria-hidden>
<ArrowRight size={14} strokeWidth={2} /> <ArrowRight size={14} strokeWidth={2} />
</span> </span>
+3 -2
View File
@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import Image from 'next/image'; import Image from 'next/image';
import { Robot, CATEGORY_LABELS } from '@/data/robots'; import { Robot, CATEGORY_LABELS } from '@/data/robots';
import { displayName } from '@/lib/seo';
type Props = { type Props = {
robot: Robot; robot: Robot;
@@ -24,7 +25,7 @@ export function RobotProductCard({ robot, priority = false }: Props) {
> >
<Link <Link
href={detailHref} href={detailHref}
aria-label={`View details for ${robot.brandLabel} ${robot.name}`} aria-label={`View details for ${displayName(robot.brandLabel, robot.name)}`}
style={{ style={{
position: 'relative', position: 'relative',
display: 'block', display: 'block',
@@ -50,7 +51,7 @@ export function RobotProductCard({ robot, priority = false }: Props) {
/> />
<Image <Image
src={robot.image} src={robot.image}
alt={`${robot.brandLabel} ${robot.name}`} alt={displayName(robot.brandLabel, robot.name)}
fill fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ style={{
+24 -3
View File
@@ -12,6 +12,8 @@ export interface Industry {
icon: 'building' | 'utensils' | 'hotel' | 'shopping-bag' | 'heart-pulse' | 'graduation-cap' | 'shield' | 'warehouse' | 'sparkles' | 'landmark'; icon: 'building' | 'utensils' | 'hotel' | 'shopping-bag' | 'heart-pulse' | 'graduation-cap' | 'shield' | 'warehouse' | 'sparkles' | 'landmark';
image: string; image: string;
imageAlt: string; imageAlt: string;
/** Sector with a short coming-soon page instead of a full INDUSTRY_PAGES entry. */
upcoming?: { seoTitle: string; seoDescription: string };
} }
export const INDUSTRIES: Industry[] = [ export const INDUSTRIES: Industry[] = [
@@ -121,9 +123,9 @@ export const INDUSTRIES: Industry[] = [
}, },
]; ];
// Sectors with no detail page content yet (no entry in INDUSTRY_PAGES). They are // Sectors with no full detail page content yet (no entry in INDUSTRY_PAGES).
// kept out of INDUSTRIES so no card, sitemap entry or route points at an empty // Each renders a short coming-soon page from its `upcoming` copy. Move an entry
// page. Move an entry back up once its content is written. // up into INDUSTRIES once its INDUSTRY_PAGES content is written.
export const UPCOMING_INDUSTRIES: Industry[] = [ export const UPCOMING_INDUSTRIES: Industry[] = [
{ {
id: 'events', id: 'events',
@@ -137,6 +139,11 @@ export const UPCOMING_INDUSTRIES: Industry[] = [
icon: 'sparkles', icon: 'sparkles',
image: '/industries/cards/events.webp', image: '/industries/cards/events.webp',
imageAlt: 'Robotics activation for UAE live events and brand experiences', imageAlt: 'Robotics activation for UAE live events and brand experiences',
upcoming: {
seoTitle: 'Robots for Events and Exhibitions in the UAE',
seoDescription:
'Humanoid and quadruped robot showcases for UAE events, exhibitions and brand activations. Book a demo with YS Lootah Robotics in Dubai.',
},
}, },
{ {
id: 'smart-buildings', id: 'smart-buildings',
@@ -150,6 +157,11 @@ export const UPCOMING_INDUSTRIES: Industry[] = [
icon: 'building', icon: 'building',
image: '/industries/cards/smart-buildings.webp', image: '/industries/cards/smart-buildings.webp',
imageAlt: 'Cleaning robot in a modern UAE smart office building', imageAlt: 'Cleaning robot in a modern UAE smart office building',
upcoming: {
seoTitle: 'Robots for Smart Buildings in the UAE',
seoDescription:
'Cleaning, delivery and patrol robots working as one fleet in UAE smart buildings. Talk to YS Lootah Robotics about a deployment in Dubai.',
},
}, },
{ {
id: 'government', id: 'government',
@@ -163,9 +175,18 @@ export const UPCOMING_INDUSTRIES: Industry[] = [
icon: 'landmark', icon: 'landmark',
image: '/industries/unitree/utility-room.webp', image: '/industries/unitree/utility-room.webp',
imageAlt: 'Quadruped robots deployed for UAE civic and emergency services', imageAlt: 'Quadruped robots deployed for UAE civic and emergency services',
upcoming: {
seoTitle: 'Robots for Government and Public Sector in the UAE',
seoDescription:
'Humanoid, quadruped and service robots for UAE government and public sector programs, with local deployment and support from YS Lootah Robotics.',
},
}, },
]; ];
export function getIndustryBySlug(slug: string): Industry | undefined { export function getIndustryBySlug(slug: string): Industry | undefined {
return INDUSTRIES.find((i) => i.slug === slug); return INDUSTRIES.find((i) => i.slug === slug);
} }
export function getUpcomingIndustryBySlug(slug: string): Industry | undefined {
return UPCOMING_INDUSTRIES.find((i) => i.slug === slug);
}
+9 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ROBOTS } from '@/data/robots'; import { ROBOTS } from '@/data/robots';
import { ACCESSORIES } from '@/data/accessories'; import { ACCESSORIES } from '@/data/accessories';
import { INDUSTRIES } from '@/data/industries'; import { INDUSTRIES, UPCOMING_INDUSTRIES } from '@/data/industries';
import { INDUSTRY_PAGES } from '@/data/industry-pages'; import { INDUSTRY_PAGES } from '@/data/industry-pages';
import { canonical, displayName, pageMetadata, siteName } from './seo'; import { canonical, displayName, pageMetadata, siteName } from './seo';
@@ -55,4 +55,12 @@ describe('industries', () => {
expect(page.seoTitle).not.toContain(siteName); expect(page.seoTitle).not.toContain(siteName);
} }
}); });
it('gives every upcoming sector its own coming-soon title', () => {
for (const industry of UPCOMING_INDUSTRIES) {
expect(industry.upcoming?.seoTitle, industry.slug).toBeTruthy();
expect(industry.upcoming?.seoTitle).not.toContain(siteName);
expect(INDUSTRIES.some((i) => i.slug === industry.slug), industry.slug).toBe(false);
}
});
}); });