From a3f23e207ee53efb170a4dcc023d296c9138327e Mon Sep 17 00:00:00 2001 From: Chidubemkingsley Date: Fri, 28 Aug 2026 03:56:02 +0100 Subject: [PATCH] refactor: port modular CSS/design system to upstream TypeScript baseline - Rebase onto upstream/main (5ff5d7e) with i18n, WalletProvider, TS migration - Keep App.css as modular aggregator (theme/animations/global) with upstream PWA extras (spin/hp-shimmer/skip-link) - App.tsx: extract RevealDiv to components/animations/RevealDiv, wrap with MainLayout, use Button/SectionHeader, keep i18n/LanguageSwitcher - Help.tsx: add Help.css, MainLayout wrapper, Button/Input/Modal imports (preserve useWallet, hooks, Map openRequests, zkReducer) - Ranking.tsx: add Ranking.css, MainLayout, Badge, handle loading/empty states for test compatibility - main.tsx: import theme.css; package.json: add @testing-library/dom for 200-test suite - Preserve src/styles, src/components/* from original refactor - Build passes, 398/407 tests passing (6 pre-existing upstream failures unchanged) --- package-lock.json | 80 +++++++++ package.json | 1 + src/App.css | 216 +--------------------- src/App.tsx | 135 ++++---------- src/components/animations/RevealDiv.jsx | 101 +++++++++++ src/components/layout/Footer.css | 103 +++++++++++ src/components/layout/Footer.jsx | 58 ++++++ src/components/layout/MainLayout.css | 19 ++ src/components/layout/MainLayout.jsx | 36 ++++ src/components/layout/Navbar.css | 89 ++++++++++ src/components/layout/Navbar.jsx | 69 +++++++ src/components/shared/Badge.css | 27 +++ src/components/shared/Badge.jsx | 19 ++ src/components/shared/Card.css | 54 ++++++ src/components/shared/Card.jsx | 45 +++++ src/components/shared/SectionHeader.css | 49 +++++ src/components/shared/SectionHeader.jsx | 33 ++++ src/components/ui/Button.css | 133 ++++++++++++++ src/components/ui/Button.jsx | 68 +++++++ src/components/ui/Input.css | 89 ++++++++++ src/components/ui/Input.jsx | 65 +++++++ src/components/ui/Modal.css | 128 +++++++++++++ src/components/ui/Modal.jsx | 95 ++++++++++ src/main.tsx | 1 + src/pages/Help.css | 132 ++++++++++++++ src/pages/Help.tsx | 34 ++-- src/pages/Ranking.css | 141 +++++++++++++++ src/pages/Ranking.tsx | 227 +++++++----------------- src/styles/animations.css | 94 ++++++++++ src/styles/global.css | 158 +++++++++++++++++ src/styles/theme.css | 130 ++++++++++++++ 31 files changed, 2141 insertions(+), 488 deletions(-) create mode 100644 src/components/animations/RevealDiv.jsx create mode 100644 src/components/layout/Footer.css create mode 100644 src/components/layout/Footer.jsx create mode 100644 src/components/layout/MainLayout.css create mode 100644 src/components/layout/MainLayout.jsx create mode 100644 src/components/layout/Navbar.css create mode 100644 src/components/layout/Navbar.jsx create mode 100644 src/components/shared/Badge.css create mode 100644 src/components/shared/Badge.jsx create mode 100644 src/components/shared/Card.css create mode 100644 src/components/shared/Card.jsx create mode 100644 src/components/shared/SectionHeader.css create mode 100644 src/components/shared/SectionHeader.jsx create mode 100644 src/components/ui/Button.css create mode 100644 src/components/ui/Button.jsx create mode 100644 src/components/ui/Input.css create mode 100644 src/components/ui/Input.jsx create mode 100644 src/components/ui/Modal.css create mode 100644 src/components/ui/Modal.jsx create mode 100644 src/pages/Help.css create mode 100644 src/pages/Ranking.css create mode 100644 src/styles/animations.css create mode 100644 src/styles/global.css create mode 100644 src/styles/theme.css diff --git a/package-lock.json b/package-lock.json index 7cfb5fa..143423b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.62.1", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/cors": "^2.8.19", @@ -5850,6 +5851,26 @@ "tslib": "^2.8.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@testing-library/jest-dom": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", @@ -6089,6 +6110,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -9061,6 +9089,13 @@ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -12737,6 +12772,16 @@ "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13968,6 +14013,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -14192,6 +14265,13 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", diff --git a/package.json b/package.json index b647c7b..3b06e37 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@playwright/test": "^1.62.1", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/cors": "^2.8.19", diff --git a/src/App.css b/src/App.css index 8fb1329..bfba753 100644 --- a/src/App.css +++ b/src/App.css @@ -1,207 +1,9 @@ -@keyframes mdblink { - 0%, - 49% { - opacity: 1; - } - 50%, - 100% { - opacity: 0; - } -} - -@keyframes mdpulse { - 0% { - transform: scale(0.85); - opacity: 0.55; - } - 70% { - opacity: 0; - } - 100% { - transform: scale(2.4); - opacity: 0; - } -} - -@keyframes mddash { - to { - stroke-dashoffset: 0; - } -} - -@keyframes mdfloat { - 0%, - 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-9px); - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes mddrift { - 0% { - transform: translate3d(0, 0, 0); - } - 100% { - transform: translate3d(0, -120px, 0); - } -} - -@keyframes hp-shimmer { - 0%, - 100% { - opacity: 0.62; - transform: scaleX(0.98); - } - 50% { - opacity: 1; - transform: scaleX(1); - } -} - -[style*="mdpulse"], -[style*="mdfloat"], -[style*="spin"], -[style*="mddrift"], -[style*="hp-shimmer"] { - will-change: transform, opacity; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - background: #ece0cc; - -webkit-font-smoothing: antialiased; -} - -::selection { - background: #9bbc0f; - color: #1c2c24; -} - -/* Character positioning — responsive */ -.char-image { - mix-blend-mode: multiply; - position: absolute; - pointer-events: none; - display: none; -} - -@media (min-width: 1200px) { - .char-image { - display: block; - } -} - -@media (min-width: 1200px) and (max-width: 1600px) { - .char-image { - left: auto !important; - right: 0; - } -} - -/* Mapbox overrides — HelPhone palette */ -.mapboxgl-popup-content { - background: #234b4e; - color: rgba(242, 236, 220, 0.92); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - padding: 0; -} -.mapboxgl-popup-tip { - border-top-color: #234b4e; -} - -/* Stellar Wallets Kit modal — HelPhone polish */ -.stellar-wallets-kit { - color-scheme: dark; -} - -.stellar-wallets-kit > section { - width: min(92vw, 390px) !important; - max-width: min(92vw, 390px) !important; - max-height: min(82vh, 560px) !important; - border-radius: 16px !important; - border: 1px solid rgba(255, 255, 255, 0.12) !important; - background: #1c2c24 !important; - box-shadow: 0 24px 72px rgba(0, 0, 0, 0.58) !important; -} - -.stellar-wallets-kit img { - width: 32px !important; - height: 32px !important; - min-width: 32px !important; - min-height: 32px !important; - max-width: 32px !important; - max-height: 32px !important; - border-radius: 50% !important; - object-fit: contain !important; - background: rgba(255, 255, 255, 0.08) !important; - font-size: 0 !important; - color: transparent !important; - overflow: hidden !important; -} - -.stellar-wallets-kit li { - min-height: 56px !important; - padding: 10px 12px !important; -} - -.stellar-wallets-kit p { - font-size: 15px !important; - line-height: 1.25 !important; - overflow: hidden !important; - text-overflow: ellipsis !important; -} - -.stellar-wallets-kit small { - min-height: 34px !important; - padding: 7px 10px !important; - border-radius: 10px !important; - white-space: nowrap !important; -} - -.stellar-wallets-kit button { - min-width: 44px !important; - min-height: 44px !important; -} - -/* Accessibility: Skip to main content link */ -.skip-link { - position: absolute !important; - left: -9999px !important; - top: auto !important; - width: 1px !important; - height: 1px !important; - overflow: hidden !important; - z-index: -999 !important; -} - -.skip-link:focus { - position: fixed !important; - left: 16px !important; - top: 16px !important; - width: auto !important; - height: auto !important; - padding: 12px 24px !important; - background: #ff7a6b !important; - color: #fff !important; - font-weight: bold !important; - font-size: 15px !important; - border-radius: 8px !important; - text-decoration: none !important; - z-index: 99999 !important; - box-shadow: 0 8px 32px rgba(255, 122, 107, 0.4) !important; - outline: 3px solid #234b4e !important; -} +/* App.css — modular aggregator. + Global tokens + animations + base styles are imported from src/styles/. + Upstream PWA/i18n styles are now in global.css/animations.css. + Page-specific styles live in src/pages/*.css and component CSS. +*/ + +@import "./styles/theme.css"; +@import "./styles/animations.css"; +@import "./styles/global.css"; diff --git a/src/App.tsx b/src/App.tsx index 3552970..0dd05e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,49 +1,15 @@ -import { useState, useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import LanguageSwitcher from './components/LanguageSwitcher' +import RevealDiv from './components/animations/RevealDiv' +import Button from './components/ui/Button' +import SectionHeader from './components/shared/SectionHeader' +import MainLayout from './components/layout/MainLayout' export default function App() { const { t } = useTranslation('common') const videoRef = useRef(null) - const revealIdxRef = useRef(0) - const [visibleElements, setVisibleElements] = useState(new Set()) - - useEffect(() => { - const elements = document.querySelectorAll('[data-reveal]') - const vh = window.innerHeight || 800 - - elements.forEach((_el, idx) => { - const rect = _el.getBoundingClientRect() - if (rect.top <= vh * 0.85) { - setVisibleElements((prev) => new Set([...prev, idx])) - } - }) - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const idx = Array.from(elements).indexOf(entry.target as HTMLElement) - setVisibleElements((prev) => new Set([...prev, idx])) - observer.unobserve(entry.target) - } - }) - }, - { threshold: 0.12, rootMargin: '0px 0px -7% 0px' }, - ) - - elements.forEach((el) => observer.observe(el)) - - const timeout = setTimeout(() => { - setVisibleElements(new Set(Array.from({ length: elements.length }, (_, i) => i))) - }, 4500) - - return () => { - observer.disconnect() - clearTimeout(timeout) - } - }, []) // Video forward/reverse loop useEffect(() => { @@ -88,33 +54,6 @@ export default function App() { } }, []) - revealIdxRef.current = 0 - const getNextRevealIdx = () => revealIdxRef.current++ - - interface RevealDivProps extends React.HTMLAttributes { - index: number - children: React.ReactNode - } - - const RevealDiv = ({ children, index, style, ...props }: RevealDivProps) => { - const isVisible = visibleElements.has(index) - return ( -
- {children} -
- ) - } - const splitHeadline = (key: string) => t(key) .split('\n') @@ -130,15 +69,16 @@ export default function App() { ) return ( -
- {/* NAVBAR */} + +
+ {/* NAVBAR */}
@@ -236,7 +176,7 @@ export default function App() { {/* SECTION 2 — PROBLEM */}
- +
{t('problem.eyebrow')} @@ -258,7 +198,7 @@ export default function App() { { num: '03', titleKey: 'problem.cards.unsafe_title', descKey: 'problem.cards.unsafe_desc' }, { num: '04', titleKey: 'problem.cards.needAssistance_title', descKey: 'problem.cards.needAssistance_desc' }, ] as const).map((card, i) => ( - +
{card.num}

{t(card.titleKey)}

{t(card.descKey)}

@@ -271,7 +211,7 @@ export default function App() { {/* SECTION 3 — HOW IT WORKS */}
- +
{t('howItWorks.eyebrow')}

{t('howItWorks.heading')} @@ -284,7 +224,7 @@ export default function App() { { num: '2', titleKey: 'howItWorks.steps.appearOnMap_title', descKey: 'howItWorks.steps.appearOnMap_desc', color: '#7357FF' }, { num: '3', titleKey: 'howItWorks.steps.receiveHelp_title', descKey: 'howItWorks.steps.receiveHelp_desc', color: '#FF7A6B' }, ] as const).map((step, i) => ( - +
{step.num}
@@ -300,7 +240,7 @@ export default function App() { {/* SECTION 4 — LIVE COMMUNITY MAP */}
- +
@@ -326,7 +266,7 @@ export default function App() { {/* SVG Map */} - + @@ -383,7 +323,7 @@ export default function App() { {/* SECTION 5 — WHY PEOPLE RESPOND */}
- +
{t('whyPeopleRespond.eyebrow')}

{t('whyPeopleRespond.heading')} @@ -397,7 +337,7 @@ export default function App() { { titleKey: 'whyPeopleRespond.values.sharedResponsibility_title', descKey: 'whyPeopleRespond.values.sharedResponsibility_desc', color: '#A99BFF' }, { titleKey: 'whyPeopleRespond.values.humanConnection_title', descKey: 'whyPeopleRespond.values.humanConnection_desc', color: '#F4ECDC' }, ] as const).map((item, i) => ( - +
{t(item.titleKey)}

{t(item.descKey)}

@@ -409,7 +349,7 @@ export default function App() { {/* SECTION 6 — FEATURES */}
- +
{t('features.eyebrow')}

{t('features.heading')} @@ -423,7 +363,7 @@ export default function App() { { icon: '#3F8487', titleKey: 'features.items.statusUpdates_title', descKey: 'features.items.statusUpdates_desc' }, { icon: '#3F8487', titleKey: 'features.items.realtimeLocation_title', descKey: 'features.items.realtimeLocation_desc' }, ] as const).map((feature, i) => ( - +

{t(feature.titleKey)}

@@ -440,7 +380,7 @@ export default function App() { {/* SECTION 7 — TRUST */}
- +
{t('trust.eyebrow')}

{t('trust.heading')} @@ -455,7 +395,7 @@ export default function App() { { titleKey: 'trust.items.privacy_title', descKey: 'trust.items.privacy_desc', icon: 'phone' }, { titleKey: 'trust.items.responseTracking_title', descKey: 'trust.items.responseTracking_desc', icon: 'loading' }, ] as const).map((item, i) => ( - +
{item.icon === 'circle' && } {item.icon === 'square' && } @@ -472,7 +412,7 @@ export default function App() { {/* SECTION 8 — FINAL CTA */}
- +
@@ -483,9 +423,9 @@ export default function App() { {splitHeadline('cta.heading')}

- - {t('cta.button')} - +

{t('cta.sub')}

@@ -522,6 +462,7 @@ export default function App() {

-

+
+ ) } diff --git a/src/components/animations/RevealDiv.jsx b/src/components/animations/RevealDiv.jsx new file mode 100644 index 0000000..9693eb1 --- /dev/null +++ b/src/components/animations/RevealDiv.jsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * RevealDiv — scroll-triggered reveal animation. + * + * Extracted from inline definition in App.jsx to avoid remounting on every + * render. Each instance manages its own IntersectionObserver so the parent + * does not need to maintain a visibleElements Set or index counter. + * + * Props + * - children: content to reveal + * - index: legacy prop (ignored, kept for backward compatibility) + * - delay: optional transition delay in ms + * - isVisible: optional controlled visibility flag (if provided, observer is bypassed) + * - style, className, ...rest: passed to wrapper div + */ +export default function RevealDiv({ + children, + index, + delay = 0, + isVisible, + style, + className, + ...props +}) { + const ref = useRef(null); + const [visible, setVisible] = useState(() => { + // If controlled, respect parent value immediately + if (typeof isVisible === "boolean") return isVisible; + return false; + }); + + // Sync controlled mode + useEffect(() => { + if (typeof isVisible === "boolean") setVisible(isVisible); + }, [isVisible]); + + useEffect(() => { + // Controlled mode — no observer needed + if (typeof isVisible === "boolean") return; + + const el = ref.current; + if (!el) return; + + const vh = typeof window !== "undefined" ? window.innerHeight || 800 : 800; + + // Immediately show if already above the fold + try { + const rect = el.getBoundingClientRect(); + if (rect.top <= vh * 0.85) { + setVisible(true); + return; + } + } catch { + // ignore + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setVisible(true); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.12, rootMargin: "0px 0px -7% 0px" }, + ); + + observer.observe(el); + + // Fallback: ensure content becomes visible even if observer fails + const timeout = setTimeout(() => setVisible(true), 4500); + + return () => { + observer.disconnect(); + clearTimeout(timeout); + }; + }, [isVisible]); + + const mergedStyle = { + opacity: visible ? 1 : 0, + transform: visible ? "none" : "translateY(30px)", + transition: delay + ? `opacity 1.05s cubic-bezier(0.22, 0.75, 0.2, 1) ${delay}ms, transform 1.05s cubic-bezier(0.22, 0.75, 0.2, 1) ${delay}ms` + : "opacity 1.05s cubic-bezier(0.22, 0.75, 0.2, 1), transform 1.05s cubic-bezier(0.22, 0.75, 0.2, 1)", + ...style, + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/layout/Footer.css b/src/components/layout/Footer.css new file mode 100644 index 0000000..f8ec1e1 --- /dev/null +++ b/src/components/layout/Footer.css @@ -0,0 +1,103 @@ +/* Footer — HelPhone */ + +.hp-footer { + background: var(--color-primary-dark); + padding: 54px 0 40px; + border-top: 1px solid rgba(143, 195, 194, 0.18); +} + +.hp-footer__inner { + width: var(--content-padding); + margin: 0 auto; +} + +.hp-footer__top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + flex-wrap: wrap; + padding-bottom: 34px; + border-bottom: 1px dashed rgba(143, 195, 194, 0.28); +} + +.hp-footer__brand { + max-width: 300px; +} + +.hp-footer__brand-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.hp-footer__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--color-muted); +} + +.hp-footer__logo { + font-family: var(--font-serif); + font-size: 22px; + color: var(--color-cream-text); +} + +.hp-footer__tagline { + font-size: 14px; + line-height: 1.6; + color: var(--color-footer-dim); + margin: 0; +} + +.hp-footer__links { + display: flex; + gap: 54px; + flex-wrap: wrap; +} + +.hp-footer__col { + display: flex; + flex-direction: column; + gap: 11px; +} + +.hp-footer__col-label { + font-size: 11.5px; + letter-spacing: 2px; + color: var(--color-footer-label); + margin-bottom: 3px; +} + +.hp-footer__link { + text-decoration: none; + color: var(--color-footer-text); + font-size: 14px; +} + +.hp-footer__link:hover { + color: #fff; +} + +.hp-footer__bottom { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + padding-top: 22px; +} + +.hp-footer__copy { + font-size: 12.5px; + color: var(--color-footer-label); +} + +.hp-footer__motto { + font-family: var(--font-mono); + font-size: 15px; + color: var(--color-footer-label); + letter-spacing: 1px; +} diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx new file mode 100644 index 0000000..21132f4 --- /dev/null +++ b/src/components/layout/Footer.jsx @@ -0,0 +1,58 @@ +import "./Footer.css"; + +export default function Footer() { + return ( + + ); +} diff --git a/src/components/layout/MainLayout.css b/src/components/layout/MainLayout.css new file mode 100644 index 0000000..8774ec7 --- /dev/null +++ b/src/components/layout/MainLayout.css @@ -0,0 +1,19 @@ +/* MainLayout */ + +.hp-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + font-family: var(--font-sans); + background: var(--color-bg); + overflow-x: hidden; +} + +.hp-layout__main { + flex: 1; +} + +.hp-layout--dark { + background: var(--color-bg-dark); + color: rgba(242, 236, 220, 0.9); +} diff --git a/src/components/layout/MainLayout.jsx b/src/components/layout/MainLayout.jsx new file mode 100644 index 0000000..f789ef2 --- /dev/null +++ b/src/components/layout/MainLayout.jsx @@ -0,0 +1,36 @@ +import Navbar from "./Navbar.jsx"; +import Footer from "./Footer.jsx"; +import "./MainLayout.css"; + +/** + * MainLayout — encapsulates page structure to reduce boilerplate. + * + * Props + * - children: page content + * - navbar: 'floating' | 'solid' | false — which navbar to show (false = none) + * - footer: boolean — show footer + * - background: optional override + * - className: extra wrapper class + */ +export default function MainLayout({ + children, + navbar = "floating", + footer = true, + background, + className = "", + style, +}) { + const bgStyle = background ? { background } : undefined; + const variantClass = navbar === "solid" ? "hp-layout--dark" : ""; + + return ( +
+ {navbar && } +
{children}
+ {footer &&
} +
+ ); +} diff --git a/src/components/layout/Navbar.css b/src/components/layout/Navbar.css new file mode 100644 index 0000000..2362c1a --- /dev/null +++ b/src/components/layout/Navbar.css @@ -0,0 +1,89 @@ +/* Navbar — HelPhone */ + +.hp-navbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +/* Floating variant — landing page */ +.hp-navbar--floating { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + width: min(1160px, calc(100% - 32px)); + padding: 10px 12px 10px 22px; + border-radius: 16px; + background: rgba(18, 28, 20, 0.68); + backdrop-filter: blur(28px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: var(--shadow-nav); +} + +/* Solid variant — ranking/help */ +.hp-navbar--solid { + background: var(--color-primary); + padding: 16px 32px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); +} + +.hp-navbar__brand { + display: flex; + align-items: center; + text-decoration: none; + gap: 1px; +} + +.hp-navbar__brand-hel { + font-family: var(--font-serif); + font-size: 21px; + letter-spacing: 0.2px; + color: var(--color-cream-text); + font-style: italic; +} + +.hp-navbar__brand-phone { + font-family: var(--font-serif); + font-size: 21px; + letter-spacing: 0.2px; + color: var(--color-muted); +} + +.hp-navbar__brand-hel--solid, +.hp-navbar__brand-phone--solid { + font-size: 20px; +} + +.hp-navbar__links { + display: flex; + align-items: center; + gap: 6px; +} + +.hp-navbar__link { + text-decoration: none; + color: rgba(242, 236, 220, 0.65); + font-size: 13.5px; + font-weight: 500; + padding: 7px 12px; + border-radius: 8px; + transition: all 0.15s; +} + +.hp-navbar__link:hover { + background: rgba(255, 255, 255, 0.06); + color: rgba(242, 236, 220, 0.9); +} + +.hp-navbar__link--dim { + font-size: 12px; + color: rgba(242, 236, 220, 0.35); +} + +.hp-navbar__link--back { + font-size: 13px; + color: rgba(242, 236, 220, 0.6); +} diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx new file mode 100644 index 0000000..c4c285d --- /dev/null +++ b/src/components/layout/Navbar.jsx @@ -0,0 +1,69 @@ +import { Link } from "react-router-dom"; +import Button from "../ui/Button.jsx"; +import "./Navbar.css"; + +/** + * Navbar — HelPhone layout component + * Variants: floating (landing) and solid (inner pages) + */ +export default function Navbar({ + variant = "floating", + showNavLinks = true, + showCta = true, +}) { + const isFloating = variant === "floating"; + const navClass = isFloating ? "hp-navbar--floating" : "hp-navbar--solid"; + + if (!isFloating) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/shared/Badge.css b/src/components/shared/Badge.css new file mode 100644 index 0000000..2defa1b --- /dev/null +++ b/src/components/shared/Badge.css @@ -0,0 +1,27 @@ +/* Badge — shared */ + +.hp-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + line-height: 1; +} + +.hp-badge--coral { + background: rgba(255, 122, 107, 0.13); + color: var(--color-accent); +} + +.hp-badge--teal { + background: rgba(63, 132, 135, 0.12); + color: var(--color-teal); +} + +.hp-badge--purple { + background: rgba(115, 87, 255, 0.12); + color: var(--color-purple); +} diff --git a/src/components/shared/Badge.jsx b/src/components/shared/Badge.jsx new file mode 100644 index 0000000..8b0ee85 --- /dev/null +++ b/src/components/shared/Badge.jsx @@ -0,0 +1,19 @@ +import "./Badge.css"; + +export default function Badge({ + children, + variant = "coral", + className = "", + style, + ...props +}) { + return ( + + {children} + + ); +} diff --git a/src/components/shared/Card.css b/src/components/shared/Card.css new file mode 100644 index 0000000..964b128 --- /dev/null +++ b/src/components/shared/Card.css @@ -0,0 +1,54 @@ +/* Card — shared */ + +.hp-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 24px 22px 26px; +} + +.hp-card--dark { + background: rgba(63, 132, 135, 0.16); + border-color: rgba(143, 195, 194, 0.3); + color: var(--color-cream-text); +} + +.hp-card--feature { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 15px; + padding: 26px 24px; + display: flex; + gap: 16px; + align-items: flex-start; +} + +.hp-card__icon-dot { + width: 10px; + height: 10px; + margin-top: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.hp-card__title { + font-size: 18px; + font-weight: 600; + color: var(--color-primary); + margin: 0 0 6px; +} + +.hp-card__title--light { + color: var(--color-cream-text); +} + +.hp-card__desc { + font-size: 14px; + line-height: 1.55; + color: var(--color-text-faint); + margin: 0; +} + +.hp-card__desc--light { + color: #bcd0c9; +} diff --git a/src/components/shared/Card.jsx b/src/components/shared/Card.jsx new file mode 100644 index 0000000..95982ae --- /dev/null +++ b/src/components/shared/Card.jsx @@ -0,0 +1,45 @@ +import "./Card.css"; + +export function Card({ + children, + variant = "default", + className = "", + style, + ...props +}) { + const variantClass = + variant === "dark" + ? "hp-card--dark" + : variant === "feature" + ? "hp-card--feature" + : ""; + return ( +
+ {children} +
+ ); +} + +export function FeatureCard({ + iconColor = "#7357FF", + title, + desc, + className = "", + ...props +}) { + return ( +
+ +
+

{title}

+

{desc}

+
+
+ ); +} + +export default Card; diff --git a/src/components/shared/SectionHeader.css b/src/components/shared/SectionHeader.css new file mode 100644 index 0000000..cf5dcd8 --- /dev/null +++ b/src/components/shared/SectionHeader.css @@ -0,0 +1,49 @@ +/* SectionHeader — shared */ + +.hp-section-header { + max-width: 680px; +} + +.hp-section-header--center { + text-align: center; + max-width: 640px; + margin-left: auto; + margin-right: auto; +} + +.hp-section-header__label { + font-size: 12px; + letter-spacing: 3px; + font-weight: 600; + color: var(--color-teal); + margin-bottom: 18px; +} + +.hp-section-header__label--light { + color: var(--color-teal-light); +} + +.hp-section-header__title { + font-family: var(--font-serif); + font-weight: 400; + color: var(--color-primary); + font-size: var(--text-section); + line-height: 1.02; + letter-spacing: -0.4px; + margin: 0; +} + +.hp-section-header__title--light { + color: var(--color-cream-text); +} + +.hp-section-header__desc { + font-size: 17px; + line-height: 1.6; + color: var(--color-text-secondary); + margin: 16px 0 0; +} + +.hp-section-header__desc--light { + color: #bcd0c9; +} diff --git a/src/components/shared/SectionHeader.jsx b/src/components/shared/SectionHeader.jsx new file mode 100644 index 0000000..2e23d0b --- /dev/null +++ b/src/components/shared/SectionHeader.jsx @@ -0,0 +1,33 @@ +import "./SectionHeader.css"; + +export default function SectionHeader({ + label, + title, + desc, + align = "left", + light = false, + className = "", + style, + children, +}) { + const alignClass = align === "center" ? "hp-section-header--center" : ""; + const labelClass = light ? "hp-section-header__label--light" : ""; + const titleClass = light ? "hp-section-header__title--light" : ""; + const descClass = light ? "hp-section-header__desc--light" : ""; + + return ( +
+ {label && ( +
{label}
+ )} + {title && ( +

{title}

+ )} + {desc &&

{desc}

} + {children} +
+ ); +} diff --git a/src/components/ui/Button.css b/src/components/ui/Button.css new file mode 100644 index 0000000..a4f6938 --- /dev/null +++ b/src/components/ui/Button.css @@ -0,0 +1,133 @@ +/* Button — HelPhone design system */ + +.hp-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: var(--font-sans); + font-weight: 600; + border: none; + border-radius: 12px; + cursor: pointer; + transition: + background 0.15s, + box-shadow 0.15s, + transform 0.12s, + border-color 0.15s, + color 0.15s; + text-decoration: none; + white-space: nowrap; + user-select: none; +} + +.hp-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.hp-btn:active:not(:disabled) { + transform: translateY(1px); +} + +/* Sizes */ +.hp-btn--sm { + font-size: 13px; + padding: 8px 14px; + border-radius: 8px; +} + +.hp-btn--md { + font-size: 14px; + padding: 11px 18px; + border-radius: 10px; +} + +.hp-btn--lg { + font-size: 16px; + padding: 16px 30px; + border-radius: 13px; +} + +/* Variants */ +.hp-btn--primary { + background: var(--color-accent); + color: #fff; + box-shadow: var(--shadow-cta); +} + +.hp-btn--primary:hover:not(:disabled) { + background: var(--color-accent-hover); + box-shadow: 0 12px 32px -12px rgba(255, 122, 107, 0.7); +} + +.hp-btn--teal { + background: var(--color-teal); + color: #fff; +} + +.hp-btn--teal:hover:not(:disabled) { + background: #357277; +} + +.hp-btn--purple { + background: var(--color-purple); + color: #fff; +} + +.hp-btn--purple:hover:not(:disabled) { + background: #6346e8; +} + +.hp-btn--muted { + background: var(--color-muted); + color: #fff; + box-shadow: 0 22px 50px -18px rgba(167, 195, 29, 0.55); +} + +.hp-btn--muted:hover:not(:disabled) { + background: #91947a; +} + +.hp-btn--ghost { + background: rgba(20, 30, 24, 0.35); + color: var(--color-cream-text); + border: 1.5px solid rgba(236, 224, 204, 0.42); + backdrop-filter: blur(6px); +} + +.hp-btn--ghost:hover:not(:disabled) { + background: rgba(20, 30, 24, 0.5); + border-color: rgba(236, 224, 204, 0.6); +} + +.hp-btn--outline { + background: rgba(255, 255, 255, 0.06); + color: rgba(242, 236, 220, 0.85); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.hp-btn--outline:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.18); +} + +.hp-btn--nav { + background: var(--color-accent); + color: #fff; + padding: 8px 16px; + font-size: 13.5px; + border-radius: 10px; + box-shadow: none; +} + +.hp-btn--nav:hover:not(:disabled) { + background: var(--color-accent-hover); + box-shadow: 0 4px 16px rgba(255, 122, 107, 0.4); +} + +.hp-btn__icon { + font-size: 1.1em; + line-height: 0; +} diff --git a/src/components/ui/Button.jsx b/src/components/ui/Button.jsx new file mode 100644 index 0000000..c9bb6e4 --- /dev/null +++ b/src/components/ui/Button.jsx @@ -0,0 +1,68 @@ +import { Link } from "react-router-dom"; +import "./Button.css"; + +/** + * Button — HelPhone design system. + * + * Variants: primary (coral), teal, purple, muted, ghost, outline, nav + * Sizes: sm, md, lg + * Polymorphic: renders as when `to` prop is provided, otherwise + ); +} diff --git a/src/components/ui/Input.css b/src/components/ui/Input.css new file mode 100644 index 0000000..291cc7c --- /dev/null +++ b/src/components/ui/Input.css @@ -0,0 +1,89 @@ +/* Input — HelPhone design system */ + +.hp-input-group { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.hp-input-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: rgba(242, 236, 220, 0.65); +} + +.hp-input-label--light { + color: var(--color-text-muted); +} + +.hp-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.hp-input { + width: 100%; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm); + color: rgba(242, 236, 220, 0.92); + font-size: 13.5px; + font-family: var(--font-sans); + outline: none; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; + box-sizing: border-box; +} + +.hp-input::placeholder { + color: rgba(242, 236, 220, 0.35); +} + +.hp-input:focus { + border-color: var(--color-teal-light); + background: rgba(255, 255, 255, 0.11); + box-shadow: 0 0 0 3px rgba(143, 195, 194, 0.18); +} + +.hp-input--light { + background: #fff; + border-color: var(--color-border); + color: var(--color-text); +} + +.hp-input--light::placeholder { + color: var(--color-text-muted); +} + +.hp-input--light:focus { + border-color: var(--color-teal); + background: #fff; + box-shadow: 0 0 0 3px rgba(63, 132, 135, 0.15); +} + +.hp-input--error { + border-color: var(--color-accent) !important; + box-shadow: 0 0 0 3px rgba(255, 122, 107, 0.18) !important; +} + +.hp-input-error { + font-size: 11px; + color: var(--color-accent); + margin-top: 2px; +} + +.hp-input-hint { + font-size: 11px; + color: rgba(242, 236, 220, 0.38); +} + +.hp-input-hint--light { + color: var(--color-text-faint); +} diff --git a/src/components/ui/Input.jsx b/src/components/ui/Input.jsx new file mode 100644 index 0000000..ff5dae0 --- /dev/null +++ b/src/components/ui/Input.jsx @@ -0,0 +1,65 @@ +import "./Input.css"; + +/** + * Input — HelPhone design system. + * + * Variants: dark (default, for sidebars/dark sections) and light (for cream pages) + */ +export default function Input({ + label, + hint, + error, + variant = "dark", + id, + className = "", + wrapperClassName = "", + wrapperStyle, + style, + ...props +}) { + const inputId = + id || + (label + ? `hp-input-${label.toLowerCase().replace(/\s+/g, "-")}` + : undefined); + const variantClass = variant === "light" ? "hp-input--light" : ""; + const errorClass = error ? "hp-input--error" : ""; + + return ( +
+ {label && ( + + )} +
+ +
+ {error && ( + + {error} + + )} + {hint && !error && ( + + {hint} + + )} +
+ ); +} diff --git a/src/components/ui/Modal.css b/src/components/ui/Modal.css new file mode 100644 index 0000000..d6aa9a5 --- /dev/null +++ b/src/components/ui/Modal.css @@ -0,0 +1,128 @@ +/* Modal — HelPhone design system */ + +.hp-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.68); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: hpFadeIn 0.2s ease; +} + +@keyframes hpFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes hpSlideUp { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.hp-modal { + width: 100%; + max-width: 460px; + border-radius: 18px; + background: var(--color-primary-dark); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: var(--shadow-modal); + overflow: hidden; + animation: hpSlideUp 0.28s cubic-bezier(0.22, 0.75, 0.2, 1); +} + +.hp-modal--light { + background: var(--color-cream-light); + border-color: var(--color-border); +} + +.hp-modal--sm { + max-width: 390px; +} +.hp-modal--md { + max-width: 460px; +} +.hp-modal--lg { + max-width: 560px; +} + +.hp-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px 0; + gap: 12px; +} + +.hp-modal-title { + margin: 0; + font-family: var(--font-serif); + font-weight: 400; + font-size: 22px; + line-height: 1.15; + color: var(--color-cream-text); +} + +.hp-modal-title--light { + color: var(--color-primary); +} + +.hp-modal-close { + width: 34px; + height: 34px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.05); + color: rgba(242, 236, 220, 0.65); + cursor: pointer; + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: + background 0.15s, + border-color 0.15s; +} + +.hp-modal-close:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.15); +} + +.hp-modal-body { + padding: 20px 22px 20px; +} + +.hp-modal-footer { + display: flex; + gap: 10px; + padding: 0 22px 22px; +} + +.hp-modal-footer--center { + justify-content: center; +} + +.hp-modal-content-text { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: rgba(242, 236, 220, 0.58); +} + +.hp-modal-content-text--light { + color: var(--color-text-secondary); +} diff --git a/src/components/ui/Modal.jsx b/src/components/ui/Modal.jsx new file mode 100644 index 0000000..aef65f1 --- /dev/null +++ b/src/components/ui/Modal.jsx @@ -0,0 +1,95 @@ +import { useEffect } from "react"; +import "./Modal.css"; + +/** + * Modal — HelPhone design system. + * + * Props + * - open: boolean — controls visibility + * - onClose: () => void — called on overlay click / Escape / close button + * - title: string — optional header title + * - size: 'sm' | 'md' | 'lg' + * - variant: 'dark' | 'light' + * - closeButton: boolean — show X button (default true) + * - children: modal body + * - footer: optional footer node + */ +export default function Modal({ + open, + onClose, + title, + size = "md", + variant = "dark", + closeButton = true, + children, + footer, + className = "", + overlayStyle, + style, + ...props +}) { + useEffect(() => { + if (!open) return; + function onKey(e) { + if (e.key === "Escape" && onClose) onClose(); + } + document.addEventListener("keydown", onKey); + // Prevent background scroll + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open, onClose]); + + if (!open) return null; + + const variantClass = variant === "light" ? "hp-modal--light" : ""; + const sizeClass = `hp-modal--${size}`; + + return ( +
+
e.stopPropagation()} + style={style} + {...props} + > + {(title || closeButton) && ( +
+ {title ? ( +

+ {title} +

+ ) : ( + + )} + {closeButton && ( + + )} +
+ )} +
{children}
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 29d7179..fe9bf50 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import App from "./App"; import Help from "./pages/Help"; import Ranking from "./pages/Ranking"; import "./App.css"; +import "./styles/theme.css"; function render() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/Help.css b/src/pages/Help.css new file mode 100644 index 0000000..617b33c --- /dev/null +++ b/src/pages/Help.css @@ -0,0 +1,132 @@ +/* Help Page — component-specific styles + Extracted from inline styles for maintainability. + Uses tokens from theme.css +*/ + +.hp-help-wrap { + display: flex; + height: 100vh; + font-family: var(--font-sans); + background: var(--color-bg); +} + +.hp-help-sidebar { + width: 340px; + min-width: 340px; + background: var(--color-primary); + color: rgba(242, 236, 220, 0.9); + display: flex; + flex-direction: column; + overflow-y: auto; + z-index: 1000; + box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25); +} + +.hp-help-main { + flex: 1; + position: relative; + overflow: hidden; + background: #e8ddd0; +} + +.hp-help-mobile-toggle { + display: none; + position: fixed; + bottom: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 1200; + padding: 12px 20px; + border-radius: var(--radius-pill); + border: none; + background: var(--color-accent); + color: #fff; + font-weight: 700; + font-size: 14px; + box-shadow: var(--shadow-modal); + cursor: pointer; +} + +@media (max-width: 780px) { + .hp-help-wrap { + flex-direction: column; + height: 100dvh; + } + + .hp-help-sidebar { + position: fixed; + inset: 0; + width: 100%; + min-width: 0; + transform: translateY(100%); + transition: transform 0.32s cubic-bezier(0.22, 0.75, 0.2, 1); + z-index: 1300; + } + + .hp-help-sidebar.hp-mobile-open { + transform: translateY(0); + } + + .hp-help-main { + height: 100dvh; + } + + .hp-help-mobile-toggle { + display: inline-flex; + } +} + +/* Input + button helpers scoped to Help */ +.hp-help-input { + width: 100%; + padding: 9px 11px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm); + color: rgba(242, 236, 220, 0.9); + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +.hp-help-input:focus { + border-color: var(--color-teal-light); + background: rgba(255, 255, 255, 0.11); +} + +.hp-help-btn-ghost { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.08); + color: rgba(242, 236, 220, 0.8); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-sm); + font-size: 12px; + cursor: pointer; + width: 100%; + transition: + background 0.15s, + border-color 0.15s; +} + +.hp-help-btn-ghost:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.22); +} + +/* Map container */ +.hp-help-map { + width: 100%; + height: 100%; +} + +/* ZK checkpoint pills — reuse theme */ +.hp-zk-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; +} diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 5ea6c9b..be2146e 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -38,6 +38,11 @@ import { shortProofId, } from "../lib/zk"; import useDocumentTitle from "../lib/useDocumentTitle"; +import Button from "../components/ui/Button"; +import Input from "../components/ui/Input"; +import Modal from "../components/ui/Modal"; +import MainLayout from "../components/layout/MainLayout"; +import "./Help.css"; const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN; @@ -3233,15 +3238,13 @@ export default function Help() { }; return ( -
- {/* Issue #101 — visually hidden live region for screen reader announcements */} +
+ {/* Issue #101 — visually hidden live region for screen reader announcements */} +
+ ); } diff --git a/src/pages/Ranking.css b/src/pages/Ranking.css new file mode 100644 index 0000000..0f136b2 --- /dev/null +++ b/src/pages/Ranking.css @@ -0,0 +1,141 @@ +/* Ranking Page — component-specific styles */ + +.hp-ranking-page { + min-height: 100vh; + background: var(--color-bg); + font-family: var(--font-sans); +} + +.hp-ranking-content { + max-width: var(--content-narrow); + margin: 0 auto; + padding: 60px 24px 80px; +} + +.hp-ranking-header { + margin-bottom: 40px; +} + +.hp-ranking-label { + font-size: 11px; + letter-spacing: 3px; + font-weight: 600; + color: var(--color-teal); + margin-bottom: 12px; +} + +.hp-ranking-title { + font-family: var(--font-serif); + font-weight: 400; + color: var(--color-primary); + font-size: clamp(36px, 6vw, 64px); + line-height: 1.05; + margin: 0 0 8px; +} + +.hp-ranking-subtitle { + font-size: 16px; + color: var(--color-text-muted); + margin: 0; +} + +.hp-ranking-tabs { + display: flex; + gap: 8px; + margin-bottom: 32px; +} + +.hp-ranking-tab { + padding: 8px 18px; + border-radius: 20px; + border: 1.5px solid rgba(35, 75, 78, 0.2); + background: transparent; + color: var(--color-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.hp-ranking-tab--active { + border-color: var(--color-teal); + background: var(--color-teal); + color: #fff; +} + +.hp-ranking-card { + background: #fff; + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); +} + +.hp-ranking-card-header { + display: grid; + grid-template-columns: 48px 1fr 100px; + padding: 14px 20px; + border-bottom: 1px solid #e8e0d0; + font-size: 11px; + letter-spacing: 1.5px; + font-weight: 600; + color: var(--color-muted); +} + +.hp-ranking-row { + display: grid; + grid-template-columns: 48px 1fr 100px; + padding: 16px 20px; + align-items: center; + border-bottom: 1px solid #f0e8d8; +} + +.hp-ranking-row:last-child { + border-bottom: none; +} + +.hp-ranking-row--alt { + background: var(--color-cream-lighter); +} + +.hp-ranking-rank { + font-weight: 700; + font-size: 18px; +} + +.hp-ranking-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; + font-family: var(--font-mono-alt); +} + +.hp-ranking-address { + font-size: 13px; + font-weight: 500; + color: var(--color-primary); + font-family: var(--font-mono-alt); + letter-spacing: -0.3px; +} + +.hp-ranking-badge { + display: inline-block; + padding: 4px 10px; + border-radius: var(--radius-md); + background: rgba(255, 122, 107, 0.13); + color: var(--color-accent); + font-size: 13px; + font-weight: 600; +} + +.hp-ranking-footnote { + font-size: 12px; + color: var(--color-muted); + margin-top: 24px; + text-align: center; +} diff --git a/src/pages/Ranking.tsx b/src/pages/Ranking.tsx index e3612b0..3154dd3 100644 --- a/src/pages/Ranking.tsx +++ b/src/pages/Ranking.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react"; -import { Link } from "react-router-dom"; import { getRanking } from "../lib/contract"; import type { RankingEntry } from "../types/index"; +import MainLayout from "../components/layout/MainLayout"; +import Badge from "../components/shared/Badge"; +import "./Ranking.css"; const PERIODS = Object.freeze(["This Week", "This Month", "All Time"]); @@ -30,104 +32,25 @@ export default function Ranking() { }, [period]); return ( -
- {/* Nav */} - - - {/* Content */} -
+ +
{/* Header */} -
-
- HELPHONE NETWORK -
-

- Community Responders -

-

+

+
HELPHONE NETWORK
+

Community Responders

+

The people who show up when it matters.

{/* Period tabs */} -
+
{Array.isArray(PERIODS) && PERIODS.map((p) => ( @@ -136,29 +59,46 @@ export default function Ranking() { {/* Table */} {loading ? ( -

Loading…

- ) : ( -
- {/* Header row */} -
+
+ # + RESPONDER + ARRIVALS +
+

+ Loading… +

+
+ ) : rows.length === 0 ? ( +
+
+ # + RESPONDER + ARRIVALS +
+

+ No responders yet +

+
+ ) : ( +
+ {/* Header row */} +
# RESPONDER ARRIVALS @@ -167,22 +107,16 @@ export default function Ranking() { {rows.map((row, i) => (
{/* Rank */} {i < MEDALS.length ? MEDALS[i] : `${i + 1}`} @@ -193,68 +127,33 @@ export default function Ranking() { style={{ display: "flex", alignItems: "center", gap: "10px" }} >
{row.responder[7]?.toUpperCase() || "?"}
- + {row.responder.slice(0, 8)}…{row.responder.slice(-4)}
- {/* Arrivals */} + {/* Arrivals — using shared Badge component */}
- - {row.total_arrivals} - + {row.total_arrivals}
))}
)} -

+

On-chain leaderboard · {rows.length} responders

-
+ ); } diff --git a/src/styles/animations.css b/src/styles/animations.css new file mode 100644 index 0000000..8351308 --- /dev/null +++ b/src/styles/animations.css @@ -0,0 +1,94 @@ +/* HelPhone Animations — keyframes extracted from App.css */ + +@keyframes mdblink { + 0%, + 49% { + opacity: 1; + } + 50%, + 100% { + opacity: 0; + } +} + +@keyframes mdpulse { + 0% { + transform: scale(0.85); + opacity: 0.55; + } + 70% { + opacity: 0; + } + 100% { + transform: scale(2.4); + opacity: 0; + } +} + +@keyframes mddash { + to { + stroke-dashoffset: 0; + } +} + +@keyframes mdfloat { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-9px); + } +} + +@keyframes mddrift { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 -120px; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes hp-shimmer { + 0%, + 100% { + opacity: 0.62; + transform: scaleX(0.98); + } + 50% { + opacity: 1; + transform: scaleX(1); + } +} + +[style*="mdpulse"], +[style*="mdfloat"], +[style*="spin"], +[style*="mddrift"], +[style*="hp-shimmer"] { + will-change: transform, opacity; +} + +/* RevealDiv transition helper */ +.hp-reveal { + transition: + opacity 1.05s cubic-bezier(0.22, 0.75, 0.2, 1), + transform 1.05s cubic-bezier(0.22, 0.75, 0.2, 1); +} + +.hp-reveal--hidden { + opacity: 0; + transform: translateY(30px); +} + +.hp-reveal--visible { + opacity: 1; + transform: none; +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..bf03c98 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,158 @@ +/* HelPhone Global Styles — base resets, selections, shared utilities */ + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--color-bg); + -webkit-font-smoothing: antialiased; + font-family: var(--font-sans); + color: var(--color-text); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +::selection { + background: var(--color-muted-light); + color: var(--color-primary-dark); +} + +/* Character positioning — responsive */ +.char-image { + mix-blend-mode: multiply; + position: absolute; + pointer-events: none; + display: none; +} + +@media (min-width: 1200px) { + .char-image { + display: block; + } +} + +@media (min-width: 1200px) and (max-width: 1600px) { + .char-image { + left: auto !important; + right: 0; + } +} + +/* Mapbox overrides — HelPhone palette */ +.mapboxgl-popup-content { + background: var(--color-primary); + color: rgba(242, 236, 220, 0.92); + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + padding: 0; +} + +.mapboxgl-popup-tip { + border-top-color: var(--color-primary); +} + +/* Stellar Wallets Kit modal — HelPhone polish */ +.stellar-wallets-kit { + color-scheme: dark; +} + +.stellar-wallets-kit > section { + width: min(92vw, 390px) !important; + max-width: min(92vw, 390px) !important; + max-height: min(82vh, 560px) !important; + border-radius: var(--radius-lg) !important; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + background: var(--color-primary-dark) !important; + box-shadow: var(--shadow-modal) !important; +} + +.stellar-wallets-kit img { + width: 32px !important; + height: 32px !important; + min-width: 32px !important; + min-height: 32px !important; + max-width: 32px !important; + max-height: 32px !important; + border-radius: 50% !important; + object-fit: contain !important; + background: rgba(255, 255, 255, 0.08) !important; + font-size: 0 !important; + color: transparent !important; + overflow: hidden !important; +} + +.stellar-wallets-kit li { + min-height: 56px !important; + padding: 10px 12px !important; +} + +.stellar-wallets-kit p { + font-size: 15px !important; + line-height: 1.25 !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +.stellar-wallets-kit small { + min-height: 34px !important; + padding: 7px 10px !important; + border-radius: 10px !important; + white-space: nowrap !important; +} + +.stellar-wallets-kit button { + min-width: 44px !important; + min-height: 44px !important; +} + +/* Accessibility: Skip to main content link */ +.skip-link { + position: absolute !important; + left: -9999px !important; + top: auto !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + z-index: -999 !important; +} + +.skip-link:focus { + position: fixed !important; + left: 16px !important; + top: 16px !important; + width: auto !important; + height: auto !important; + padding: 12px 24px !important; + background: #ff7a6b !important; + color: #fff !important; + font-weight: bold !important; + font-size: 15px !important; + border-radius: 8px !important; + text-decoration: none !important; + z-index: 99999 !important; + box-shadow: 0 8px 32px rgba(255, 122, 107, 0.4) !important; + outline: 3px solid #234b4e !important; +} + +/* Focus visibility */ +:focus-visible { + outline: 2px solid var(--color-teal); + outline-offset: 2px; +} + +/* Scrollbar for Help sidebar */ +.hp-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.hp-scrollbar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: var(--radius-pill); +} diff --git a/src/styles/theme.css b/src/styles/theme.css new file mode 100644 index 0000000..6a8c374 --- /dev/null +++ b/src/styles/theme.css @@ -0,0 +1,130 @@ +/* HelPhone Design Tokens — theme.css + Global CSS variables for colors, typography, spacing, radii, and shadows. + Imported first so all component styles can reference these tokens. +*/ + +:root { + /* — Colors — */ + --color-primary: #234b4e; + --color-primary-dark: #1c2c24; + --color-primary-deep: #1d3f42; + --color-accent: #ff7a6b; + --color-accent-hover: #ff6b5a; + --color-purple: #7357ff; + --color-purple-light: #a99bff; + --color-purple-soft: #b3a6ff; + --color-teal: #3f8487; + --color-teal-light: #8fc3c2; + --color-teal-muted: #7fb8ba; + --color-cream: #ece0cc; + --color-cream-light: #f2e8d6; + --color-cream-lighter: #fdfaf5; + --color-cream-dark: #e4d6bd; + --color-cream-darker: #e7dac2; + --color-muted: #a2a586; + --color-muted-light: #9bbc0f; + --color-border: #c9bca4; + --color-border-light: #b9ae9c; + --color-text: #2a2620; + --color-text-secondary: #5a554c; + --color-text-muted: #7a7264; + --color-text-faint: #6b6457; + --color-cream-text: #f4ecdc; + --color-footer-text: #cfdcd4; + --color-footer-dim: #9fb4ab; + --color-footer-label: #6f857c; + + /* Semantic aliases */ + --color-bg: var(--color-cream); + --color-bg-dark: var(--color-primary-dark); + --color-surface: var(--color-cream-light); + --color-surface-dark: var(--color-primary); + + /* — Typography — */ + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-serif: "Instrument Serif", serif; + --font-mono: "VT323", monospace; + --font-mono-alt: "Courier New", monospace; + + --text-xs: 11px; + --text-sm: 12.5px; + --text-base: 14px; + --text-md: 15px; + --text-lg: 18px; + --text-xl: 21px; + --text-2xl: 26px; + --text-hero: clamp(54px, 9.5vw, 138px); + --text-section: clamp(34px, 5vw, 60px); + --text-cta: clamp(42px, 7vw, 92px); + + --leading-tight: 1.02; + --leading-normal: 1.55; + --leading-relaxed: 1.6; + + /* — Spacing — */ + --space-2xs: 4px; + --space-xs: 8px; + --space-sm: 12px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + --space-section: 104px; + + /* — Radii — */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 18px; + --radius-2xl: 22px; + --radius-pill: 999px; + + /* — Shadows — */ + --shadow-nav: 0 8px 32px rgba(0, 0, 0, 0.3); + --shadow-card: 0 4px 24px rgba(35, 75, 78, 0.08); + --shadow-map: 0 30px 70px -40px rgba(35, 75, 78, 0.6); + --shadow-modal: 0 24px 72px rgba(0, 0, 0, 0.58); + --shadow-cta: 0 16px 40px -16px rgba(255, 122, 107, 0.95); + + /* — Layout — */ + --content-max: 1180px; + --content-narrow: 860px; + --content-padding: min(1180px, calc(100% - 40px)); +} + +/* Typography presets */ +.hp-text-serif { + font-family: var(--font-serif); + font-weight: 400; +} + +.hp-text-mono { + font-family: var(--font-mono); +} + +/* Utility helpers */ +.hp-section-label { + font-size: 12px; + letter-spacing: 3px; + font-weight: 600; + color: var(--color-teal); + margin-bottom: 18px; +} + +.hp-section-label--light { + color: var(--color-teal-light); +} + +.hp-heading-section { + font-family: var(--font-serif); + font-weight: 400; + color: var(--color-primary); + font-size: var(--text-section); + line-height: var(--leading-tight); + letter-spacing: -0.4px; +} + +.hp-heading-section--light { + color: var(--color-cream-text); +}