From 8d66e324e8c8e237b6a2df4ec493dbf896fd9234 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Thu, 23 Jul 2026 16:18:31 +0200 Subject: [PATCH] Redesign and enhance About page layout and content - Updated `VoteEvidence` and `NotifySpotlight` components with improved layouts, scaling, and responsiveness. - Added live mobile sharing button leveraging the Web Share API on supported devices. - Simplified and modernized repo activity metrics, introducing display floors and "last updated" live ping messaging. - Improved German and French translations for new and existing components. - Removed outdated or redundant components and feature cards for cleaner structure and focus. - Refined media handling, accessibility, and content ordering for better usability and readability. --- .gitignore | 2 + common/messages/de.json | 9 + common/messages/fr.json | 9 + web/components/about/repo-activity.tsx | 157 ++++++++------ web/components/about/vote-evidence.tsx | 106 ++++++---- web/pages/about.tsx | 282 +++++++++++++++++-------- 6 files changed, 376 insertions(+), 189 deletions(-) diff --git a/.gitignore b/.gitignore index 73b43555..a02bd49b 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,5 @@ test-results # The home-page hero clips and their posters are NOT committed. They live in Cloudflare R2 and are # downloaded into web/public/ by web/scripts/fetch-media.mjs during the build, so Vercel still serves # them same-origin. Push new renders with media-creator's `npm run upload:media`. + +*.back \ No newline at end of file diff --git a/common/messages/de.json b/common/messages/de.json index f0139d51..84862aa3 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -14,6 +14,12 @@ "about.block.keyword.title": "Suche nach Schlüsselwörtern", "about.block.mission.text": "Unsere einzige Mission ist es, mehr authentische menschliche Beziehungen zu schaffen, und jede Entscheidung muss diesem Ziel dienen.", "about.block.mission.title": "Eine Mission", + "about.block.notify.step1.text": "Filtern Sie die gesamte Community bis zur genau passenden Person — Werte, Interessen, Ort.", + "about.block.notify.step1.title": "Nach Stichwort suchen", + "about.block.notify.step2.text": "Noch niemand dabei? Speichern Sie sie mit einem Tippen, statt erneut nachzusehen.", + "about.block.notify.step2.title": "Suche speichern", + "about.block.notify.step3.text": "Wir schreiben Ihnen an dem Tag, an dem jemand Passendes beitritt.", + "about.block.notify.step3.title": "E-Mail erhalten", "about.block.notify.text": "Sie müssen die App nicht ständig überprüfen! Wir kontaktieren Sie, wenn neue Benutzer Ihren Suchkriterien entsprechen.", "about.block.notify.title": "Suchbenachrichtigungen erhalten", "about.block.personality.text": "Werte und Interessen zuerst, Fotos sind zweitrangig.", @@ -41,6 +47,9 @@ "about.seo.title": "Über uns", "about.settings.copied": "Kopiert!", "about.settings.copy_info": "Infos kopieren", + "about.share.button": "Teilen", + "about.share.text": "Durchdachte Eins-zu-eins-Verbindungen, offen entwickelt.", + "about.share.title": "Compass", "about.subtitle": "Finden Sie Ihre Leute ganz einfach — basierend auf wer sie sind, nicht wie sie aussehen.", "about.suggestions.button": "Hier vorschlagen →", "about.suggestions.text": "Machen Sie Vorschläge oder lassen Sie uns wissen, dass Sie durch dieses Formular helfen möchten. Jede Idee zählt.", diff --git a/common/messages/fr.json b/common/messages/fr.json index ff6cb661..1cc24cd3 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -14,6 +14,12 @@ "about.block.keyword.title": "Recherche par mots-clés", "about.block.mission.text": "Notre unique mission est de créer plus de relations humaines authentiques, et chaque décision doit servir cet objectif.", "about.block.mission.title": "Une mission", + "about.block.notify.step1.text": "Filtre toute la communauté jusqu'à la personne exacte — valeurs, intérêts, localisation.", + "about.block.notify.step1.title": "Recherche par mot-clé", + "about.block.notify.step2.text": "Personne ne correspond encore ? Enregistre-la en un geste au lieu de revenir vérifier.", + "about.block.notify.step2.title": "Enregistre la recherche", + "about.block.notify.step3.text": "Nous t'écrivons le jour où quelqu'un qui correspond nous rejoint.", + "about.block.notify.step3.title": "Reçois l'e-mail", "about.block.notify.text": "Plus besoin de vérifier constamment l'application ! Nous to contacterons lorsque de nouveaux utilisateurs correspondent à tes recherches.", "about.block.notify.title": "Reçois des notifications de recherches", "about.block.personality.text": "Valeurs et centres d'intérêt d'abord, les photos sont secondaires.", @@ -41,6 +47,9 @@ "about.seo.title": "À propos", "about.settings.copied": "Copié !", "about.settings.copy_info": "Copier les infos", + "about.share.button": "Partager", + "about.share.text": "Des connexions authentiques en tête-à-tête, construites en toute transparence.", + "about.share.title": "Compass", "about.subtitle": "Trouve tes personnes en toute simplicité — selon qui ils sont, pas selon leur apparence.", "about.suggestions.button": "Suggérer ici →", "about.suggestions.text": "Donne des suggestions ou fais-nous savoir que tu souhaites aider via ce formulaire. Chaque idée compte.", diff --git a/web/components/about/repo-activity.tsx b/web/components/about/repo-activity.tsx index 90908b48..a905770c 100644 --- a/web/components/about/repo-activity.tsx +++ b/web/components/about/repo-activity.tsx @@ -2,9 +2,8 @@ import {CodeBracketIcon, StarIcon, UserGroupIcon} from '@heroicons/react/24/outl import clsx from 'clsx' import {githubRepo} from 'common/constants' import {ComponentType, ReactNode, SVGProps} from 'react' -import {SectionLabel} from 'web/components/about/section' import {Reveal} from 'web/components/widgets/reveal' -import {Section, surface} from 'web/components/widgets/surface' +import {surface} from 'web/components/widgets/surface' import {useAPIGetter} from 'web/hooks/use-api-getter' import {useT} from 'web/lib/locale' @@ -31,14 +30,34 @@ import {useT} from 'web/lib/locale' type IconType = ComponentType> -function Metric({icon: Icon, value, label}: {icon: IconType; value: ReactNode; label: string}) { +function Metric({ + icon: Icon, + value, + label, + live, +}: { + icon: IconType + value: ReactNode + label: string + live?: boolean +}) { return (
-
+
+ {/* The recency stat is the one signal that survives when the counts are still under their + display floors, so it carries the "the project is alive" claim on its own. The pulse makes + that read at a glance instead of asking the reader to parse a date. motion-safe so + reduced-motion visitors just get the static dot. */} + {live && ( + + + + + )} {value}
{label}
@@ -55,7 +74,7 @@ function daysAgoLabel(date: Date, t: ReturnType) { return t('about.repo.days_ago', '{count} days ago', {count: days}) } -export function RepoActivity() { +export function RepoActivity({className}: {className?: string}) { const t = useT() const {data} = useAPIGetter('repo-stats', {}) @@ -64,66 +83,80 @@ export function RepoActivity() { // The Date survives the wire as a Date via the schema's Zod serialization, but a cached response // rehydrated from a string would not — normalise rather than trust it. const lastCommit = data.lastCommitTime ? new Date(data.lastCommitTime) : null - const hasAny = - data.contributors !== null || - data.stars !== null || - (lastCommit && !isNaN(lastCommit.getTime())) - if (!hasAny) return null + const hasValidCommit = !!lastCommit && !isNaN(lastCommit.getTime()) + // A contributor or star count only supports the "community owned" claim once it is large enough to + // read as momentum; below these floors the honest move is to omit the number rather than sit a small + // one next to a sentence about being community-owned. Recency has no floor — "changed this week" is + // proof of life whatever the other two say — so in practice this block is usually just that one stat, + // and the layout below treats one metric as its normal case, not a degenerate one. + const CONTRIBUTORS_FLOOR = 10 + const STARS_FLOOR = 100 + const metrics: {icon: IconType; value: ReactNode; label: string; live?: boolean}[] = [] + if (data.contributors !== null && data.contributors >= CONTRIBUTORS_FLOOR) + metrics.push({ + icon: UserGroupIcon, + value: data.contributors, + label: t('about.repo.contributors', 'Contributors'), + }) + if (data.stars !== null && data.stars >= STARS_FLOOR) + metrics.push({ + icon: StarIcon, + value: data.stars, + label: t('about.repo.stars', 'GitHub stars'), + }) + if (hasValidCommit) + metrics.push({ + icon: CodeBracketIcon, + value: daysAgoLabel(lastCommit as Date, t), + label: t('about.repo.last_commit', 'Last change'), + live: true, + }) + + // Every count fell below its display floor and there is no commit date — nothing here earns a heading. + if (metrics.length === 0) return null + + // { + // /* Section and label live in here rather than in the page, so that a failed GitHub call takes + // the whole section with it instead of leaving a heading over nothing. */ + // } return ( -
- {/* Section and label live in here rather than in the page, so that a failed GitHub call takes - the whole section with it instead of leaving a heading over nothing. */} - {t('about.repo.label', 'Built in the open')} - -

- {t( - 'about.repo.claim', - 'Community owned. No investors, no acquisition, nothing to sell you.', - )} -

-

- {t( - 'about.repo.intro', - 'The whole thing is built in public — every line of code, every change, open to read and to challenge:', - )} -

+ //
+ // {t('about.repo.label', 'Built in the open')} + +

+ {t( + 'about.repo.claim', + 'Community owned. No investors, no acquisition, nothing to sell you.', + )} +

+

+ {t( + 'about.repo.intro', + 'The whole thing is built in public — every line of code, every change, open to read and to challenge:', + )} +

-
- {data.contributors !== null && ( - - )} - {data.stars !== null && ( - - )} - {lastCommit && !isNaN(lastCommit.getTime()) && ( - - )} + {/* A footer bar, not a grid: the metric count is usually one (the counts sit below their display + floors), and one cell in a three-column grid reads as broken. Here the stats and the source + link share a rule-topped row that stays balanced whether one metric shows or three — the stats + settle to the left, the link to the right, and both wrap cleanly on a phone. */} +
+
+ {metrics.map((m) => ( + + ))}
- -

- - {t('about.repo.link', 'Read the source →')} - -

- -
+ + {t('about.repo.link', 'Read the source →')} + +
+ + // ) } diff --git a/web/components/about/vote-evidence.tsx b/web/components/about/vote-evidence.tsx index bcd1bd31..46f74123 100644 --- a/web/components/about/vote-evidence.tsx +++ b/web/components/about/vote-evidence.tsx @@ -39,7 +39,13 @@ const SHOTS = { } // Matches Tailwind's `sm` (640px): below it, the card's own layout has already gone narrow. -const NARROW_MEDIA = '(max-width: 420px)' +// const NARROW_MEDIA = '(max-width: 420px)' + +// From lg up (see the grid below), the block is two columns and the about main column is only ~760px +// wide, so a landscape shot would be squeezed into ~300px and rendered near-illegible. There the +// reflowed *portrait* capture is the right one: it fills the column's height beside the copy and stays +// at a legible scale. Keep this breakpoint in sync with the grid's `lg:`. +// const WIDE_COLUMN_MEDIA = '(min-width: 1024px)' /** * A rather than next/image, so the browser picks a width itself and downloads only that @@ -52,16 +58,23 @@ const NARROW_MEDIA = '(max-width: 420px)' function Shot({theme, alt, className}: {theme: 'light' | 'dark'; alt: string; className: string}) { return ( - {/* width/height on the too, not just the : the two shots have different aspect - ratios, and without them the box is reserved at the wide ratio and jumps on load. */} + {/* width/height on every too, not just the : the shots have different aspect + ratios, and without them the box is reserved at the wide ratio and jumps on load. The first + matching source wins, so the two portrait cases are listed before the wide fallback. */} + {/**/} {alt} - {/* This was the "Democratic" feature card, moved down here verbatim. Asserting it one screen - above its own evidence read as a duplicate; stated immediately before the vote that proves - it, the claim and the proof are one thought. The translation keys are unchanged from the +
+ {/* Two columns from lg up: the claim and its link sit left, the ballot that proves them fills the + right — the width the capped screenshot used to leave empty. This was the "Democratic" feature + card, moved down here verbatim; asserting it one screen above its own evidence read as a + duplicate, so claim and proof are now one thought. The translation keys are unchanged from the card, so the existing fr/de strings apply as they are. */} -

- {t('about.block.democratic.prefix', 'Governed and ')} - {t('about.block.democratic.link_voted', 'voted')} - {t( - 'about.block.democratic.middle', - ' by the community, while ensuring no drift through our ', - )} - - {t('about.block.democratic.link_constitution', 'constitution')} - - {t('about.block.democratic.suffix', '.')} -

+
+
+

+ {t('about.block.democratic.prefix', 'Governed and ')} + {t('about.block.democratic.link_voted', 'voted')} + {t( + 'about.block.democratic.middle', + ' by the community, while ensuring no drift through our ', + )} + + {t('about.block.democratic.link_constitution', 'constitution')} + + {t('about.block.democratic.suffix', '.')} +

-

- {t( - 'about.vote.intro', - 'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:', - )} -

+

+ {t( + 'about.vote.intro', + 'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:', + )} +

- {/* No border here: the captured card brings its own, and adding a second draws a double rule - along the same edge. */} -
- - +
+ {t( + 'about.vote.caption', + 'Every proposal and tally is public — including the ones that lost. ', + )} + + {t('about.vote.link', 'See all proposals →')} + +
+
+ + {/* No border here: the captured card brings its own, and adding a second draws a double rule + along the same edge. Below lg it is the landscape shot, capped and left-aligned so its + baked-in title stays under this block's heading (the shot is 868 CSS px, and filling the + card scaled it past 1:1, blowing that title larger than the heading). From lg up the column + is only ~300px, so serves the portrait capture instead; a fixed width holds it at + a legible scale and a soft shadow lifts it off the surface. */} +
+ + +
- -
- {t( - 'about.vote.caption', - 'Every proposal and tally is public — including the ones that lost. ', - )} - - {t('about.vote.link', 'See all proposals →')} - -
) } diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 4061f627..bed9fbcb 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -1,33 +1,35 @@ import { BellIcon, + BookmarkIcon, ChatBubbleLeftRightIcon, CodeBracketIcon, + EnvelopeIcon, FlagIcon, GiftIcon, - GlobeAltIcon, HeartIcon, LightBulbIcon, MagnifyingGlassIcon, MegaphoneIcon, + ShareIcon, SparklesIcon, } from '@heroicons/react/24/outline' +import {GlobeAltIcon} from '@heroicons/react/24/solid' import clsx from 'clsx' import {discordLink, formLink, githubRepo} from 'common/constants' import {DEPLOYED_WEB_URL} from 'common/envs/constants' -import {ComponentType, ReactNode, SVGProps} from 'react' +import {ComponentType, ReactNode, SVGProps, useEffect, useState} from 'react' import {StatBand} from 'web/components/about/platform-stats' import {RepoActivity} from 'web/components/about/repo-activity' import {AlertDemo} from 'web/components/about/search-alert-demo' import {SectionLabel} from 'web/components/about/section' import {VoteEvidence} from 'web/components/about/vote-evidence' -import {CopyLinkOrShareButton, ShareProfileOnXButton} from 'web/components/buttons/copy-link-button' import {GeneralButton} from 'web/components/buttons/general-button' -import {Row} from 'web/components/layout/row' import {PageBase} from 'web/components/page-base' import {SEO} from 'web/components/SEO' import {MemberGrowth} from 'web/components/widgets/charts' import {Reveal} from 'web/components/widgets/reveal' import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface' +import {useIsMobile} from 'web/hooks/use-is-mobile' import {useT} from 'web/lib/locale' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -86,26 +88,26 @@ function FeatureCard({icon, title, text}: FeatureCardProps) { // ─── Full-width Feature Card ────────────────────────────────────────────────── -function FeatureCardWide({icon, title, text}: FeatureCardProps) { - return ( -
- -
-

{title}

- {/* Capped: the card is full-width, so without this the line runs to ~800px at desktop, well - past a readable measure. The wider page container buys layout room, not longer lines. */} -

{text}

-
-
- ) -} +// function FeatureCardWide({icon, title, text}: FeatureCardProps) { +// return ( +//
+// +//
+//

