Skip to content

Commit 5f5674d

Browse files
authored
Merge pull request #1458 from techisigu/feat/landing-1345-1348
feat(landing,seo): Step + FooterColumn rebuild, SEO metadata, responsive hardening (#1345 #1346 #1347 #1348)
2 parents ab1bc2c + f0edd2a commit 5f5674d

12 files changed

Lines changed: 406 additions & 49 deletions

File tree

docs/pr/techisigu-1345-1348.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Landing page: Step, FooterColumn, SEO metadata, responsive
2+
3+
Four landing-page issues.
4+
5+
## What changed and why
6+
7+
### #1345 - Step component + real "how it works" flow
8+
- `Step` takes an optional `href` and links the step title to the corresponding feature.
9+
The **step number is not passed in** - `landing.css` already derives it with a CSS
10+
`counter()` from the `<ol>` order, so it can't drift from the visual list.
11+
- The four steps now describe the real, shipped flow: connect a Stellar wallet -> browse
12+
markets -> place a bet -> claim a payout (i18n `howItWorks.*`), each linking to `/markets`
13+
or `/account/bets`.
14+
- Tests: title links to its feature; plain title without `href`; no hardcoded numbering in
15+
the DOM; the `LandingPage` data-driven test asserts the `<ol>` and the four step links.
16+
17+
### #1346 - FooterColumn + real footer navigation
18+
- `FooterColumn` gains a `children` slot (for embeds) and an `external` link flag
19+
(`target="_blank"` + `rel="noopener noreferrer"`).
20+
- The footer columns were rewritten to routes that **exist**: Product (Markets `/markets`,
21+
Statistics `/statistics`, Create a market `/markets/create`), Resources (Documentation +
22+
GitHub, both external), and a "stay in the loop" column linking back to the hero signup
23+
form (a second `<NewsletterSignup>` would duplicate its field ids and break a11y). The
24+
dead `/docs`, `/github` (internal), `/discord`, `/privacy`, `/terms` links and the Legal
25+
column were removed - there are no such pages.
26+
- `e2e/landing-links-responsive.spec.ts` asserts every internal footer link resolves
27+
(`request.get(href)` < 400) - the CI link-check the AC asks for.
28+
- Tests: external vs internal link attributes; `children` render; the data-driven test now
29+
asserts real routes and the absence of the removed links.
30+
31+
### #1347 - SEO metadata + structured data
32+
- `app/layout.tsx`: site-wide defaults - `metadataBase`, a `title` template, Open Graph
33+
`siteName`/`url`, Twitter `summary_large_image`.
34+
- `app/page.tsx` (kept server-side, still code-split via `next/dynamic` with `ssr: true` and
35+
the existing loading fallback): route-level `title` / `description` / canonical / full
36+
Open Graph + Twitter with an `/og-image.png`, plus an `Organization` + `WebSite` JSON-LD
37+
`<script type="application/ld+json">` (a build-time constant, server-rendered so
38+
link-unfurl crawlers see it).
39+
- Locale-specific metadata (#25) is out of scope here - the i18n layer only has an `en`
40+
bundle today; `SITE_URL` reads `NEXT_PUBLIC_SITE_URL` with a default.
41+
- Tests: `landing-metadata.test.ts` asserts the exported `metadata` shape and the layout
42+
defaults.
43+
44+
### #1348 - responsive hardening
45+
- `overflow-wrap: anywhere` on hero/feature/step/footer text so a long word in a translated
46+
locale wraps instead of widening the page.
47+
- Grid tracks changed to `minmax(min(100%, 230px), 1fr)` so a track can shrink below its
48+
content's min-content size; `.footer-content { max-width: 100% }`.
49+
- A `@media (max-width: 360px)` block forces single-column grids and trims inline padding.
50+
- `e2e/landing-links-responsive.spec.ts` asserts `scrollWidth - clientWidth <= 1` at 320 /
51+
768 / 1440.
52+
53+
## How to test
54+
55+
```
56+
cd frontend
57+
PUPPETEER_SKIP_DOWNLOAD=true npm ci --legacy-peer-deps --ignore-scripts
58+
./node_modules/.bin/jest src/components/__tests__/Step.test.tsx \
59+
src/components/__tests__/FooterColumn.test.tsx \
60+
src/components/__tests__/LandingPage.dataDriven.test.tsx \
61+
src/app/__tests__/landing-metadata.test.tsx
62+
```
63+
64+
- 37 tests pass across the touched suites; `page.test.tsx` (the dynamic-loading fallback
65+
test) still passes - the code-split + loading spinner were preserved.
66+
- `tsc --noEmit`: no errors in the touched files over the repo's pre-existing count.
67+
- `e2e/landing-links-responsive.spec.ts` not run here (needs the browser install).
68+
- Pre-existing on `main`, unchanged by this branch (verified against a clean `upstream/main`
69+
worktree - identical 30 failures): `Statistics.test.tsx`, `LandingPage.keyboard.test.tsx`,
70+
`LandingPage.accessibility.test.tsx`, `useAsync.test.ts`, `useReferral.test.ts`.
71+
72+
## Breaking changes
73+
74+
None. New props and metadata only. Footer link targets changed (removed dead links).
75+
76+
## Related issues
77+
78+
Closes #1345
79+
Closes #1346
80+
Closes #1347
81+
Closes #1348
82+
83+
## PR Checklist
84+
85+
- [x] Branch is up to date with `main`
86+
- [x] Commit messages follow Conventional Commits
87+
- [x] Tests added or updated for the change
88+
- [x] Documentation updated if behaviour changed (n/a)
89+
- [x] No secrets or credentials committed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* #1346 - every internal footer link resolves (no dead links to removed pages).
5+
* #1348 - no horizontal scroll at 320 / 768 / 1440.
6+
*/
7+
8+
test.describe('landing footer links resolve (#1346)', () => {
9+
test('every internal footer link returns a non-error status', async ({ page, request }) => {
10+
await page.goto('/');
11+
const footer = page.getByRole('contentinfo');
12+
const hrefs = await footer.locator('a[href^="/"]:not([href^="//"])').evaluateAll((els) =>
13+
Array.from(new Set(els.map((el) => (el as HTMLAnchorElement).getAttribute('href') ?? ''))),
14+
);
15+
16+
expect(hrefs.length).toBeGreaterThan(0);
17+
for (const href of hrefs) {
18+
if (href.startsWith('/#') || href === '/#') continue; // in-page anchor
19+
const res = await request.get(href);
20+
expect(res.status(), `${href} should resolve`).toBeLessThan(400);
21+
}
22+
});
23+
});
24+
25+
test.describe('landing has no horizontal scroll (#1348)', () => {
26+
for (const width of [320, 768, 1440]) {
27+
test(`at ${width}px`, async ({ page }) => {
28+
await page.setViewportSize({ width, height: 900 });
29+
await page.goto('/');
30+
await page.waitForLoadState('networkidle');
31+
await page.evaluate(() => document.fonts.ready);
32+
33+
const overflow = await page.evaluate(() => {
34+
const doc = document.documentElement;
35+
return doc.scrollWidth - doc.clientWidth;
36+
});
37+
// allow a 1px rounding tolerance
38+
expect(overflow, `viewport ${width}px must not scroll horizontally`).toBeLessThanOrEqual(1);
39+
});
40+
}
41+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { metadata } from '../page';
2+
import { metadata as rootMetadata } from '../layout';
3+
4+
/**
5+
* #1347 - the landing route emits SEO metadata (title/description/OG/Twitter) via
6+
* the Next.js Metadata API, and the root layout carries the site-wide defaults.
7+
* The JSON-LD is asserted structurally.
8+
*/
9+
10+
describe('landing page metadata (#1347)', () => {
11+
it('sets a descriptive title and description', () => {
12+
expect(String(metadata.title)).toMatch(/PredictIQ/);
13+
expect(metadata.description).toEqual(expect.stringContaining('prediction markets'));
14+
});
15+
16+
it('has Open Graph and Twitter card metadata with an image', () => {
17+
expect(metadata.openGraph?.type).toBe('website');
18+
expect(metadata.openGraph?.images).toBeTruthy();
19+
expect(metadata.twitter?.card).toBe('summary_large_image');
20+
});
21+
22+
it('resolves relative asset URLs via metadataBase', () => {
23+
expect(metadata.metadataBase).toBeInstanceOf(URL);
24+
expect(metadata.alternates?.canonical).toBe('/');
25+
});
26+
});
27+
28+
describe('root layout metadata', () => {
29+
it('provides site-wide defaults and a title template', () => {
30+
expect(rootMetadata.metadataBase).toBeInstanceOf(URL);
31+
const title = rootMetadata.title as { default: string; template: string };
32+
expect(title.default).toMatch(/PredictIQ/);
33+
expect(title.template).toContain('%s');
34+
expect(rootMetadata.openGraph?.siteName).toBe('PredictIQ');
35+
});
36+
});

frontend/src/app/layout.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,27 @@ const body = Exo_2({
2626
display: 'swap',
2727
});
2828

29+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://predictiq.app';
30+
31+
// Site-wide metadata defaults. Per-route pages (e.g. app/page.tsx) override
32+
// title/description and add route-specific Open Graph data.
2933
export const metadata = {
30-
title: 'PredictIQ — Decentralized Prediction Markets on Stellar',
34+
metadataBase: new URL(SITE_URL),
35+
title: {
36+
default: 'PredictIQ — Decentralized Prediction Markets on Stellar',
37+
template: '%s · PredictIQ',
38+
},
3139
description:
3240
'Create, bet on, and resolve prediction markets with transparency, security, and fairness powered by the Stellar blockchain.',
41+
applicationName: 'PredictIQ',
42+
openGraph: {
43+
type: 'website',
44+
siteName: 'PredictIQ',
45+
url: SITE_URL,
46+
},
47+
twitter: {
48+
card: 'summary_large_image',
49+
},
3350
};
3451

3552
export default async function RootLayout({ children }: { children: ReactNode }) {

frontend/src/app/page.tsx

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,68 @@
1-
'use client';
1+
import type { Metadata } from 'next';
22
import dynamic from 'next/dynamic';
33
import { LoadingSpinner } from '../components/LoadingSpinner';
44

5-
// Dynamic import with code splitting. With ssr: true, next/dynamic manages
6-
// its own loading boundary, so no outer <Suspense> is needed here.
7-
const LandingPage = dynamic(() => import('../components/LandingPage').then(mod => ({ default: mod.LandingPage })), {
8-
loading: () => <LoadingSpinner aria-label="Loading page" />,
9-
ssr: true,
10-
});
5+
// Landing-page SEO (#1347). `metadataBase` lets the relative OG/Twitter image
6+
// paths resolve to absolute URLs for link-unfurl crawlers.
7+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://predictiq.app';
8+
9+
export const metadata: Metadata = {
10+
metadataBase: new URL(SITE_URL),
11+
title: 'PredictIQ — Decentralized Prediction Markets on Stellar',
12+
description:
13+
'Create, bet on, and resolve prediction markets on the Stellar blockchain. Multi-outcome markets, hybrid oracle + community resolution, and instant payouts.',
14+
alternates: { canonical: '/' },
15+
openGraph: {
16+
type: 'website',
17+
url: SITE_URL,
18+
siteName: 'PredictIQ',
19+
title: 'PredictIQ — Decentralized Prediction Markets on Stellar',
20+
description: 'Create, bet on, and resolve prediction markets on the Stellar blockchain.',
21+
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'PredictIQ' }],
22+
},
23+
twitter: {
24+
card: 'summary_large_image',
25+
title: 'PredictIQ — Decentralized Prediction Markets on Stellar',
26+
description: 'Create, bet on, and resolve prediction markets on the Stellar blockchain.',
27+
images: ['/og-image.png'],
28+
},
29+
};
30+
31+
// Organization + WebSite JSON-LD. Build-time constant, no user input, no `<`.
32+
const structuredData = {
33+
'@context': 'https://schema.org',
34+
'@graph': [
35+
{
36+
'@type': 'Organization',
37+
'@id': SITE_URL + '/#organization',
38+
name: 'PredictIQ',
39+
url: SITE_URL,
40+
logo: SITE_URL + '/icons/logo.svg',
41+
sameAs: ['https://github.com/solutions-plug/predictIQ'],
42+
},
43+
{
44+
'@type': 'WebSite',
45+
'@id': SITE_URL + '/#website',
46+
url: SITE_URL,
47+
name: 'PredictIQ',
48+
description: 'Decentralized prediction markets on Stellar.',
49+
publisher: { '@id': SITE_URL + '/#organization' },
50+
},
51+
],
52+
};
53+
54+
// Code-split, with a single accessible loading fallback. `ssr: true` keeps the
55+
// initial HTML server-rendered for crawlers.
56+
const LandingPage = dynamic(
57+
() => import('../components/LandingPage').then((mod) => ({ default: mod.LandingPage })),
58+
{ loading: () => <LoadingSpinner aria-label="Loading page" />, ssr: true },
59+
);
1160

1261
export default function Home() {
13-
return <LandingPage />;
62+
return (
63+
<>
64+
<script type="application/ld+json">{JSON.stringify(structuredData)}</script>
65+
<LandingPage />
66+
</>
67+
);
1468
}

frontend/src/components/LandingPage.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import React from 'react';
24
import { useI18n } from '../lib/hooks/useI18n';
35
import { useDarkMode } from '../lib/hooks/useDarkMode';
@@ -24,22 +26,25 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
2426
];
2527

2628
const steps = [
27-
{ title: t('howItWorks.step1.title'), description: t('howItWorks.step1.description') },
28-
{ title: t('howItWorks.step2.title'), description: t('howItWorks.step2.description') },
29-
{ title: t('howItWorks.step3.title'), description: t('howItWorks.step3.description') },
30-
{ title: t('howItWorks.step4.title'), description: t('howItWorks.step4.description') },
29+
{ title: t('howItWorks.step1.title'), description: t('howItWorks.step1.description'), href: '/markets' },
30+
{ title: t('howItWorks.step2.title'), description: t('howItWorks.step2.description'), href: '/markets' },
31+
{ title: t('howItWorks.step3.title'), description: t('howItWorks.step3.description'), href: '/markets' },
32+
{ title: t('howItWorks.step4.title'), description: t('howItWorks.step4.description'), href: '/account/bets' },
3133
];
3234

35+
// Product links point at routes that actually exist; Resources links are
36+
// external. The Legal column was dropped - there are no privacy/terms pages
37+
// yet, and #1346 requires no dead links.
3338
const footerColumns = [
3439
{ heading: t('footer.title'), headingLevel: 'h2' as const, tagline: t('footer.tagline') },
35-
{ heading: t('footer.linksHeading'), links: [
36-
{ href: '/docs', label: t('footer.documentation') },
37-
{ href: '/github', label: t('footer.github') },
38-
{ href: '/discord', label: t('footer.discord') },
40+
{ heading: t('footer.productHeading'), links: [
41+
{ href: '/markets', label: t('footer.markets') },
42+
{ href: '/statistics', label: t('footer.statistics') },
43+
{ href: '/markets/create', label: t('footer.createMarket') },
3944
] },
40-
{ heading: t('footer.legalHeading'), links: [
41-
{ href: '/privacy', label: t('footer.privacy') },
42-
{ href: '/terms', label: t('footer.terms') },
45+
{ heading: t('footer.resourcesHeading'), links: [
46+
{ href: 'https://github.com/solutions-plug/predictIQ#readme', label: t('footer.documentation'), external: true },
47+
{ href: 'https://github.com/solutions-plug/predictIQ', label: t('footer.github'), external: true },
4348
] },
4449
];
4550

@@ -192,8 +197,15 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
192197
{footerColumns.map((column) => (
193198
<FooterColumn key={column.heading} {...column} />
194199
))}
200+
<FooterColumn heading={t('footer.newsletterHeading')}>
201+
{/* The signup form itself lives in the hero (one set of field ids on
202+
the page); the footer points people back to it. */}
203+
<p>
204+
<a href="#main-content">{t('hero.signupHeading')}</a>
205+
</p>
206+
</FooterColumn>
195207
</div>
196-
208+
197209
<div className="footer-bottom">
198210
<p>{t('footer.copyright')}</p>
199211
</div>

frontend/src/components/__tests__/FooterColumn.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,32 @@ describe('FooterColumn', () => {
5656
);
5757
expect(screen.getAllByRole('link', { name: 'Guide' })).toHaveLength(2);
5858
});
59+
60+
it('external links open in a new tab with a safe rel', () => {
61+
render(
62+
<FooterColumn
63+
heading="Resources"
64+
links={[{ href: 'https://github.com/x/y', label: 'GitHub', external: true }]}
65+
/>,
66+
);
67+
const link = screen.getByRole('link', { name: 'GitHub' });
68+
expect(link).toHaveAttribute('target', '_blank');
69+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
70+
});
71+
72+
it('internal links get no target/rel', () => {
73+
render(<FooterColumn heading="Product" links={[{ href: '/markets', label: 'Markets' }]} />);
74+
const link = screen.getByRole('link', { name: 'Markets' });
75+
expect(link).not.toHaveAttribute('target');
76+
expect(link).not.toHaveAttribute('rel');
77+
});
78+
79+
it('renders arbitrary children (e.g. a newsletter embed) under the heading', () => {
80+
render(
81+
<FooterColumn heading="Stay in the loop">
82+
<button type="button">Subscribe</button>
83+
</FooterColumn>,
84+
);
85+
expect(screen.getByRole('button', { name: 'Subscribe' })).toBeInTheDocument();
86+
});
5987
});

0 commit comments

Comments
 (0)