Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/pr/spotkorner-dot-1337-1342-1343-1344.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Deprecation banner, schema-path sweep, hero CTAs, feature-card links

Four `frontend/` issues (API client + landing page).

## What changed and why

### #1337 - surface API deprecation headers
New end-to-end path for the `Deprecation` / `Sunset` / `Link` response headers
(`API_SPEC.md`'s 12-month deprecation policy):
- `lib/api/deprecation.ts` - a small signal bus. `reportResponseHeaders(headers)` acts only
when `Deprecation` is present and not `false` (so one stale non-deprecated response can
neither fabricate nor clear the banner); it parses `Sunset` and the migration URL from
`Link` (`rel="deprecation"` / `"sunset"` / `"successor-version"`, falling back to a bare
`<url>`).
- Both request helpers call it after every response.
- `components/DeprecationBanner.tsx` - a dismissible, non-blocking `role="status"` banner
with the formatted sunset date and migration link. Dismissal is stored in `localStorage`
**keyed by sunset date**: it stays hidden across visits, but a new/changed sunset date
brings it back.
- Tests: no-op without the header / with `Deprecation: false`; opens with parsed
sunset + link; a later plain response doesn't clear it; dismissal persists per date; a new
date re-shows.

### #1342 - schema-path contract sweep
The `client-schema-path-contract.test.ts` / `client-schema-type-contract.test.ts` pair
already exists and passes (the type test fails `tsc` on drift; the path test checks 10
endpoints). Added a whole-source sweep: every `"/api/..."` / `"/health"` string literal in
`public-client.ts` + `admin-client.ts` must be a key in `schema.d.ts`'s `paths` **or** in an
explicit `KNOWN_UNLISTED` set (placeBet #78, newsletter, email admin endpoints). A new
client method with a mistyped or un-schema'd path now fails this test at PR time rather than
404ing in production.

### #1343 - hero CTAs
The hero had only the newsletter form. Added a primary CTA (`Explore markets` -> `/markets`,
into the live product) and a secondary CTA (`See how it works` -> `#how-it-works`) as plain
`<a>` links, plus i18n keys and `.hero-cta` styles. Motion: the repo's global
`prefers-reduced-motion` rule (`accessibility.css`) already disables the hero's entrance
animations; the new CTAs carry no auto-playing motion.
- Tests: primary CTA `href="/markets"`, secondary `href="#how-it-works"` and the target
section exists.

### #1344 - feature cards as links + real copy
- `FeatureCard` takes an optional `href`; when set, the whole card is a single
keyboard-reachable `<a>` (with an `.sr-only` "learn more" suffix and a `:focus-visible`
outline). Without `href` it stays a plain `<article>`.
- The three feature entries now describe real platform capabilities - multi-outcome markets,
hybrid oracle + community resolution with a dispute window, and Stellar settlement with
the referral program - instead of generic "Fast / Secure / Decentralized" copy. Each links
to `/markets`.
- `.features-grid` gets an explicit `grid-template-columns: 1fr` below the 520px breakpoint
(on top of the existing `auto-fit` reflow) so the single-column layout is unambiguous.
- Tests: card is/ isn't a link per `href`; keyboard-reachable; the LandingPage data-driven
and i18n tests updated for the new copy.

## How to test

```
cd frontend
PUPPETEER_SKIP_DOWNLOAD=true npm ci --legacy-peer-deps --ignore-scripts
./node_modules/.bin/jest src/lib/api src/components/__tests__/FeatureCard.test.tsx \
src/components/__tests__/DeprecationBanner.test.tsx src/components/__tests__/LandingPage.hero.test.tsx
```

- 150 tests pass across the touched suites.
- `tsc --noEmit`: no errors in the touched files over the repo's pre-existing count.
- Pre-existing on `main` (unchanged by this PR): `Statistics.test.tsx`,
`LandingPage.keyboard.test.tsx`, and `LandingPage.accessibility.test.tsx` are red
(identical 12 failures with or without this branch - a stale `getStatistics` mock shape
and an ambiguous `getByRole('alert')` after an earlier Newsletter/Statistics refactor;
the alert scoping is fixed in the FE-037 PR).

## Breaking changes

None. New exports and props only.

## Related issues

Closes #1337
Closes #1342
Closes #1343
Closes #1344

## PR Checklist

- [x] Branch is up to date with `main`
- [x] Commit messages follow Conventional Commits
- [x] Tests added or updated for the change
- [x] Documentation updated if behaviour changed (n/a)
- [x] No secrets or credentials committed
80 changes: 80 additions & 0 deletions frontend/src/components/DeprecationBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client';

import React, { useEffect, useState } from 'react';
import {
onDeprecation,
currentDeprecation,
type DeprecationInfo,
} from '../lib/api/deprecation';

const DISMISS_KEY = 'predictiq.deprecation.dismissed-sunset';

function readDismissed(): string | null {
try {
return localStorage.getItem(DISMISS_KEY);
} catch {
return null;
}
}

function formatSunset(sunset: string | null): string {
if (!sunset) return 'soon';
const d = new Date(sunset);
return Number.isNaN(d.getTime())
? sunset
: d.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
}

/**
* Dismissible, non-blocking banner shown when the API reports it is deprecated.
* The dismissal is remembered per sunset-date: it stays hidden across visits, but
* a new/changed sunset date brings it back.
*/
export const DeprecationBanner: React.FC = () => {
const [info, setInfo] = useState<DeprecationInfo | null>(() => currentDeprecation());
const [dismissedSunset, setDismissedSunset] = useState<string | null>(() => readDismissed());

useEffect(() => onDeprecation(setInfo), []);

if (!info) return null;
// Only counts as "dismissed" when it was dismissed for *this* sunset date.
if (dismissedSunset !== null && dismissedSunset === (info.sunset ?? '')) return null;

const dismiss = () => {
const key = info.sunset ?? '';
try {
localStorage.setItem(DISMISS_KEY, key);
} catch {
/* storage unavailable - banner will reappear next load, which is acceptable */
}
setDismissedSunset(key);
};

return (
<div className="deprecation-banner" role="status" aria-live="polite">
<p className="deprecation-banner__text">
This version of the PredictIQ API is deprecated and support ends{' '}
<strong>{formatSunset(info.sunset)}</strong>.
{info.migrationUrl && (
<>
{' '}
<a href={info.migrationUrl} className="deprecation-banner__link">
Read the migration guide
</a>
.
</>
)}
</p>
<button
type="button"
className="deprecation-banner__dismiss"
onClick={dismiss}
aria-label="Dismiss deprecation notice"
>
&times;
</button>
</div>
);
};

export default DeprecationBanner;
21 changes: 16 additions & 5 deletions frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
const { isDarkMode, toggleDarkMode } = useDarkMode();

const features = [
{ icon: '/icons/decentralized.svg', title: t('features.decentralized.title'), description: t('features.decentralized.description') },
{ icon: '/icons/secure.svg', title: t('features.secure.title'), description: t('features.secure.description') },
{ icon: '/icons/fast.svg', title: t('features.fast.title'), description: t('features.fast.description') },
{ icon: '/icons/decentralized.svg', title: t('features.decentralized.title'), description: t('features.decentralized.description'), href: '/markets' },
{ icon: '/icons/secure.svg', title: t('features.secure.title'), description: t('features.secure.description'), href: '/markets' },
{ icon: '/icons/fast.svg', title: t('features.fast.title'), description: t('features.fast.description'), href: '/markets' },
];

const steps = [
Expand Down Expand Up @@ -117,8 +117,19 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
<p className="hero-description">
{t('hero.description')}
</p>

{/* CTA Form */}

{/* Primary CTAs into the live product. Plain links (not the newsletter
form) so the hero works with JS pending and needs no client state. */}
<div className="hero-cta-group">
<a href="/markets" className="hero-cta hero-cta--primary">
{t('hero.primaryCta')}
</a>
<a href="#how-it-works" className="hero-cta hero-cta--secondary">
{t('hero.secondaryCta')}
</a>
</div>

{/* Early-access signup */}
<NewsletterSignup />
</section>

Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/__tests__/DeprecationBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeprecationBanner } from '../DeprecationBanner';
import {
reportResponseHeaders,
_resetDeprecationForTests,
} from '../../lib/api/deprecation';

function deprecatedHeaders(sunset: string, link?: string): Headers {
const h = new Headers();
h.set('Deprecation', 'true');
h.set('Sunset', sunset);
if (link) h.set('Link', link);
return h;
}

describe('DeprecationBanner (#1337)', () => {
beforeEach(() => {
_resetDeprecationForTests();
localStorage.clear();
});
afterEach(() => {
_resetDeprecationForTests();
localStorage.clear();
});

it('renders nothing until the API reports a deprecation', () => {
render(<DeprecationBanner />);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

it('shows the sunset date and migration link once a deprecated response arrives', () => {
render(<DeprecationBanner />);

act(() => {
reportResponseHeaders(
deprecatedHeaders('2026-07-01', '<https://docs/migrate>; rel="deprecation"'),
);
});

const banner = screen.getByRole('status');
expect(banner).toHaveTextContent(/support ends/i);
expect(screen.getByRole('link', { name: /migration guide/i })).toHaveAttribute(
'href',
'https://docs/migrate',
);
});

it('dismissal is remembered for that sunset date across remounts', async () => {
const first = render(<DeprecationBanner />);
act(() => {
reportResponseHeaders(deprecatedHeaders('2026-07-01'));
});
await userEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
first.unmount();

// Same sunset date: stays hidden after a fresh mount.
_resetDeprecationForTests();
render(<DeprecationBanner />);
act(() => {
reportResponseHeaders(deprecatedHeaders('2026-07-01'));
});
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

it('a new sunset date brings the banner back after a prior dismissal', () => {
localStorage.setItem('predictiq.deprecation.dismissed-sunset', '2026-07-01');
render(<DeprecationBanner />);

act(() => {
reportResponseHeaders(deprecatedHeaders('2027-01-01')); // different date
});

expect(screen.getByRole('status')).toBeInTheDocument();
});
});
17 changes: 17 additions & 0 deletions frontend/src/components/__tests__/FeatureCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,21 @@ describe('FeatureCard', () => {
render(<FeatureCard icon="/icons/chart.svg" title="Live data" description="Real-time odds." />);
expect(screen.getByRole('article')).toBeInTheDocument();
});

it('without href, the card is not a link', () => {
render(<FeatureCard icon="/i.svg" title="Plain" description="No link." />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});

it('with href, the whole card is a keyboard-reachable link with an accessible name', () => {
render(
<FeatureCard icon="/i.svg" title="Oracle resolution" description="Pyth + Reflector." href="/markets" />,
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/markets');
// Anchors are tab-focusable by default (no tabindex needed); assert it isn't
// removed from the tab order.
expect(link).not.toHaveAttribute('tabindex', '-1');
expect(link).toHaveAccessibleName(/oracle resolution/i);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe('LandingPage data-driven sections', () => {
render(<LandingPage />);
const cards = document.querySelectorAll('.feature-card');
expect(cards).toHaveLength(3);
expect(screen.getByRole('heading', { name: 'Fully Decentralized' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Secure & Audited' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Lightning Fast' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Multi-outcome markets' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Hybrid oracle + community resolution' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Stellar speed, with referrals' })).toBeInTheDocument();
});

it('renders the how-it-works steps as an ordered list with one item per step', () => {
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/__tests__/LandingPage.hero.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import LandingPage from '../LandingPage';
import { api } from '../../lib/api/public-client';

describe('LandingPage hero CTAs (#1343)', () => {
beforeEach(() => {
jest
.spyOn(api, 'getStatistics')
.mockResolvedValue({ total_markets: 1, total_volume: 0, active_markets: 0 });
});
afterEach(() => jest.restoreAllMocks());

it('the primary CTA routes into the product at /markets', () => {
render(<LandingPage />);
const primary = screen.getByRole('link', { name: /explore markets/i });
expect(primary).toHaveAttribute('href', '/markets');
});

it('the secondary CTA anchors to the "how it works" section', () => {
render(<LandingPage />);
const secondary = screen.getByRole('link', { name: /see how it works/i });
expect(secondary).toHaveAttribute('href', '#how-it-works');
expect(document.querySelector('#how-it-works')).toBeInTheDocument();
});

it('every feature card is a keyboard-reachable link into the product', () => {
render(<LandingPage />);
const cards = document.querySelectorAll('.feature-card');
expect(cards).toHaveLength(3);
cards.forEach((card) => {
const link = card.querySelector('a');
expect(link).toHaveAttribute('href', '/markets');
expect(link).not.toHaveAttribute('tabindex', '-1');
});
});
});
31 changes: 24 additions & 7 deletions frontend/src/components/landing/FeatureCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,29 @@ export interface FeatureCardProps {
icon: string;
title: string;
description: string;
/** When set, the whole card is a link (keyboard-reachable via Tab). */
href?: string;
}

export const FeatureCard: React.FC<FeatureCardProps> = ({ icon, title, description }) => (
<article className="feature-card">
<img src={icon} alt="" aria-hidden="true" width="64" height="64" />
<h3>{title}</h3>
<p>{description}</p>
</article>
);
export const FeatureCard: React.FC<FeatureCardProps> = ({ icon, title, description, href }) => {
const body = (
<>
<img src={icon} alt="" aria-hidden="true" width="64" height="64" />
<h3>{title}</h3>
<p>{description}</p>
</>
);

if (href) {
return (
<article className="feature-card feature-card--link">
<a href={href} className="feature-card__link">
{body}
<span className="visually-hidden"> — learn more</span>
</a>
</article>
);
}

return <article className="feature-card">{body}</article>;
};
Loading