diff --git a/common/messages/de.json b/common/messages/de.json index 84862aa3..44c1bc36 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -47,7 +47,20 @@ "about.seo.title": "Über uns", "about.settings.copied": "Kopiert!", "about.settings.copy_info": "Infos kopieren", + "about.share.benefit.events.text": "Mehr Menschen in der Nähe bedeuten bessere Treffen und Veranstaltungen.", + "about.share.benefit.events.title": "Reichere Events", + "about.share.benefit.free.text": "Mehr Beitragende halten Compass werbefrei und ohne Bezahlschranke.", + "about.share.benefit.free.title": "Für immer kostenlos", + "about.share.benefit.match.text": "Jede Person, die beitritt, erhöht die Chance, dass die Person, die Sie suchen, schon hier ist.", + "about.share.benefit.match.title": "Bessere Chancen auf ein Match", + "about.share.benefit.people.text": "Ein größerer Pool an Menschen, die Ihre Werte wirklich teilen.", + "about.share.benefit.people.title": "Mehr Gleichgesinnte", "about.share.button": "Teilen", + "about.share.button_cta": "Compass teilen", + "about.share.copied": "Link kopiert!", + "about.share.headline": "Compass wird für Sie mit jeder Person besser, die Sie mitbringen.", + "about.share.kicker": "Ein Teilen, eine Person — so entsteht eine Gemeinschaft wie diese.", + "about.share.reframe": "Selbst wenn ein Freund nicht Ihr Match ist, bringt er seine Welt mit — seine Freunde, seine Kreise, die nachdenklichen Menschen, denen Sie sonst nie begegnet wären. Teilen ist kein Gefallen an ihn. Es ist eine Investition in Ihre eigenen künftigen Verbindungen.", "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.", @@ -64,6 +77,7 @@ "about.eyebrow": "Über Compass", "about.features.label": "Was uns unterscheidet", "about.growth.label": "Mitglieder, Tendenz steigend", + "about.growth.recent": "+{count} diesen Monat", "about.growth.today": "Heute", "about.help.label": "Helfen Sie Compass zu wachsen", "about.mission.label": "Warum es uns gibt", diff --git a/common/messages/fr.json b/common/messages/fr.json index 1cc24cd3..b5159aa1 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -47,7 +47,20 @@ "about.seo.title": "À propos", "about.settings.copied": "Copié !", "about.settings.copy_info": "Copier les infos", + "about.share.benefit.events.text": "Plus de monde à proximité, ce sont de meilleures rencontres et rassemblements.", + "about.share.benefit.events.title": "Des événements plus riches", + "about.share.benefit.free.text": "Plus de contributeurs gardent Compass sans pub ni abonnement.", + "about.share.benefit.free.title": "Gratuit, pour toujours", + "about.share.benefit.match.text": "Chaque nouvelle personne augmente les chances que celle que tu cherches soit déjà là.", + "about.share.benefit.match.title": "De meilleures chances de rencontre", + "about.share.benefit.people.text": "Un plus grand vivier de personnes qui partagent vraiment tes valeurs.", + "about.share.benefit.people.title": "Plus de personnes pour vous", "about.share.button": "Partager", + "about.share.button_cta": "Partager Compass", + "about.share.copied": "Lien copié !", + "about.share.headline": "Compass s'améliore pour toi à chaque personne que tu invites.", + "about.share.kicker": "Un partage, une personne — c'est ainsi qu'une communauté comme celle-ci se construit.", + "about.share.reframe": "Même si un ami n'est pas ton match, il amène son monde avec lui — ses amis, ses cercles, les personnes réfléchies que tu n'aurais jamais rencontrées autrement. Partager n'est pas un service que tu lui rends. C'est un investissement dans tes propres connexions futures.", "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.", @@ -64,6 +77,7 @@ "about.eyebrow": "À propos de Compass", "about.features.label": "Ce qui nous différencie", "about.growth.label": "membres, et ça continue", + "about.growth.recent": "+{count} ce mois-ci", "about.growth.today": "Aujourd'hui", "about.help.label": "Aide Compass à grandir", "about.mission.label": "Pourquoi nous existons", diff --git a/web/components/about/vote-evidence.tsx b/web/components/about/vote-evidence.tsx index 46f74123..e020b5b0 100644 --- a/web/components/about/vote-evidence.tsx +++ b/web/components/about/vote-evidence.tsx @@ -119,7 +119,7 @@ export function VoteEvidence() {

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

diff --git a/web/components/widgets/charts.tsx b/web/components/widgets/charts.tsx index d3a2cdcc..5a32d561 100644 --- a/web/components/widgets/charts.tsx +++ b/web/components/widgets/charts.tsx @@ -1,4 +1,5 @@ -import {useEffect, useState} from 'react' +import {ArrowTrendingUpIcon} from '@heroicons/react/24/outline' +import {useEffect, useRef, useState} from 'react' import { Area, AreaChart, @@ -182,10 +183,18 @@ function CustomLegend({payload}: any) { * does, but it grows linearly — past a few thousand members this wants an aggregate endpoint * returning daily totals instead. */ +// How long the line takes to draw itself in on reveal. Reused as the delay before the endpoint dot +// fades in, so the dot lands exactly when the line reaches it rather than floating ahead of it. +const GROWTH_DRAW_MS = 1400 + export function MemberGrowth() { const t = useT() const [data, setData] = useState<{dateTs: number; members: number}[]>([]) const [failed, setFailed] = useState(false) + // 'hidden' → not mounted; 'drawing' → recharts animates it in; 'done' → static, endpoint dot + // shown. Driven by an IntersectionObserver so the curve grows when the reader scrolls to it. + const [phase, setPhase] = useState<'hidden' | 'drawing' | 'done'>('hidden') + const figureRef = useRef(null) useEffect(() => { async function load() { @@ -211,6 +220,30 @@ export function MemberGrowth() { void load() }, []) + // Draw the curve on reveal, not on data load — a line "growing" only reads as growth if the reader + // watches it happen. Re-armed when the data arrives (the figure, and so the ref, only mounts then). + // Reduced-motion visitors skip the draw and get the final curve immediately. + useEffect(() => { + const el = figureRef.current + if (!el || phase !== 'hidden') return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setPhase('done') + return + } + const obs = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + obs.disconnect() + setPhase('drawing') + window.setTimeout(() => setPhase('done'), GROWTH_DRAW_MS) + } + }, + {rootMargin: '0px 0px -10% 0px'}, + ) + obs.observe(el) + return () => obs.disconnect() + }, [data.length, phase]) + // Render nothing rather than a zero or a spinner: this is supporting evidence on a page that reads // fine without it, and an empty chart frame claims more than it shows. if (failed || !data.length) return null @@ -219,33 +252,78 @@ export function MemberGrowth() { const monthYear = (ts: number) => new Date(ts).toLocaleDateString('en-US', {month: 'short', year: 'numeric'}) + // Trailing-30-day growth, shown as a momentum badge beside the total. Read from the real series (never + // hardcoded), and rendered only when positive — a flat or empty period simply omits it rather than + // showing "+0", the same "say nothing rather than something weak" rule the rest of the page follows. + const lastIndex = data.length - 1 + const recent = total - data[Math.max(0, lastIndex - 30)].members + return ( -
-
- {total.toLocaleString()} - - {t('about.growth.label', 'members and counting')} - +
+
+
+ {total.toLocaleString()} + + {t('about.growth.label', 'members and counting')} + +
+ {recent > 0 && ( + + + {t('about.growth.recent', '+{count} this month', {count: recent})} + + )}
- + - + - + {/* Mounted only once in view, so recharts draws it in from the left on reveal. Held out + entirely while 'hidden' rather than rendered invisibly — that keeps the animation from + firing off-screen before the reader ever sees it. */} + {phase !== 'hidden' && ( + { + const {cx, cy, index} = props + if (index !== lastIndex || cx == null || cy == null) + return + return ( + + + + + ) + }} + /> + )}
diff --git a/web/pages/about.tsx b/web/pages/about.tsx index bed9fbcb..48381f1a 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -2,6 +2,7 @@ import { BellIcon, BookmarkIcon, ChatBubbleLeftRightIcon, + CheckIcon, CodeBracketIcon, EnvelopeIcon, FlagIcon, @@ -12,12 +13,14 @@ import { MegaphoneIcon, ShareIcon, SparklesIcon, + UserGroupIcon, } 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, useEffect, useState} from 'react' +import Link from 'next/link' +import {ComponentType, ReactNode, SVGProps, 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' @@ -29,8 +32,9 @@ 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 {useUser} from 'web/hooks/use-user' import {useT} from 'web/lib/locale' +import {copyToClipboard} from 'web/lib/util/copy' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -260,12 +264,47 @@ function MissionStatement({title, text}: {title: string; text: string}) { ) } -// ─── Help Card ──────────────────────────────────────────────────────────────── +// ─── Help Cards ─────────────────────────────────────────────────────────────── -function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id}: HelpCardProps) { +/** + * The lead of the "other ways to help" group. Contributing suggestions/help is the one we most want of + * the four, so it is the full-width horizontal card with a large icon and a *button* rather than a bare + * text link — but only an outline button, never a filled one. The filled amber CTA is spoken for by + * "Share Compass" one block up, which must stay the loudest thing here; a second filled CTA next to it + * out-shouts it. So the hierarchy is deliberately: filled Share › outlined Suggest › the three links. + */ +function FeaturedHelpCard({icon, title, text, buttonLabel, buttonUrl, id}: HelpCardProps) { return (
+ +
+

+ {title} +

+

{text}

+
+
+ +
+
+ ) +} + +function HelpCard({icon, title, text, buttonLabel, buttonUrl, id}: HelpCardProps) { + return ( +

{text}

- {/* `mt-auto` on the button rather than `flex-1` on the paragraph, and the parent is now actually - `flex flex-col`. It was not, so the old `flex-1` did nothing and the four buttons in this grid - sat wherever their card's text happened to end — measured 20-23px out of line with each other. */} -
- -
+ {/* A text link, not a boxed button: these three are the secondary asks, so their action is + deliberately lighter than the featured card's filled CTA. `mt-auto` pins it to the card's base + so the three links line up regardless of copy length. */} + + {buttonLabel} +
) } @@ -299,47 +335,63 @@ function HelpCard({icon, title, text, buttonLabel, buttonUrl, buttonPrimary, id} // ─── Share Strip ────────────────────────────────────────────────────────────── /** - * The closing ask, and the page's visual climax. - * - * It is the only dark block, which is what makes it land — so it is also the reason the "One Mission" - * 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. + * One network-effect benefit in the closing share block. Kept terse — the argument is carried by the + * headline and the reframe beside it; these three just make "better for you" concrete (more people who + * fit, better events, still free). Styled for the dark panel: faint tile, amber glyph, warm-white text. */ +function ShareBenefit({icon: Icon, title, text}: {icon: IconType; title: string; text: string}) { + return ( +
  • +
    + +
    +
    +
    {title}
    +
    {text}
    +
    +
  • + ) +} + /** - * The share control on the closing strip. + * The share control on the closing block. * - * 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. + * Universal, not mobile-only: the block's whole argument is that sharing is easy and in the reader's + * interest, so a desktop with no button would undercut it. Phones get the native share sheet (they can + * pick WhatsApp, Messages, ...); everywhere else — and any browser without the Web Share API — it copies + * the link and confirms. The API is probed at click time, so there is no SSR/first-paint branch to get + * wrong and the button always renders. * - * 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. + * When the sharer is signed in the link carries their `?referrer=` tag, the same attribution the + * /referrals page and users.ts already speak — so the shares this block is arguing for actually get + * credited to them, which is the whole self-interest case. Logged-out visitors (the page is public) + * have no username, so they share the bare URL. */ -function MobileShareButton() { +function ShareCTA() { const t = useT() - const isMobile = useIsMobile() - const [canShare, setCanShare] = useState(false) + const user = useUser() + const [copied, setCopied] = useState(false) - useEffect(() => { - setCanShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function') - }, []) - - if (!isMobile || !canShare) return null + const shareUrl = user?.username + ? `${DEPLOYED_WEB_URL}/?referrer=${user.username}` + : DEPLOYED_WEB_URL 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. + if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') { + 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: shareUrl, + }) + } catch { + // The user dismissing the share sheet rejects the promise; not an error worth surfacing. + } + return } + copyToClipboard(shareUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) } return ( @@ -347,44 +399,130 @@ function MobileShareButton() { type="button" onClick={onClick} className={clsx( - 'inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold border', + 'inline-flex items-center gap-2 rounded-xl border px-5 py-2.5 text-sm font-semibold', 'transition-all duration-200 ease-out', 'bg-cta text-white border-cta hover:bg-cta-hover', 'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]', )} > -