mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<SVGProps<SVGSVGElement>>
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-[18px] h-[18px] text-primary-600" strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-heading text-xl font-bold text-ink-900 leading-tight tabular-nums">
|
||||
<div className="flex items-center gap-2 font-heading text-xl font-bold text-ink-900 leading-tight tabular-nums">
|
||||
{/* 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 && (
|
||||
<span className="relative flex h-2 w-2 shrink-0" aria-hidden>
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-primary-500/70 motion-safe:animate-ping" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
|
||||
</span>
|
||||
)}
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-ink-600 truncate">{label}</div>
|
||||
@@ -55,7 +74,7 @@ function daysAgoLabel(date: Date, t: ReturnType<typeof useT>) {
|
||||
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>
|
||||
{/* 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. */}
|
||||
<SectionLabel>{t('about.repo.label', 'Built in the open')}</SectionLabel>
|
||||
<Reveal className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
|
||||
{t(
|
||||
'about.repo.claim',
|
||||
'Community owned. No investors, no acquisition, nothing to sell you.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-ink-600 leading-relaxed mb-7 max-w-2xl">
|
||||
{t(
|
||||
'about.repo.intro',
|
||||
'The whole thing is built in public — every line of code, every change, open to read and to challenge:',
|
||||
)}
|
||||
</p>
|
||||
// <Section>
|
||||
// <SectionLabel>{t('about.repo.label', 'Built in the open')}</SectionLabel>
|
||||
<Reveal className={clsx(surface, 'p-6 sm:p-8', className)}>
|
||||
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
|
||||
{t(
|
||||
'about.repo.claim',
|
||||
'Community owned. No investors, no acquisition, nothing to sell you.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-ink-600 leading-relaxed max-w-2xl">
|
||||
{t(
|
||||
'about.repo.intro',
|
||||
'The whole thing is built in public — every line of code, every change, open to read and to challenge:',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{data.contributors !== null && (
|
||||
<Metric
|
||||
icon={UserGroupIcon}
|
||||
value={data.contributors}
|
||||
label={t('about.repo.contributors', 'Contributors')}
|
||||
/>
|
||||
)}
|
||||
{data.stars !== null && (
|
||||
<Metric
|
||||
icon={StarIcon}
|
||||
value={data.stars}
|
||||
label={t('about.repo.stars', 'GitHub stars')}
|
||||
/>
|
||||
)}
|
||||
{lastCommit && !isNaN(lastCommit.getTime()) && (
|
||||
<Metric
|
||||
icon={CodeBracketIcon}
|
||||
value={daysAgoLabel(lastCommit, t)}
|
||||
label={t('about.repo.last_commit', 'Last change')}
|
||||
/>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="mt-7 border-t border-canvas-200 pt-7 flex flex-wrap items-center justify-between gap-x-8 gap-y-5">
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-5">
|
||||
{metrics.map((m) => (
|
||||
<Metric key={m.label} icon={m.icon} value={m.value} label={m.label} live={m.live} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed mt-7">
|
||||
<a
|
||||
href={githubRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary-700 hover:underline"
|
||||
>
|
||||
{t('about.repo.link', 'Read the source →')}
|
||||
</a>
|
||||
</p>
|
||||
</Reveal>
|
||||
</Section>
|
||||
<a
|
||||
href={githubRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-canvas-200 bg-canvas-100 px-4 py-2 text-sm font-medium text-primary-700 transition-colors hover:bg-canvas-200/60"
|
||||
>
|
||||
{t('about.repo.link', 'Read the source →')}
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
// </Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <picture> 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 (
|
||||
<picture>
|
||||
{/* width/height on the <source> too, not just the <img>: 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 <source> too, not just the <img>: 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. */}
|
||||
<source
|
||||
media={NARROW_MEDIA}
|
||||
media={'(min-width: 0px)'}
|
||||
srcSet={`/images/vote-tally-${theme}-narrow.webp`}
|
||||
width={SHOTS.narrow.width}
|
||||
height={SHOTS.narrow.height}
|
||||
/>
|
||||
{/*<source*/}
|
||||
{/* media={NARROW_MEDIA}*/}
|
||||
{/* srcSet={`/images/vote-tally-${theme}-narrow.webp`}*/}
|
||||
{/* width={SHOTS.narrow.width}*/}
|
||||
{/* height={SHOTS.narrow.height}*/}
|
||||
{/*/>*/}
|
||||
<img
|
||||
src={`/images/vote-tally-${theme}.webp`}
|
||||
src={`/images/vote-tally-${theme}-narrow.webp`}
|
||||
alt={alt}
|
||||
width={SHOTS.wide.width}
|
||||
height={SHOTS.wide.height}
|
||||
@@ -82,47 +95,56 @@ export function VoteEvidence() {
|
||||
)
|
||||
|
||||
return (
|
||||
<figure className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
{/* 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
|
||||
<figure className={clsx(surface, 'p-6 sm:p-8 lg:p-10')}>
|
||||
{/* 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. */}
|
||||
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
|
||||
{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 ',
|
||||
)}
|
||||
<Link href="/constitution" className="text-primary-500 hover:underline">
|
||||
{t('about.block.democratic.link_constitution', 'constitution')}
|
||||
</Link>
|
||||
{t('about.block.democratic.suffix', '.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 items-center gap-8 sm:grid-cols-[1fr_auto] lg:gap-12">
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-ink-900 text-xl sm:text-2xl leading-snug tracking-tight mb-3 max-w-2xl text-balance">
|
||||
{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 ',
|
||||
)}
|
||||
<Link href="/constitution" className="text-primary-500 hover:underline">
|
||||
{t('about.block.democratic.link_constitution', 'constitution')}
|
||||
</Link>
|
||||
{t('about.block.democratic.suffix', '.')}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-ink-600 leading-relaxed mb-7 max-w-2xl">
|
||||
{t(
|
||||
'about.vote.intro',
|
||||
'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-ink-600 leading-relaxed max-w-md">
|
||||
{t(
|
||||
'about.vote.intro',
|
||||
'Members propose changes, everyone votes, and the result is binding. This one added a step to signing up:',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* No border here: the captured card brings its own, and adding a second draws a double rule
|
||||
along the same edge. */}
|
||||
<div className="overflow-hidden rounded-xl">
|
||||
<Shot theme="light" alt={alt} className="dark:hidden" />
|
||||
<Shot theme="dark" alt={alt} className="hidden dark:block" />
|
||||
<figcaption className="text-sm text-ink-600 leading-relaxed mt-5">
|
||||
{t(
|
||||
'about.vote.caption',
|
||||
'Every proposal and tally is public — including the ones that lost. ',
|
||||
)}
|
||||
<Link href="/vote" className="font-medium text-primary-700 hover:underline">
|
||||
{t('about.vote.link', 'See all proposals →')}
|
||||
</Link>
|
||||
</figcaption>
|
||||
</div>
|
||||
|
||||
{/* 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 <picture> serves the portrait capture instead; a fixed width holds it at
|
||||
a legible scale and a soft shadow lifts it off the surface. */}
|
||||
<div className="max-w-xl mx-auto overflow-hidden rounded-xl w-[300px] lg:w-[264px] lg:max-w-none lg:shadow-[0_24px_60px_-28px_rgb(44_36_22/0.5)]">
|
||||
<Shot theme="light" alt={alt} className="dark:hidden" />
|
||||
<Shot theme="dark" alt={alt} className="hidden dark:block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figcaption className="text-sm text-ink-600 leading-relaxed mt-5">
|
||||
{t(
|
||||
'about.vote.caption',
|
||||
'Every proposal and tally is public — including the ones that lost. ',
|
||||
)}
|
||||
<Link href="/vote" className="font-medium text-primary-700 hover:underline">
|
||||
{t('about.vote.link', 'See all proposals →')}
|
||||
</Link>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={clsx(
|
||||
surface,
|
||||
surfaceHover,
|
||||
'col-span-1 md:col-span-2 p-6 sm:p-7',
|
||||
'flex flex-col sm:flex-row items-start sm:items-center gap-5 sm:gap-6',
|
||||
)}
|
||||
>
|
||||
<IconChip icon={icon} />
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-ink-900 mb-2">{title}</h3>
|
||||
{/* 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. */}
|
||||
<p className="text-sm text-ink-600 leading-relaxed max-w-3xl">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// function FeatureCardWide({icon, title, text}: FeatureCardProps) {
|
||||
// return (
|
||||
// <div
|
||||
// className={clsx(
|
||||
// surface,
|
||||
// surfaceHover,
|
||||
// 'col-span-1 md:col-span-2 p-6 sm:p-7',
|
||||
// 'flex flex-col sm:flex-row items-start sm:items-center gap-5 sm:gap-6',
|
||||
// )}
|
||||
// >
|
||||
// <IconChip icon={icon} />
|
||||
// <div className="min-w-0">
|
||||
// <h3 className="font-bold text-ink-900 mb-2">{title}</h3>
|
||||
// {/* 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. */}
|
||||
// <p className="text-sm text-ink-600 leading-relaxed max-w-3xl">{text}</p>
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
// ─── 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 (
|
||||
<li className="relative flex gap-4 pb-6 last:pb-0">
|
||||
{/* 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 && (
|
||||
<span aria-hidden className="absolute left-5 top-11 -bottom-0 w-px bg-canvas-200" />
|
||||
)}
|
||||
<div className="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 ring-1 ring-primary-200">
|
||||
<Icon className="h-5 w-5 text-primary-600" strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="min-w-0 pt-1">
|
||||
<div className="font-semibold text-ink-900 leading-snug">{title}</div>
|
||||
<div className="text-sm text-ink-600 leading-relaxed mt-1">{text}</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={clsx(surface, 'relative overflow-hidden p-6 sm:p-10')}>
|
||||
<div
|
||||
@@ -129,13 +196,26 @@ function NotifySpotlight({title, text, note}: {title: string; text: string; note
|
||||
className="pointer-events-none absolute -right-24 top-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full bg-primary-500/[0.07] blur-3xl"
|
||||
/>
|
||||
<div className="relative grid grid-cols-1 md:grid-cols-[1fr_auto] gap-10 md:gap-14 items-center">
|
||||
{/* 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. */}
|
||||
<div className="min-w-0">
|
||||
<IconChip icon={BellIcon} large />
|
||||
<h3 className="font-heading font-bold text-ink-900 text-[clamp(26px,3vw,38px)] leading-[1.15] tracking-tight mt-6 mb-4 text-balance">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-base sm:text-lg text-ink-600 leading-relaxed max-w-lg">{text}</p>
|
||||
<p className="text-sm text-ink-600 leading-relaxed max-w-lg mt-4">{note}</p>
|
||||
<ol className="mt-8 max-w-md">
|
||||
{steps.map((s, i) => (
|
||||
<FlowStep
|
||||
key={s.title}
|
||||
icon={s.icon}
|
||||
title={s.title}
|
||||
text={s.text}
|
||||
last={i === steps.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{/* 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold border',
|
||||
'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)]',
|
||||
)}
|
||||
>
|
||||
<ShareIcon className="w-[1.05rem] h-[1.05rem]" strokeWidth={2} aria-hidden="true" />
|
||||
{t('about.share.button', 'Share')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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. */}
|
||||
<p className="text-white/70 text-base leading-relaxed">{text}</p>
|
||||
</div>
|
||||
<Row className="flex gap-2 flex-wrap">
|
||||
{/*// */}
|
||||
{/*// ${*/}
|
||||
{/*// 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'*/}
|
||||
{/*// }*/}
|
||||
<ShareProfileOnXButton
|
||||
className={clsx(
|
||||
'px-5 py-2.5 rounded-xl text-sm font-semibold border transition-all duration-200 ease-out',
|
||||
'bg-white/[0.06] !text-white/70 border-white/10 hover:bg-white/[0.12] hover:text-white',
|
||||
)}
|
||||
/>
|
||||
<CopyLinkOrShareButton
|
||||
url={DEPLOYED_WEB_URL}
|
||||
children={t('about.copy_link', ' Copy Link')}
|
||||
className={clsx(
|
||||
'px-5 py-2.5 rounded-xl text-sm font-semibold border transition-all duration-200 ease-out',
|
||||
'bg-cta text-white hover:text-white border-cta hover:bg-cta-hover',
|
||||
'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]',
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
{/* Renders only on mobile; on desktop MobileShareButton returns null and the strip is copy-only
|
||||
by design. */}
|
||||
<MobileShareButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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.',
|
||||
)}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
@@ -441,44 +559,34 @@ export default function About() {
|
||||
</Reveal>
|
||||
{/* 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. */}
|
||||
<Reveal className="mt-4 sm:mt-5">
|
||||
<FeatureCardWide
|
||||
icon={GlobeAltIcon}
|
||||
title={t('about.block.vision.title', 'Vision')}
|
||||
text={t(
|
||||
'about.block.vision.text',
|
||||
'Compass is to human connection what Linux, Wikipedia, and Firefox are to software and knowledge: a public good built by the people who use it, for the benefit of everyone.',
|
||||
)}
|
||||
/>
|
||||
</Reveal>
|
||||
{/*<Reveal className="mt-4 sm:mt-5">*/}
|
||||
{/* <FeatureCardWide*/}
|
||||
{/* icon={GlobeAltIcon}*/}
|
||||
{/* title={t('about.block.vision.title', 'Vision')}*/}
|
||||
{/* text={t(*/}
|
||||
{/* 'about.block.vision.text',*/}
|
||||
{/* 'Compass is to human connection what Linux, Wikipedia, and Firefox are to software and knowledge: a public good built by the people who use it, for the benefit of everyone.',*/}
|
||||
{/* )}*/}
|
||||
{/* />*/}
|
||||
{/*</Reveal>*/}
|
||||
</Section>
|
||||
|
||||
{/* ── How a decision gets made ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.vote.label', 'How a decision gets made')}</SectionLabel>
|
||||
|
||||
<Reveal>
|
||||
<VoteEvidence />
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<RepoActivity className="mt-4 sm:mt-5" />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* Owns its own heading, because it renders nothing when GitHub is unreachable and a label with
|
||||
nothing under it is worse than no section. */}
|
||||
<RepoActivity />
|
||||
|
||||
{/* ── Help ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.help.label', 'Help Compass grow')}</SectionLabel>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="mb-5">
|
||||
<MemberGrowth />
|
||||
</div>
|
||||
|
||||
{/* ── Share strip ── */}
|
||||
<Reveal>
|
||||
<ShareStrip
|
||||
@@ -490,6 +598,10 @@ export default function About() {
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-5">
|
||||
<MemberGrowth />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-5 mt-5">
|
||||
{helpCards.map((card, i) => (
|
||||
<Reveal key={card.id} delay={(i % 2) * 70}>
|
||||
|
||||
Reference in New Issue
Block a user