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 (
-
-
-
+
-
+
-
+ {/* 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 (
- {/* `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)]',
)}
>
-
- {t('about.share.button', 'Share')}
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied
+ ? t('about.share.copied', 'Link copied!')
+ : t('about.share.button_cta', 'Share Compass')}
)
}
-function ShareStrip({title, text}: {title: string; text: string}) {
+/**
+ * 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.
+ *
+ * The ask is framed as self-interest rather than charity (it used to close with "thank you for
+ * supporting our mission", which asks for a favour). Same argument as the share-compass email: growth is
+ * a network effect the sharer benefits from, and even a friend who is not your match brings their world
+ * with them. The headline states the payoff, the paragraph reframes the obvious objection, the benefits
+ * make the payoff concrete, and the full-width bar at the base carries the one action.
+ */
+function ShareStrip() {
const t = useT()
+
+ const benefits = [
+ {
+ icon: UserGroupIcon,
+ title: t('about.share.benefit.people.title', 'More kindred spirits'),
+ text: t(
+ 'about.share.benefit.people.text',
+ 'A bigger pool of people who actually share your values.',
+ ),
+ },
+ // {
+ // icon: CalendarDaysIcon,
+ // title: t('about.share.benefit.events.title', 'Richer events'),
+ // text: t(
+ // 'about.share.benefit.events.text',
+ // 'More people nearby means better meetups and gatherings.',
+ // ),
+ // },
+ // Dropped: "Better odds of a match". "Match" is swipe-app vocabulary, and Compass positions itself
+ // against exactly that — it was the one benefit here that sounded like the thing we aren't.
+ // {
+ // icon: HeartIcon,
+ // title: t('about.share.benefit.match.title', 'Better odds of a match'),
+ // text: t(
+ // 'about.share.benefit.match.text',
+ // "Every person who joins raises the chance the one you're looking for is already here.",
+ // ),
+ // },
+ {
+ icon: GiftIcon,
+ title: t('about.share.benefit.free.title', 'Free, forever'),
+ text: t(
+ 'about.share.benefit.free.text',
+ 'More contributors keep Compass ad-free and paywall-free.',
+ ),
+ },
+ ]
+
return (
-
-
-
-
-
- {t('about.final.label', 'Spread the word')}
-
-
-
- {title}
-
- {/* Was `text-primary-500` — amber body copy on espresso. The accent belongs on the eyebrow
- and the icon; the sentence itself reads better in plain warm white. */}
-
{text}
+
+
+
+
+ {t('about.final.label', 'Spread the word')}
+
+
+
+
+
+
+ {t(
+ 'about.share.headline',
+ 'Compass gets better for you with every person you bring.',
+ )}
+
+ {/* The reframe, condensed from the share-compass email: the friend you tell need not be your
+ match — they bring their world, and that is the reader's own upside. */}
+
+ {t(
+ 'about.share.reframe',
+ "Even if a friend isn't your match, they bring their world with them — their friends, their circles, the thoughtful people you'd never have met otherwise. Sharing isn't just a favor to them. It's an investment in your own future connections.",
+ )}
+
+
+
+ {/* An inset panel rather than a bare list: a few short rows floating in a tall column read as
+ empty space. The panel gives the right side visual mass, stretches to the copy's height
+ (items-stretch on the grid), and centres the benefits within it. */}
+
+ {benefits.map((b) => (
+
+ ))}
+
+
+
+ {/* The share action sits at the base of the block, under a hairline rule and spanning the full
+ width, so it reads as the block's climax rather than a mid-column element. */}
+
+
+ {/**/}
+ {/* {t(*/}
+ {/* 'about.share.kicker',*/}
+ {/* "One share, one person — that's how a community like this is built.",*/}
+ {/* )}*/}
+ {/**/}
- {/* Renders only on mobile; on desktop MobileShareButton returns null and the strip is copy-only
- by design. */}
-
)
@@ -587,24 +725,29 @@ export default function About() {
{t('about.help.label', 'Help Compass grow')}
- {/* ── Share strip ── */}
-
-
-
+ {/* Growth line first, share block second: the momentum is what motivates the ask. Show that
+ Compass is growing, then invite the reader to add to it — the same order as the
+ share-compass email, which opens with the member count before asking. The share block then
+ closes the section on the action rather than on a passive chart. */}
+
+ {/* ── Share strip ── */}
-
+
+
+
-
- {helpCards.map((card, i) => (
-
+ {/* One primary ask leads, then the three lighter ones. "Give Suggestions or Contribute" is
+ the contribution we most want, so it is the full-width featured card with the filled CTA;
+ the rest sit below as a quieter row of three with text-link actions. */}
+
+
+
+
+