diff --git a/docs/pr/spotkorner-dot-1337-1342-1343-1344.md b/docs/pr/spotkorner-dot-1337-1342-1343-1344.md new file mode 100644 index 0000000..87fa244 --- /dev/null +++ b/docs/pr/spotkorner-dot-1337-1342-1343-1344.md @@ -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 + ``). +- 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 +`` 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 `` (with an `.sr-only` "learn more" suffix and a `:focus-visible` + outline). Without `href` it stays a plain `
`. +- 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 diff --git a/frontend/src/components/DeprecationBanner.tsx b/frontend/src/components/DeprecationBanner.tsx new file mode 100644 index 0000000..8a6ebd4 --- /dev/null +++ b/frontend/src/components/DeprecationBanner.tsx @@ -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(() => currentDeprecation()); + const [dismissedSunset, setDismissedSunset] = useState(() => 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 ( +
+

+ This version of the PredictIQ API is deprecated and support ends{' '} + {formatSunset(info.sunset)}. + {info.migrationUrl && ( + <> + {' '} + + Read the migration guide + + . + + )} +

+ +
+ ); +}; + +export default DeprecationBanner; diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index cf23aec..bcd429f 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -18,9 +18,9 @@ export const LandingPage: React.FC = ({ 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 = [ @@ -117,8 +117,19 @@ export const LandingPage: React.FC = ({ className }) => {

{t('hero.description')}

- - {/* 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. */} +
+ + {t('hero.primaryCta')} + + + {t('hero.secondaryCta')} + +
+ + {/* Early-access signup */} diff --git a/frontend/src/components/__tests__/DeprecationBanner.test.tsx b/frontend/src/components/__tests__/DeprecationBanner.test.tsx new file mode 100644 index 0000000..7d2af01 --- /dev/null +++ b/frontend/src/components/__tests__/DeprecationBanner.test.tsx @@ -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(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows the sunset date and migration link once a deprecated response arrives', () => { + render(); + + act(() => { + reportResponseHeaders( + deprecatedHeaders('2026-07-01', '; 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(); + 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(); + 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(); + + act(() => { + reportResponseHeaders(deprecatedHeaders('2027-01-01')); // different date + }); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/FeatureCard.test.tsx b/frontend/src/components/__tests__/FeatureCard.test.tsx index 983a290..70ad141 100644 --- a/frontend/src/components/__tests__/FeatureCard.test.tsx +++ b/frontend/src/components/__tests__/FeatureCard.test.tsx @@ -34,4 +34,21 @@ describe('FeatureCard', () => { render(); expect(screen.getByRole('article')).toBeInTheDocument(); }); + + it('without href, the card is not a link', () => { + render(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('with href, the whole card is a keyboard-reachable link with an accessible name', () => { + render( + , + ); + 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); + }); }); diff --git a/frontend/src/components/__tests__/LandingPage.dataDriven.test.tsx b/frontend/src/components/__tests__/LandingPage.dataDriven.test.tsx index abcd6ef..469e343 100644 --- a/frontend/src/components/__tests__/LandingPage.dataDriven.test.tsx +++ b/frontend/src/components/__tests__/LandingPage.dataDriven.test.tsx @@ -18,9 +18,9 @@ describe('LandingPage data-driven sections', () => { render(); 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', () => { diff --git a/frontend/src/components/__tests__/LandingPage.hero.test.tsx b/frontend/src/components/__tests__/LandingPage.hero.test.tsx new file mode 100644 index 0000000..e3150ff --- /dev/null +++ b/frontend/src/components/__tests__/LandingPage.hero.test.tsx @@ -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(); + 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(); + 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(); + 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'); + }); + }); +}); diff --git a/frontend/src/components/landing/FeatureCard.tsx b/frontend/src/components/landing/FeatureCard.tsx index 3384951..1d96b09 100644 --- a/frontend/src/components/landing/FeatureCard.tsx +++ b/frontend/src/components/landing/FeatureCard.tsx @@ -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 = ({ icon, title, description }) => ( -
- -

{title}

-

{description}

-
-); +export const FeatureCard: React.FC = ({ icon, title, description, href }) => { + const body = ( + <> + +

{title}

+

{description}

+ + ); + + if (href) { + return ( + + ); + } + + return
{body}
; +}; diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts index 0998bcc..4509bac 100644 --- a/frontend/src/lib/__tests__/i18n.test.ts +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -59,7 +59,7 @@ describe('i18n', () => { it('should handle nested keys', () => { const result = i18n.t('features.decentralized.title'); - expect(result).toBe('Fully Decentralized'); + expect(result).toBe('Multi-outcome markets'); }); it('should return default value when a key resolves to a nested object rather than a string', () => { diff --git a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts index a977961..e5c8bbc 100644 --- a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts +++ b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts @@ -132,4 +132,51 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => { expect(calledPath).toBe(fillTemplate(template, params)); }, ); + + // Whole-source sweep: no client method may reference a path that is neither in + // schema.d.ts nor an explicitly-tracked "not in openapi.yaml yet" entry. Adding a + // new endpoint with a mistyped or un-schema'd path fails here at PR time (#1342). + const KNOWN_UNLISTED: ReadonlySet = new Set([ + '/health', // liveness probe, not part of the versioned API surface + '/api/v1/blockchain/markets/{market_id}/bets', // placeBet - pending openapi.yaml (#78) + '/api/v1/newsletter/subscribe', + '/api/v1/newsletter/confirm', + '/api/v1/newsletter/unsubscribe', + '/api/v1/newsletter/gdpr/request-token', + '/api/v1/newsletter/gdpr/export', + '/api/v1/newsletter/gdpr/delete', + '/api/v1/email/preview/{param}', + '/api/v1/email/test', + '/api/v1/email/analytics', + '/api/v1/email/queue/stats', + '/api/blockchain/replay', + ]); + + const PLACEHOLDER = new RegExp(String.raw`\$\{[^}]+\}`, 'g'); + + it('every /api path literal in the client source is schema-defined or explicitly tracked', () => { + const dir = path.join(__dirname, '..'); + const sources = ['public-client.ts', 'admin-client.ts'] + .map((f) => fs.readFileSync(path.join(dir, f), 'utf8')) + .join('\n'); + + const literals = new Set(); + const re = /["'`](\/(?:api|health)[^"'`\s]*)["'`]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(sources))) { + literals.add(m[1].replace(PLACEHOLDER, '{param}')); + } + expect(literals.size).toBeGreaterThan(10); // sanity: the sweep found paths + + // schema keys use real placeholder names (`{market_id}`); the swept literals + // are normalised to `{param}`. Compare with placeholders stripped. + const stripParams = (p: string) => p.replace(/\{[^}]+\}/g, '{}'); + const schemaShapes = new Set([...schemaPaths].map(stripParams)); + const allowedShapes = new Set([...KNOWN_UNLISTED].map(stripParams)); + + const unaccounted = [...literals] + .map(stripParams) + .filter((p) => !schemaShapes.has(p) && !allowedShapes.has(p)); + expect(unaccounted).toEqual([]); + }); }); diff --git a/frontend/src/lib/api/__tests__/deprecation.test.ts b/frontend/src/lib/api/__tests__/deprecation.test.ts new file mode 100644 index 0000000..dafc660 --- /dev/null +++ b/frontend/src/lib/api/__tests__/deprecation.test.ts @@ -0,0 +1,64 @@ +import { + reportResponseHeaders, + currentDeprecation, + onDeprecation, + _resetDeprecationForTests, +} from '../deprecation'; + +function headers(init: Record): Headers { + const h = new Headers(); + for (const [k, v] of Object.entries(init)) h.set(k, v); + return h; +} + +describe('deprecation signal bus (#1337)', () => { + beforeEach(() => _resetDeprecationForTests()); + afterEach(() => _resetDeprecationForTests()); + + it('is a no-op for a response with no Deprecation header', () => { + const listener = jest.fn(); + onDeprecation(listener); + + reportResponseHeaders(headers({ 'Content-Type': 'application/json' })); + + expect(listener).not.toHaveBeenCalled(); + expect(currentDeprecation()).toBeNull(); + }); + + it('treats Deprecation: false as not deprecated (no false positive)', () => { + reportResponseHeaders(headers({ Deprecation: 'false' })); + expect(currentDeprecation()).toBeNull(); + }); + + it('opens a signal with the sunset date and parsed migration link', () => { + const listener = jest.fn(); + onDeprecation(listener); + + reportResponseHeaders( + headers({ + Deprecation: 'true', + Sunset: 'Wed, 01 Jul 2026 00:00:00 GMT', + Link: '; rel="deprecation"', + }), + ); + + expect(listener).toHaveBeenCalledWith({ + sunset: 'Wed, 01 Jul 2026 00:00:00 GMT', + migrationUrl: 'https://docs.predictiq.dev/api/migrate-v2', + }); + expect(currentDeprecation()?.migrationUrl).toBe('https://docs.predictiq.dev/api/migrate-v2'); + }); + + it('a later non-deprecated response does not clear an active signal', () => { + reportResponseHeaders(headers({ Deprecation: 'true', Sunset: '2026-07-01' })); + reportResponseHeaders(headers({ 'Content-Type': 'application/json' })); + expect(currentDeprecation()?.sunset).toBe('2026-07-01'); + }); + + it('falls back to a bare Link url when no matching rel is present', () => { + reportResponseHeaders( + headers({ Deprecation: 'true', Link: '' }), + ); + expect(currentDeprecation()?.migrationUrl).toBe('https://example.com/notes'); + }); +}); diff --git a/frontend/src/lib/api/admin-client.ts b/frontend/src/lib/api/admin-client.ts index a50ccff..11bafc4 100644 --- a/frontend/src/lib/api/admin-client.ts +++ b/frontend/src/lib/api/admin-client.ts @@ -136,6 +136,7 @@ async function request( }); clear(); + reportResponseHeaders(res.headers); if (!res.ok) { if (res.status === 429) { diff --git a/frontend/src/lib/api/deprecation.ts b/frontend/src/lib/api/deprecation.ts new file mode 100644 index 0000000..20979ec --- /dev/null +++ b/frontend/src/lib/api/deprecation.ts @@ -0,0 +1,74 @@ +/** + * API deprecation signal bus (#1337). + * + * `API_SPEC.md`'s deprecation policy: deprecated versions return `Deprecation`, + * `Sunset`, and `Link` response headers with a 12-month minimum support window. + * The client reports every response's headers here; the UI subscribes to show a + * dismissible banner. + * + * False-positive guard: a response with no `Deprecation` header (or `Deprecation: + * false`) is a no-op - it never clears a real signal, but it also never opens one, + * so a single stale/cached non-deprecated response cannot suppress or fabricate the + * banner. + */ + +export interface DeprecationInfo { + /** ISO date (or HTTP-date) the version stops being supported, or null if unknown. */ + sunset: string | null; + /** URL of the migration guide, parsed from the `Link` header, or null. */ + migrationUrl: string | null; +} + +type Listener = (info: DeprecationInfo) => void; + +const listeners = new Set(); +let current: DeprecationInfo | null = null; + +export function onDeprecation(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The most recent deprecation signal, or null if the API has not reported one. */ +export function currentDeprecation(): DeprecationInfo | null { + return current; +} + +/** `; rel="deprecation"` -> `https://docs.example/migrate` */ +function parseMigrationLink(linkHeader: string | null): string | null { + if (!linkHeader) return null; + for (const part of linkHeader.split(',')) { + const url = part.match(/<([^>]+)>/)?.[1]; + const rel = part.match(/rel="?([^";]+)"?/)?.[1]; + if (url && (rel === 'deprecation' || rel === 'sunset' || rel === 'successor-version')) { + return url; + } + } + // A single bare `` with no rel is still better than nothing. + return linkHeader.match(/<([^>]+)>/)?.[1] ?? null; +} + +/** + * Feed one response's headers in. Acts only when `Deprecation` is present and not + * `false`; otherwise a no-op. + */ +export function reportResponseHeaders(headers: Headers | null | undefined): void { + if (!headers || typeof headers.get !== 'function') return; + const deprecation = headers.get('Deprecation'); + if (!deprecation || deprecation.toLowerCase() === 'false') return; + + const info: DeprecationInfo = { + sunset: headers.get('Sunset'), + migrationUrl: parseMigrationLink(headers.get('Link')), + }; + current = info; + for (const listener of listeners) listener(info); +} + +/** Test helper. */ +export function _resetDeprecationForTests(): void { + current = null; + listeners.clear(); +} diff --git a/frontend/src/lib/api/public-client.ts b/frontend/src/lib/api/public-client.ts index 95a34c8..86c79d7 100644 --- a/frontend/src/lib/api/public-client.ts +++ b/frontend/src/lib/api/public-client.ts @@ -14,6 +14,7 @@ import { getEnvConfig } from '../env'; import { apiCache, CACHE_TTL } from './cache'; +import { reportResponseHeaders } from './deprecation'; import { csrfHeaders, isCsrfTokenError } from './csrf'; import { reportRateLimited } from './rateLimit'; import type { paths, components } from './schema'; @@ -241,6 +242,7 @@ async function sendWithRetries( }); clear(); + reportResponseHeaders(res.headers); if (!res.ok) { if (res.status === 429) { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 7170b23..197761b 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -29,6 +29,8 @@ const translations: LocaleData = { emailPlaceholder: 'you@example.com', emailRequired: 'Email is required', emailInvalid: 'Please enter a valid email address', + primaryCta: 'Explore markets', + secondaryCta: 'See how it works', submitButton: 'Get Early Access', subscribedButton: 'Subscribed!', successMessage: 'Successfully subscribed to updates!', @@ -36,16 +38,16 @@ const translations: LocaleData = { features: { heading: 'Key Features', decentralized: { - title: 'Fully Decentralized', - description: 'No central authority. Markets run on smart contracts with transparent, immutable rules.', + title: 'Multi-outcome markets', + description: 'Create markets with two or many outcomes. Rules live in an on-chain Soroban contract, not a company database.', }, secure: { - title: 'Secure & Audited', - description: 'Smart contracts audited by leading security firms. Your funds are protected by battle-tested code.', + title: 'Hybrid oracle + community resolution', + description: 'Markets resolve from Pyth and Reflector oracle data, with a community-vote fallback and a dispute window when the feeds disagree.', }, fast: { - title: 'Lightning Fast', - description: 'Built on Stellar for near-instant transactions and minimal fees. Trade without waiting.', + title: 'Stellar speed, with referrals', + description: 'Near-instant settlement and low fees on Stellar. Bring others in and earn a share through the built-in referral program.', }, }, howItWorks: { diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css index 277c5e9..7fad7da 100644 --- a/frontend/src/styles/landing.css +++ b/frontend/src/styles/landing.css @@ -581,3 +581,104 @@ footer[role='contentinfo'] { .hero form { animation-delay: 180ms; } + +/* === Hero CTAs (#1343) === */ +.hero-cta-group { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-block: 1.75rem 2.5rem; + animation: rise var(--dur-slow) var(--ease-expo) both; + animation-delay: 150ms; +} +.hero-cta { + display: inline-flex; + align-items: center; + padding: 0.85rem 1.6rem; + border-radius: var(--radius-pill); + font-weight: 600; + font-size: var(--text-base); + text-decoration: none; + transition: transform var(--dur) var(--ease-expo), background-color var(--dur), + border-color var(--dur); +} +.hero-cta--primary { + background: var(--grad-brand); + color: var(--on-primary); + box-shadow: var(--shadow-glow-gold); +} +.hero-cta--primary:hover { + transform: translateY(-2px); +} +.hero-cta--secondary { + background: transparent; + color: var(--fg); + border: 1px solid var(--border-strong); +} +.hero-cta--secondary:hover { + border-color: var(--gold); +} + +/* === Feature card as a link (#1344) === */ +.feature-card--link { + padding: 0; +} +.feature-card__link { + display: block; + height: 100%; + padding: 2rem 1.75rem; + color: inherit; + text-decoration: none; +} +.feature-card__link:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +/* Single column below the small breakpoint - no tile text clips or overflows. */ +@media (max-width: 520px) { + .features-grid { + grid-template-columns: 1fr; + } + .hero-cta-group { + flex-direction: column; + align-self: stretch; + } + .hero-cta { + justify-content: center; + } +} + +/* === Deprecation banner (#1337) === */ +.deprecation-banner { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 0.85rem 1.25rem; + background: var(--gold-soft); + border-bottom: 1px solid var(--border-strong); + color: var(--fg); + font-size: var(--text-sm, 0.875rem); +} +.deprecation-banner__text { + margin: 0; + flex: 1; +} +.deprecation-banner__link { + color: var(--gold); + font-weight: 600; +} +.deprecation-banner__dismiss { + flex-shrink: 0; + background: none; + border: none; + color: var(--fg-muted); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; +} +.deprecation-banner__dismiss:hover { + color: var(--fg); +}