{title}

+// {/* Capped: the card is full-width, so without this the line runs to ~800px at desktop, well +// past a readable measure. The wider page container buys layout room, not longer lines. */} +//

{text}

+//
+//
+// ) +// } // ─── Spotlight: "Get Notified About Searches" ───────────────────────────────── @@ -121,7 +123,72 @@ function FeatureCardWide({icon, title, text}: FeatureCardProps) { * from floating in dead space at wide viewports — at 1900px the old fixed-width column left roughly half * the screen empty beside it. */ -function NotifySpotlight({title, text, note}: {title: string; text: string; note: string}) { +/** + * One rung of the three-step flow in `NotifySpotlight`. The steps are the fix for the block's dead + * space *and* the honest caption for the clip: the video loops through search → save → email, but a + * glancing reader only ever catches one frame of it, so on its own the phone under-sells the "keyword + * search" half of the promise. Spelling the arc out beside the device means the two capabilities + * (find the exact person; be told when they arrive) both land whether or not the loop is watched. + */ +function FlowStep({ + icon: Icon, + title, + text, + last, +}: { + icon: IconType + title: string + text: string + last?: boolean +}) { + return ( +
  • + {/* The connector runs from just under this rung's marker to the next one, so it stops at the + last step rather than trailing into empty space. */} + {!last && ( + + )} +
    + +
    +
    +
    {title}
    +
    {text}
    +
    +
  • + ) +} + +function NotifySpotlight({title, text}: {title: string; text: string}) { + const t = useT() + + const steps = [ + { + icon: MagnifyingGlassIcon, + title: t('about.block.notify.step1.title', 'Search by keyword'), + text: t( + 'about.block.notify.step1.text', + 'Filter the whole community down to the exact person — values, interests, location.', + ), + }, + { + icon: BookmarkIcon, + title: t('about.block.notify.step2.title', 'Save the search'), + text: t( + 'about.block.notify.step2.text', + 'Nobody matches yet? Save it in one tap instead of checking back.', + ), + }, + { + icon: EnvelopeIcon, + title: t('about.block.notify.step3.title', 'Get the email'), + text: t( + 'about.block.notify.step3.text', + 'We email you the day someone who fits actually joins.', + ), + }, + ] + return (
    + {/* The three steps are what close the gap: short copy centred against a ~580px device is what + left the panel two-thirds empty. They give the column real height, so the row is balanced + by content rather than padded by air. */}

    {title}

    {text}

    -

    {note}

    +
      + {steps.map((s, i) => ( + + ))} +
    {/* The device is 2.16x as tall as it is wide, so at any width that keeps its UI legible it is far taller than the text beside it — centring it just produced ~200px of dead panel above @@ -225,6 +305,60 @@ function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id} * statement above is tinted-light rather than dark. Two dark full-width panels on one page would read as * a repeating band and neither would be the ending. */ +/** + * The share control on the closing strip. + * + * Deliberately mobile-only. On a phone the platform's own share sheet is the right surface — it lets the + * reader pick WhatsApp, Messages, whatever they actually talk to friends and family in, which is exactly + * who this block asks them to tell. On desktop there is no good equivalent (a "copy link" pill was the + * old stand-in and it was doing very little), so we show nothing rather than a weaker button. + * + * Two gates, both required: `useIsMobile()` (viewport under the sm breakpoint) AND a live check that the + * Web Share API exists. Width alone would render a dead button on the odd narrow desktop window; the API + * check alone would surface it on desktop Safari/Edge, which do implement `navigator.share`. `canShare` + * starts false and is only set in an effect, so SSR and the first client paint render nothing — the safe + * desktop state — and the button appears on phones once mounted. + */ +function MobileShareButton() { + const t = useT() + const isMobile = useIsMobile() + const [canShare, setCanShare] = useState(false) + + useEffect(() => { + setCanShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function') + }, []) + + if (!isMobile || !canShare) return null + + const onClick = async () => { + try { + await navigator.share({ + title: t('about.share.title', 'Compass'), + text: t('about.share.text', 'Thoughtful 1-on-1 connections, built in the open.'), + url: DEPLOYED_WEB_URL, + }) + } catch { + // The user dismissing the share sheet rejects the promise; that is not an error worth surfacing. + } + } + + return ( + + ) +} + function ShareStrip({title, text}: {title: string; text: string}) { const t = useT() return ( @@ -248,29 +382,9 @@ function ShareStrip({title, text}: {title: string; text: string}) { and the icon; the sentence itself reads better in plain warm white. */}

    {text}

    - - {/*// */} - {/*// ${*/} - {/*// primary*/} - {/*// ? 'bg-primary-500 text-white border-primary-500 hover:bg-primary-600'*/} - {/*// : 'bg-white/[0.06] text-canvas-200 border-white/10 hover:bg-white/[0.12] hover:text-canvas-50'*/} - {/*// }*/} - - - + {/* Renders only on mobile; on desktop MobileShareButton returns null and the strip is copy-only + by design. */} +
    ) @@ -285,14 +399,14 @@ export default function About() { // the middle of these three; it is now the spotlight block, so what is left is the pair that sets it // up (you can search for anything) and the one that follows from it (what we match on). const searchFeatures: FeatureCardProps[] = [ - { - icon: MagnifyingGlassIcon, - title: t('about.block.keyword.title', 'Keyword Search the Database'), - text: t( - 'about.block.keyword.text', - '"Meditation", "Hiking", "Neuroscience", "Nietzsche". Access any profile and get niche.', - ), - }, + // { + // icon: MagnifyingGlassIcon, + // title: t('about.block.keyword.title', 'Keyword Search the Database'), + // text: t( + // 'about.block.keyword.text', + // '"Meditation", "Hiking", "Neuroscience", "Nietzsche". Access any profile and get niche.', + // ), + // }, { icon: SparklesIcon, title: t('about.block.personality.title', 'Personality-Centered'), @@ -303,6 +417,14 @@ export default function About() { title: t('about.block.free.title', 'Completely Free'), text: t('about.block.free.text', 'Subscription-free. Paywall-free. Ad-free.'), }, + { + icon: GlobeAltIcon, + title: t('about.block.vision.title', 'Digital Public Good'), + text: t( + 'about.block.vision.text', + 'Built by the people who use it, for the benefit of everyone.', + ), + }, ] // The "Democratic" card used to sit here. Its claim now opens the "How a decision gets made" @@ -411,10 +533,6 @@ export default function About() { 'about.block.notify.text', "No need to constantly check the app! We'll contact you when new users fit your searches.", )} - note={t( - 'about.alert.caption', - 'The email is the real one, sent to people with saved searches. Daily at most, and only when somebody new actually matches.', - )} /> @@ -441,44 +559,34 @@ export default function About() { {/* Standalone rather than a grid cell: it is the only card in this section now that "One Mission" has been promoted, and a one-item grid is just an indirection. */} - - - + {/**/} + {/* */} + {/**/} {/* ── How a decision gets made ── */}
    {t('about.vote.label', 'How a decision gets made')} + + + +
    - {/* Owns its own heading, because it renders nothing when GitHub is unreachable and a label with - nothing under it is worse than no section. */} - - {/* ── Help ── */}
    {t('about.help.label', 'Help Compass grow')} - {/* Sits inside the Help section rather than getting a heading of its own: it renders nothing - when the query comes back empty, and a section label with nothing under it is worse than - no section. It also reads as the setup for the cards below — this is what you would be - helping grow. The country spread that used to sit beside it now lives on /stats: it is a - distribution readout for someone who came to read numbers, and this page already makes its - one claim about reach in the stat band up top. */} -
    - -
    - {/* ── Share strip ── */} +
    + +
    +
    {helpCards.map((card, i) => (