mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
Solid re-design of home / about / profile / press pages
This commit is contained in:
@@ -11,7 +11,7 @@ android {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 134
|
||||
versionCode 135
|
||||
versionName "1.32.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"version": "1.51.0",
|
||||
"version": "1.52.0",
|
||||
"private": true,
|
||||
"description": "Backend API endpoints",
|
||||
"main": "src/serve.ts",
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"compatibility.empty.no_results": "Keine Ergebnisse für \"{keyword}\"",
|
||||
"compatibility.empty.not_answered": "Alle Fragen wurden beantwortet!",
|
||||
"compatibility.empty.skipped": "Sie haben keine Fragen übersprungen.",
|
||||
"compatibility.label": "Kompatibilität",
|
||||
"compatibility.onboarding.body1": "Beantworten Sie einige kurze Fragen, um die Kompatibilität basierend auf Werten und Vorlieben zu berechnen — nicht auf Fotos oder Swipes.",
|
||||
"compatibility.onboarding.body2": "Ihre Antworten beeinflussen direkt, wer mit Ihnen übereinstimmt und wie stark.",
|
||||
"compatibility.onboarding.impact": "Die meisten Menschen, die mindestens 3 Fragen beantworten, sehen wesentlich relevantere Übereinstimmungen.",
|
||||
@@ -559,6 +560,8 @@
|
||||
"languages.de": "Deutsch",
|
||||
"languages.en": "Englisch",
|
||||
"languages.fr": "Französisch",
|
||||
"media.next": "Weiter",
|
||||
"media.previous": "Zurück",
|
||||
"messages.action.add_reaction": "Reaktion hinzufügen",
|
||||
"messages.action.delete": "Löschen",
|
||||
"messages.action.edit": "Bearbeiten",
|
||||
@@ -593,6 +596,11 @@
|
||||
"messages.you_prefix": "Sie: ",
|
||||
"messaging.email_verification_required": "Sie müssen Ihre E-Mail überprüfen, um Personen zu schreiben.",
|
||||
"messaging.send_thoughtful_message": "Sende ihnen eine durchdachte Nachricht",
|
||||
"profile.age_max_compact": "unter {max}",
|
||||
"profile.age_min_compact": "{min}+",
|
||||
"profile.age_range_compact": "{min}-{max}",
|
||||
"profile.connect.no_shared_types": "Du und {name} seid nicht für dieselbe Art von Verbindung offen.",
|
||||
"profile.interests": "Interessen",
|
||||
"send_message.placeholder": "Was hat dich an {name}s Profil wirklich angesprochen? Was möchtest du gemeinsam erkunden?",
|
||||
"send_message.title": "Beginne ein bedeutungsvolles Gespräch",
|
||||
"send_message.guidance": "Compass geht um Tiefe. Nimm dir einen Moment, um etwas Echtes zu schreiben.",
|
||||
@@ -880,6 +888,7 @@
|
||||
"profile.education.short_name": "Bildung",
|
||||
"profile.education.some-college": "Hochschulbildung",
|
||||
"profile.ethnicity": "Ethnische Herkunft",
|
||||
"profile.gallery.show_photo": "Foto {number} anzeigen",
|
||||
"profile.gender": "Geschlecht",
|
||||
"profile.gender.agender": "Agender",
|
||||
"profile.gender.androgynous": "Androgyn",
|
||||
@@ -1052,6 +1061,7 @@
|
||||
"profile.language.zhuang": "Zhuang",
|
||||
"profile.language.zulu": "Zulu",
|
||||
"profile.languages": "Sprachen",
|
||||
"profile.looking_for": "Sucht",
|
||||
"profile.last_online": "Zuletzt online {time}",
|
||||
"profile.links": "Links",
|
||||
"profile.llm.extract.button_text": "Profildaten aus Text extrahieren",
|
||||
@@ -1281,6 +1291,7 @@
|
||||
"profile.smoker.yes": "Raucher",
|
||||
"profile.smokes": "Raucht",
|
||||
"profile.smoking": "Rauchen",
|
||||
"profile.status": "Status",
|
||||
"profile.substance_intention.ceremonial_spiritual": "Zeremoniell / spirituell",
|
||||
"profile.substance_intention.recreational": "Rekreativ",
|
||||
"profile.substance_intention.therapeutic": "Therapeutisch",
|
||||
@@ -1704,5 +1715,180 @@
|
||||
"faq.stuck.text": "Compass wird von den Menschen gebaut, die es nutzen — frag alles, und die Antwort landet meist auf dieser Seite für die nächste Person.",
|
||||
"faq.stuck.title": "Antwort nicht gefunden?",
|
||||
"faq.title": "Häufig gestellte Fragen",
|
||||
"faq.search.count_one": "1 passende Frage"
|
||||
"faq.search.count_one": "1 passende Frage",
|
||||
"home.eyebrow.v2": "Für immer kostenlos · Open Source · Kein Swipen",
|
||||
"home.subtitle.v2": "Lies und durchsuche jedes Profil — nach Werten, Interessen, Politik oder einem einzigen Wort wie ",
|
||||
"home.subtitle.v2.keyword": "Meditation",
|
||||
"home.subtitle.v2.end": ". Sie entscheiden, wem Sie schreiben. Kein Algorithmus entscheidet für Sie.",
|
||||
"home.proof.conversations": " begonnene Gespräche · ",
|
||||
"home.proof.messages": " gesendete Nachrichten",
|
||||
"home.who.label": "Wer hier ist",
|
||||
"home.who.title": "Sie sehen genau, wem Sie sich anschließen würden.",
|
||||
"home.who.intro": "Keine allgemeinen Adjektive. Unter den Mitgliedern, die geantwortet haben: ",
|
||||
"home.who.age": " sind zwischen 25 und 44, ",
|
||||
"home.who.education": " haben mindestens einen Bachelorabschluss",
|
||||
"home.who.gender": ", und {pct} % sind Männer",
|
||||
"home.who.religion": ", und die zwei häufigsten Antworten zur Religion sind {a} und {b}",
|
||||
"home.who.mbti": "Die häufigsten Persönlichkeitstypen sind {a} und {b}. ",
|
||||
"home.who.looking": "{relationship} suchen eine Beziehung, {friendship} Freundschaft und {collaboration} Mitstreiter — die meisten mehr als eines davon.",
|
||||
"home.who.looking.v2": "Die Mehrheit sucht eine Beziehung, doch viele suchen auch Freundschaften.",
|
||||
"home.who.chart.title": "Gesucht wird",
|
||||
"home.who.chart.subtitle": "Von {base}, die geantwortet haben",
|
||||
"home.who.link": "Die vollständige Auswertung ansehen →",
|
||||
"home.features.title.v2": "Drei Dinge, die keine Swipe-App sagen kann.",
|
||||
"home.features.title.v3": "Drei Dinge, die Sie vor der Anmeldung wissen sollten.",
|
||||
"home.profile.label": "Was Sie durchsuchen",
|
||||
"home.profile.title": "Ein Profil hier schreibt man in zwanzig Minuten, nicht in zwei.",
|
||||
"home.profile.text": "Kein Foto und ein Einzeiler. Eine Biografie in eigenen Worten, Antworten auf Fragen, die Themen, die Ihnen wichtig sind, Ihre Politik, Religion, Ernährung, Sprachen, Ausbildung, Ihr Persönlichkeitstyp und die Art von Verbindung, die Sie suchen. Mehr als zwanzig Filter greifen auf all das zu — und die Freitextsuche liest den Fließtext, sodass „Meditation“ die Person findet, die darüber geschrieben hat, nicht nur die, die es als Stichwort gesetzt hat.",
|
||||
"home.profile.chips.title": "Was ein Profil enthalten kann",
|
||||
"home.profile.chip.bio": "Biografie",
|
||||
"home.profile.chip.prompts": "Fragen",
|
||||
"home.profile.chip.causes": "Anliegen",
|
||||
"home.profile.chip.politics": "Politik",
|
||||
"home.profile.chip.religion": "Religion",
|
||||
"home.profile.chip.diet": "Ernährung",
|
||||
"home.profile.chip.languages": "Sprachen",
|
||||
"home.profile.chip.education": "Ausbildung",
|
||||
"home.profile.chip.personality": "Persönlichkeit",
|
||||
"home.profile.chip.looking": "Was Sie suchen",
|
||||
"home.profile.chips.caption": "Jedes Feld ist freiwillig.",
|
||||
"home.score.label": "Der einzige Algorithmus",
|
||||
"home.score.title": "Ein Algorithmus. Sie setzen die Gewichte, und Sie können den Quellcode lesen.",
|
||||
"home.score.text": "Zu jeder Kompatibilitätsfrage geben Sie drei Dinge an: Ihre Antwort, die Antworten, die Sie von anderen akzeptieren würden, und wie wichtig Ihnen das ist. Der Wert ist genau der Vergleich dieser drei Dinge — 130 Zeilen offener Code, keine Optimierung auf Verweildauer, kein verstecktes Ranking, kein Vorteil gegen Bezahlung. Es gibt nichts zu bezahlen.",
|
||||
"home.score.mock.title": "Eine Frage, bewertet",
|
||||
"home.score.mock.you": "Ihre Antwort",
|
||||
"home.score.mock.you_value": "Vegetarisch",
|
||||
"home.score.mock.accept": "Sie würden akzeptieren",
|
||||
"home.score.mock.accept1": "Vegetarisch",
|
||||
"home.score.mock.accept2": "Vegan",
|
||||
"home.score.mock.weight": "Wie wichtig es ist",
|
||||
"home.score.mock.weight_value": "Sehr wichtig",
|
||||
"home.score.mock.result": "Übereinstimmung",
|
||||
"home.score.link": "Die Implementierung lesen →",
|
||||
"home.message.label": "Wie hier geredet wird",
|
||||
"home.message.title": "Die kürzeste Nachricht, die Sie senden können, hat 200 Zeichen.",
|
||||
"home.message.text": "Kein „Hey“. Um ein Gespräch zu beginnen, müssen Sie etwas Echtes über die Person sagen, der Sie schreiben — der Editor sendet nicht, bevor das steht, und vor der ersten Nachricht braucht es eine bestätigte E-Mail-Adresse.",
|
||||
"home.message.mock.title": "Erste Nachricht",
|
||||
"home.message.mock.hey": "„Hey“",
|
||||
"home.message.mock.hey_count": "3 / 200",
|
||||
"home.message.mock.send": "Senden",
|
||||
"home.message.mock.real": "„Ich habe gesehen, dass du dich auch für Stoizismus interessierst — wie bist du dazu gekommen?“",
|
||||
"home.message.mock.real_count": "214 / 200",
|
||||
"home.stage.label": "Wo wir stehen",
|
||||
"home.stage.title": "Compass steht am Anfang, und das sagen wir Ihnen lieber selbst.",
|
||||
"home.stage.text": "Compass hat {members} Mitglieder und wächst jeden Monat — genug für {conversations} begonnene Gespräche und nicht genug, um jemanden in Ihrer Stadt zu garantieren. Tun Sie also zwei Dinge: Speichern Sie eine Suche, und wir schreiben Ihnen an dem Tag, an dem jemand Passendes beitritt. Und wenn Sie jemanden kennen, der hierher gehört, bringen Sie ihn mit — in dieser Größe verändert eine Person, die Sie mitbringen, Ihre Chancen mehr als jede Funktion, die wir bauen könnten.",
|
||||
"home.strip.description.v2": "Kein Risikokapital, keine Werbung, keine Abonnements und niemand auf der Gehaltsliste. Compass hat seit dem Start {spent} $ gekostet; Mitglieder haben {donated} $ gespendet. Eine Person trägt die Differenz. Jede Ausgabe wird veröffentlicht.",
|
||||
"home.strip.books": "Die Bücher einsehen",
|
||||
"home.press.text": "Berichtet haben RCF, La DH, L’Avenir und Matélé",
|
||||
"home.press.link": "Zur Berichterstattung →",
|
||||
"about.title.v2": "Was Compass ist und wie es funktioniert.",
|
||||
"about.subtitle.v2": "Ein kostenloses, öffentliches Verzeichnis von Menschen, die Tiefe suchen — Freundschaften, Partnerschaften oder Mitstreiter. Von Freiwilligen gebaut, durch Spenden finanziert, von seinen Mitgliedern regiert. Das hier ist die lange Fassung: Nichts auf dieser Seite ist eine Behauptung, die Sie nicht überprüfen können.",
|
||||
"about.stat.messages": "Nachrichten",
|
||||
"about.stat.caption": "Live aus der Datenbank gelesen, nicht aus einer Pressemappe.",
|
||||
"about.how.label": "So funktioniert es",
|
||||
"about.how.title": "Vier Schritte, und Sie steuern alle vier.",
|
||||
"about.how.step1.title": "Ein Profil schreiben",
|
||||
"about.how.step1.text": "Zwanzig Minuten, nicht zwei: eine Biografie in eigenen Worten, Antworten auf Fragen, die Themen, die Ihnen wichtig sind, Ihre Politik, Religion, Ernährung, Sprachen, Ausbildung, Ihr Persönlichkeitstyp und wonach Sie suchen. Jedes Feld ist freiwillig — was Sie leer lassen, bleibt leer.",
|
||||
"about.how.step2.title": "Alle durchsuchen",
|
||||
"about.how.step2.text": "Kein Feed, keine Warteschlange, kein Tageskontingent an Menschen. Mehr als zwanzig Filter, dazu eine Freitextsuche, die den Fließtext der Profile liest — „Meditation“ findet also die Person, die darüber geschrieben hat, nicht nur die, die es als Stichwort gesetzt hat.",
|
||||
"about.how.step3.title": "Die Suche speichern",
|
||||
"about.how.step3.text": "Heute passt niemand? Speichern Sie sie, und wir schreiben Ihnen an dem Tag, an dem jemand Passendes beitritt — der Block darunter zeigt genau, wie das abläuft.",
|
||||
"about.how.step4.title": "Etwas Echtes schreiben",
|
||||
"about.how.step4.text": "Eine erste Nachricht hat mindestens 200 Zeichen und verlangt eine bestätigte E-Mail-Adresse. Kein „Hey“: Der Editor sendet nicht, bevor Sie etwas über die Person gesagt haben, der Sie schreiben.",
|
||||
"about.how.stat.write": "Min. zum Schreiben eines Profils",
|
||||
"about.how.stat.search": "Filter zum Suchen",
|
||||
"about.how.stat.save": "Tap zum Speichern einer Suche",
|
||||
"about.how.stat.message": "Zeichen mindestens pro Nachricht",
|
||||
"about.who.label": "Wer hier ist",
|
||||
"about.who.title": "Die Mitgliedschaft, öffentlich.",
|
||||
"about.who.intro": "Eine Momentaufnahme der Menschen, die gerade auf Compass sind — echte Mitglieder, echte Zahlen.",
|
||||
"about.who.intro.v2": "Die Mitglieder sind eher Single, hoch gebildet und säkular, mit mehr Vegetarier:innen und Veganer:innen als im Durchschnitt — verteilt auf mehr als fünfzig Länder, ohne einen einzelnen Schwerpunkt.",
|
||||
"about.who.intro.v3": "Die Mitglieder sind eher Single, hoch gebildet, säkular und öfter pflanzlich unterwegs als im Durchschnitt — verteilt auf mehr als fünfzig Länder, ohne einen einzelnen Schwerpunkt.",
|
||||
"about.who.link": "Alle Verteilungen, auch die, die wir hier nicht herausgegriffen haben →",
|
||||
"about.who.row.age": "Alter",
|
||||
"about.who.row.age_text": "{pct} % sind zwischen 25 und 44.",
|
||||
"about.who.bar.age": "25–44",
|
||||
"about.who.row.education": "Ausbildung",
|
||||
"about.who.row.education_text": "{pct} % haben mindestens einen Bachelorabschluss.",
|
||||
"about.who.bar.education": "Bachelor+",
|
||||
"about.who.row.gender": "Geschlecht",
|
||||
"about.who.bar.gender": "Männer",
|
||||
"about.who.row.religion": "Religion",
|
||||
"about.who.row.diet": "Ernährung",
|
||||
"about.who.row.looking": "Gesucht wird",
|
||||
"about.who.row.languages": "Sprachen",
|
||||
"about.public.label": "Was öffentlich ist",
|
||||
"about.public.eyebrow": "Ihr Profil",
|
||||
"about.public.title": "Ihr Profil ist eine Webseite. Das ist der Sinn — und es ist ein Schalter.",
|
||||
"about.public.p1": "Compass ist ein Verzeichnis, deshalb ist Ihr Profil standardmäßig eine öffentliche Seite, die jeder lesen und Suchmaschinen indexieren können. Genau das macht das Suchen überhaupt möglich: Alle sind auffindbar, also finden Sie auch alle.",
|
||||
"about.public.p2": "Wenn Sie das nicht wollen, macht ein Tippen Ihr Profil nur für Mitglieder sichtbar — abgemeldete Besucher sehen nichts, und wir sagen Suchmaschinen, dass sie es nicht indexieren sollen. Sie können es jederzeit zurückstellen.",
|
||||
"about.public.p3": "Der Rest gehört ebenfalls Ihnen: Profile ausblenden, jemanden blockieren, ihn den Moderatoren melden, alles herunterladen, was wir über Sie gespeichert haben, oder Ihr Konto samt Daten endgültig löschen. Nachrichten sind im Ruhezustand verschlüsselt. Nichts, was Sie schreiben, wird verkauft — es gibt niemanden, an den man es verkaufen könnte.",
|
||||
"about.public.mock.public": "Öffentlich",
|
||||
"about.public.mock.public_sub": "Für alle auffindbar",
|
||||
"about.public.mock.members": "Nur für Mitglieder",
|
||||
"about.public.mock.members_sub": "Versteckt, nicht indexiert",
|
||||
"about.public.mock.caption": "Ein Tap schaltet jederzeit um.",
|
||||
"about.vote.kicker": "Zwölf Stimmen sind eine geringe Beteiligung. Wir zeigen Ihnen lieber die echte als eine größere, die nicht stimmt.",
|
||||
"about.vote.constitution": "Die Verfassung →",
|
||||
"about.vote.votes": "Laufende Abstimmungen →",
|
||||
"about.vote.members": "Stimmberechtigte Mitglieder →",
|
||||
"about.who_runs.label": "Wer es betreibt",
|
||||
"about.founder.eyebrow": "Wer dahintersteht",
|
||||
"about.founder.title": "Irgendjemand muss so etwas anfangen.",
|
||||
"about.founder.p1": "Compass wurde von Martin Braquet gestartet, einem Ingenieur aus Belgien, nach Jahren in Europa, den USA, Indien und Indonesien und der Erfahrung, dass sich gerade die wichtigsten Verbindungen am schwersten absichtlich knüpfen lassen. Er betreut das Projekt weiterhin, kann aber nicht allein entscheiden: Die Verfassung legt die Richtung in die Hände der Mitglieder, und der Quellcode ist offen — nichts hier hängt von seinem Wohlwollen ab.",
|
||||
"about.founder.p2": "Alle, die Compass gebaut haben, sind Freiwillige. Niemand wurde je bezahlt.",
|
||||
"about.founder.link": "Martins Profil →",
|
||||
"about.costs.eyebrow": "Was es kostet",
|
||||
"about.costs.title": "Das gesamte Budget, in drei Zahlen.",
|
||||
"about.costs.p1": "Seit dem Start hat Compass {spent} $ gekostet — Hosting, Infrastruktur, Domains. Mitglieder haben {donated} $ gespendet. Die übrigen {deficit} $ kamen aus der Tasche des Gründers. Es gibt keine Gehälter, kein Marketingbudget und keine privaten Kosten, und jede Position wird veröffentlicht.",
|
||||
"about.costs.chart.title": "Seit dem Start",
|
||||
"about.costs.chart.donated": "Gespendet",
|
||||
"about.costs.chart.deficit": "Vom Gründer gedeckt",
|
||||
"about.costs.books": "Die Bücher einsehen →",
|
||||
"about.costs.donate": "Spenden →",
|
||||
"about.platforms.eyebrow": "Wo es läuft",
|
||||
"about.platforms.title": "Browser, Android und eine App auf dem iPhone-Homescreen.",
|
||||
"about.platforms.p1": "Die Web-App läuft in jedem Browser. Für Android gibt es eine App bei Google Play. Auf dem iPhone fügen Sie Compass aus Safari zum Homescreen hinzu, dann verhält es sich wie eine App, Benachrichtigungen inklusive — nur aus Safari, weil iOS es nirgends sonst erlaubt; eine native iOS-App wartet darauf, dass jemand sie baut. Die Oberfläche gibt es auf Englisch, Französisch und Deutsch, und es gibt eine öffentliche API.",
|
||||
"about.platforms.android": "Android-App →",
|
||||
"about.platforms.mock.browser": "Browser",
|
||||
"about.platforms.mock.browser_sub": "Jedes Gerät",
|
||||
"about.platforms.mock.android": "Android",
|
||||
"about.platforms.mock.android_sub": "Google Play",
|
||||
"about.platforms.mock.iphone": "iPhone",
|
||||
"about.platforms.mock.iphone_sub": "Zum Homescreen hinzufügen",
|
||||
"about.press.label": "Presse",
|
||||
"about.press.text": "Über Compass haben RCF, La DH, L’Avenir und Matélé berichtet.",
|
||||
"about.press.link": "Zur Berichterstattung →",
|
||||
"about.block.public_good.title": "Digitales öffentliches Gut",
|
||||
"about.block.public_good.text": "Von den Menschen gebaut, die es nutzen, zum Nutzen aller.",
|
||||
"about.donate.text.v2": "{spent} $ ausgegeben, {donated} $ gespendet, keine Gehälter. Jede Ausgabe wird veröffentlicht, bevor wir erneut fragen.",
|
||||
"press.eyebrow": "Presse & Medien",
|
||||
"press.headline": "Compass in der Presse.",
|
||||
"press.band.caption": "{stories} Beiträge · {outlets} Medien · seit {since}",
|
||||
"press.coverage.label": "Berichterstattung",
|
||||
"press.latest": "Neuester Beitrag",
|
||||
"press.medium.article": "Artikel",
|
||||
"press.medium.radio": "Radio",
|
||||
"press.medium.video": "Video",
|
||||
"press.action.article": "Artikel lesen",
|
||||
"press.action.radio": "Interview anhören",
|
||||
"press.action.video": "Beitrag ansehen",
|
||||
"press.boilerplate.eyebrow": "Kurzprofil",
|
||||
"press.boilerplate.title": "Wörtlich übernehmen.",
|
||||
"press.boilerplate.text": "Compass (compassmeet.com) ist ein kostenloses, quelloffenes Verzeichnis, um Menschen zu finden, die Ihre Werte, Interessen und Absichten teilen — Freundschaften, Partnerschaften oder Zusammenarbeit. Statt durch einen algorithmischen Feed zu wischen, lesen und durchsuchen Mitglieder jedes Profil: nach Werten, Interessen, Überzeugungen oder einem einzelnen Stichwort. Keine Werbung, keine Abos, kein Wagniskapital. Compass wird von Freiwilligen gebaut, aus Spenden finanziert und von seinen Mitgliedern nach einer öffentlichen Verfassung regiert. Gegründet wurde das Projekt in Belgien von dem Ingenieur Martin Braquet.",
|
||||
"press.boilerplate.copy": "Kurzprofil kopieren",
|
||||
"press.boilerplate.copied": "Kopiert!",
|
||||
"press.facts.title": "Kurzinfos",
|
||||
"press.facts.members": "Mitglieder",
|
||||
"press.facts.members_live": "(live)",
|
||||
"press.facts.founder": "Gründer",
|
||||
"press.facts.based": "Sitz",
|
||||
"press.facts.based_value": "Belgien",
|
||||
"press.facts.price": "Preis",
|
||||
"press.facts.price_value": "Kostenlos — keine Werbung, keine Abos",
|
||||
"press.facts.platforms": "Plattformen",
|
||||
"press.facts.platforms_value": "Web, Android, iPhone",
|
||||
"press.facts.languages": "Oberfläche",
|
||||
"press.facts.languages_value": "Englisch, Französisch, Deutsch",
|
||||
"press.facts.source": "Quellcode"
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@
|
||||
"compatibility.empty.no_results": "Aucun résultat pour \"{keyword}\"",
|
||||
"compatibility.empty.not_answered": "Toutes les questions ont été répondues !",
|
||||
"compatibility.empty.skipped": "Vous n'avez ignoré aucune question.",
|
||||
"compatibility.label": "Compatibilité",
|
||||
"compatibility.onboarding.body1": "Répondez à quelques questions courtes pour calculer la compatibilité basée sur les valeurs et les préférences — pas sur les photos ou les swipes.",
|
||||
"compatibility.onboarding.body2": "Vos réponses affectent directement qui vous correspond et avec quelle force.",
|
||||
"compatibility.onboarding.impact": "La plupart des personnes qui répondent à au moins 5 questions trouvent des correspondances beaucoup plus pertinentes.",
|
||||
@@ -559,6 +560,8 @@
|
||||
"languages.de": "Allemand",
|
||||
"languages.en": "Anglais",
|
||||
"languages.fr": "Français",
|
||||
"media.next": "Suivant",
|
||||
"media.previous": "Précédent",
|
||||
"messages.action.add_reaction": "Ajouter une réaction",
|
||||
"messages.action.delete": "Supprimer",
|
||||
"messages.action.edit": "Modifier",
|
||||
@@ -593,6 +596,11 @@
|
||||
"messages.you_prefix": "Vous : ",
|
||||
"messaging.email_verification_required": "Vous devez vérifier votre e-mail pour pouvoir envoyer des messages.",
|
||||
"messaging.send_thoughtful_message": "Envoyez-leur un message réfléchi",
|
||||
"profile.age_max_compact": "moins de {max}",
|
||||
"profile.age_min_compact": "{min}+",
|
||||
"profile.age_range_compact": "{min}-{max}",
|
||||
"profile.connect.no_shared_types": "Vous et {name} n’êtes pas ouverts au même type de relation.",
|
||||
"profile.interests": "Centres d’intérêt",
|
||||
"send_message.placeholder": "Qu'est-ce qui vous a vraiment touché dans le profil de {name} ? Qu'aimeriez-vous explorer ensemble ?",
|
||||
"send_message.title": "Commencez une conversation qui a du sens",
|
||||
"send_message.guidance": "Prenez un moment pour écrire quelque chose d'authentique.",
|
||||
@@ -879,6 +887,7 @@
|
||||
"profile.education.short_name": "Education",
|
||||
"profile.education.some-college": "Supérieur",
|
||||
"profile.ethnicity": "Origine ethnique",
|
||||
"profile.gallery.show_photo": "Afficher la photo {number}",
|
||||
"profile.gender": "Genre",
|
||||
"profile.gender.agender": "Agenre",
|
||||
"profile.gender.androgynous": "Androgyne",
|
||||
@@ -1051,6 +1060,7 @@
|
||||
"profile.language.zhuang": "Zhuang",
|
||||
"profile.language.zulu": "Zoulou",
|
||||
"profile.languages": "Langues",
|
||||
"profile.looking_for": "Recherche",
|
||||
"profile.last_online": "Actif·ve {time}",
|
||||
"profile.links": "Liens",
|
||||
"profile.llm.extract.button_text": "Extraire les données à partir du texte",
|
||||
@@ -1280,6 +1290,7 @@
|
||||
"profile.smoker.yes": "Fumeur",
|
||||
"profile.smokes": "Fume",
|
||||
"profile.smoking": "Tabagisme",
|
||||
"profile.status": "Statut",
|
||||
"profile.substance_intention.ceremonial_spiritual": "Cérémoniel / spirituel",
|
||||
"profile.substance_intention.recreational": "Récréatif",
|
||||
"profile.substance_intention.therapeutic": "Thérapeutique",
|
||||
@@ -1703,5 +1714,180 @@
|
||||
"faq.stuck.text": "Compass est construit par celles et ceux qui l’utilisent — posez n’importe quelle question, la réponse finit généralement sur cette page pour la personne suivante.",
|
||||
"faq.stuck.title": "Vous n’avez pas trouvé votre réponse ?",
|
||||
"faq.title": "Questions fréquentes",
|
||||
"faq.search.count_one": "1 question correspondante"
|
||||
"faq.search.count_one": "1 question correspondante",
|
||||
"home.eyebrow.v2": "Gratuit pour toujours · Open source · Sans swipe",
|
||||
"home.subtitle.v2": "Lis et fouille chaque profil — par valeurs, centres d’intérêt, ou démographie.",
|
||||
"home.subtitle.v2.keyword": "méditation",
|
||||
"home.subtitle.v2.end": ". C’est toi qui décides à qui écrire. Aucun algorithme ne décide à ta place.",
|
||||
"home.proof.conversations": " conversations entamées · ",
|
||||
"home.proof.messages": " messages envoyés",
|
||||
"home.who.label": "Qui est là",
|
||||
"home.who.title": "Tu peux voir exactement qui tu rejoindrais.",
|
||||
"home.who.intro": "Pas d’adjectifs vagues. Parmi les membres qui ont répondu : ",
|
||||
"home.who.age": " ont entre 25 et 44 ans, ",
|
||||
"home.who.education": " ont au moins une licence",
|
||||
"home.who.gender": ", et {pct} % sont des hommes",
|
||||
"home.who.religion": ", et les deux réponses les plus fréquentes sur la religion sont {a} et {b}",
|
||||
"home.who.mbti": "Les types de personnalité les plus courants sont {a} et {b}. ",
|
||||
"home.who.looking": "{relationship} cherchent une relation amoureuse, {friendship} une amitié et {collaboration} des collaborateurs — la plupart plusieurs à la fois.",
|
||||
"home.who.looking.v2": "La majorité cherchent une relation amoureuse, mais beaucoup cherchent aussi des amitiés.",
|
||||
"home.who.chart.title": "Ce qu’ils cherchent",
|
||||
"home.who.chart.subtitle": "Sur {base} qui ont répondu",
|
||||
"home.who.link": "Voir le détail complet →",
|
||||
"home.features.title.v2": "Trois choses qu’aucune appli de swipe ne peut dire.",
|
||||
"home.features.title.v3": "Trois choses à savoir avant de t’inscrire.",
|
||||
"home.profile.label": "Ce que tu fouilles",
|
||||
"home.profile.title": "Ici, un profil prend vingt minutes à écrire, pas deux.",
|
||||
"home.profile.text": "Pas une photo et une punchline. Une bio avec tes mots, des réponses à des questions, les causes qui te tiennent à cœur, tes convictions politiques, ta religion, ton alimentation, tes langues, tes études, ton type de personnalité, et le genre de lien que tu cherches. Plus de vingt filtres passent sur tout ça — et la recherche libre lit le texte des bios, donc « méditation » trouve la personne qui en a parlé dans sa bio, pas seulement celle qui l’a mise en mot-clé.",
|
||||
"home.profile.chips.title": "Ce qu’un profil peut contenir",
|
||||
"home.profile.chip.bio": "Bio",
|
||||
"home.profile.chip.prompts": "Questions",
|
||||
"home.profile.chip.causes": "Causes",
|
||||
"home.profile.chip.politics": "Politique",
|
||||
"home.profile.chip.religion": "Religion",
|
||||
"home.profile.chip.diet": "Alimentation",
|
||||
"home.profile.chip.languages": "Langues",
|
||||
"home.profile.chip.education": "Études",
|
||||
"home.profile.chip.personality": "Personnalité",
|
||||
"home.profile.chip.looking": "Ce que tu cherches",
|
||||
"home.profile.chips.caption": "Chaque champ est facultatif.",
|
||||
"home.score.label": "Le seul algorithme",
|
||||
"home.score.title": "Un seul algorithme. Tu en règles les poids, et tu peux en lire le code.",
|
||||
"home.score.text": "Pour chaque question de compatibilité, tu donnes trois choses : ta réponse, les réponses que tu accepterais d’une autre personne, et l’importance que ça a pour toi. Le score, c’est la comparaison de ces trois éléments — 130 lignes de code ouvert, aucune optimisation de l’engagement, aucun classement caché, aucun coup de pouce payant. Il n’y a rien à payer.",
|
||||
"home.score.mock.title": "Une question, notée",
|
||||
"home.score.mock.you": "Ta réponse",
|
||||
"home.score.mock.you_value": "Végétarien·ne",
|
||||
"home.score.mock.accept": "Tu accepterais",
|
||||
"home.score.mock.accept1": "Végétarien·ne",
|
||||
"home.score.mock.accept2": "Végane",
|
||||
"home.score.mock.weight": "À quel point ça compte",
|
||||
"home.score.mock.weight_value": "Très important",
|
||||
"home.score.mock.result": "Compatibilité",
|
||||
"home.score.link": "Lire l’implémentation →",
|
||||
"home.message.label": "Comment on se parle ici",
|
||||
"home.message.title": "Le message le plus court possible fait 200 caractères.",
|
||||
"home.message.text": "Pas de « salut ». Pour ouvrir une conversation, il faut dire quelque chose de vrai sur la personne à qui tu écris — l’éditeur n’envoie rien tant que ce n’est pas fait, et il faut une adresse e-mail vérifiée avant le premier message.",
|
||||
"home.message.mock.title": "Premier message",
|
||||
"home.message.mock.hey": "« Salut »",
|
||||
"home.message.mock.hey_count": "3 / 200",
|
||||
"home.message.mock.send": "Envoyer",
|
||||
"home.message.mock.real": "« J’ai vu que tu t’intéresses aussi au stoïcisme — qu’est-ce qui t’y a amené·e ? »",
|
||||
"home.message.mock.real_count": "214 / 200",
|
||||
"home.stage.label": "Où nous en sommes",
|
||||
"home.stage.title": "Compass en est à ses débuts, et on préfère te le dire nous-mêmes.",
|
||||
"home.stage.text": "Compass compte {members} membres et grandit chaque mois, ce qui suffit à avoir lancé {conversations} conversations et ne suffit pas à garantir quelqu’un dans ta ville. Alors fais deux choses : enregistre une recherche, et on t’écrira le jour où quelqu’un qui correspond s’inscrit. Et si tu connais une personne qui a sa place ici, amène-la — à cette taille, une personne que tu présentes change davantage tes chances que n’importe quelle fonctionnalité qu’on pourrait développer.",
|
||||
"home.strip.description.v2": "Pas de capital-risque, pas de pub, pas d’abonnement, et personne n’est salarié. Compass a coûté {spent} $ depuis son lancement ; les membres ont donné {donated} $. Une seule personne comble la différence. Chaque dépense est publiée.",
|
||||
"home.strip.books": "Voir les comptes",
|
||||
"home.press.text": "Couvert par RCF, La DH, L’Avenir et Matélé",
|
||||
"home.press.link": "Lire les articles →",
|
||||
"about.title.v2": "Ce qu’est Compass, et comment ça marche.",
|
||||
"about.subtitle.v2": "Un annuaire public et gratuit de personnes qui cherchent de la profondeur — des amis, des partenaires ou des collaborateurs. Construit par des bénévoles, financé par des dons, gouverné par ses membres. Voici la version longue : rien sur cette page n’est une affirmation invérifiable.",
|
||||
"about.stat.messages": "Messages",
|
||||
"about.stat.caption": "Lu en direct dans la base de données, pas dans un dossier de presse.",
|
||||
"about.how.label": "Comment ça marche",
|
||||
"about.how.title": "Quatre étapes, et tu contrôles les quatre.",
|
||||
"about.how.step1.title": "Écris un profil",
|
||||
"about.how.step1.text": "Vingt minutes, pas deux : une bio avec tes mots, des réponses à des questions, les causes qui comptent pour toi, tes convictions politiques, ta religion, ton alimentation, tes langues, tes études, ton type de personnalité, et ce que tu cherches. Chaque champ est facultatif — ce que tu laisses vide reste vide.",
|
||||
"about.how.step2.title": "Fouille tout l’annuaire",
|
||||
"about.how.step2.text": "Pas de fil, pas de file d’attente, pas de quota quotidien de personnes. Plus de vingt filtres, et une recherche libre qui lit le texte des bios — donc « méditation » trouve la personne qui en a parlé, pas seulement celle qui l’a mise en mot-clé.",
|
||||
"about.how.step3.title": "Enregistre la recherche",
|
||||
"about.how.step3.text": "Personne ne correspond aujourd’hui ? Enregistre-la, et on t’écrira le jour où quelqu’un correspond — le bloc ci-dessous montre exactement comment ça fonctionne.",
|
||||
"about.how.step4.title": "Écris quelque chose de vrai",
|
||||
"about.how.step4.text": "Un premier message fait 200 caractères minimum et demande une adresse e-mail vérifiée. Pas de « salut » : l’éditeur n’envoie rien tant que tu n’as pas dit quelque chose sur la personne à qui tu écris.",
|
||||
"about.how.stat.write": "min pour écrire un profil",
|
||||
"about.how.stat.search": "filtres pour chercher",
|
||||
"about.how.stat.save": "tap pour sauvegarder une recherche",
|
||||
"about.how.stat.message": "caractères minimum pour écrire",
|
||||
"about.who.label": "Qui est là",
|
||||
"about.who.title": "Les membres, en public.",
|
||||
"about.who.intro": "Un aperçu des personnes présentes sur Compass en ce moment — de vrais membres, de vrais chiffres.",
|
||||
"about.who.intro.v2": "Les membres sont plutôt célibataires, très diplômés et non religieux, avec plus de végétariens et de végans que la moyenne — et réparti·es dans plus de cinquante pays, sans centre unique.",
|
||||
"about.who.intro.v3": "Les membres sont plutôt célibataires, très diplômés, non religieux, et plus végé que la moyenne — réparti·es dans plus de cinquante pays, sans centre unique.",
|
||||
"about.who.link": "Toutes les distributions, y compris celles qu’on n’a pas retenues ici →",
|
||||
"about.who.row.age": "Âge",
|
||||
"about.who.row.age_text": "{pct} % ont entre 25 et 44 ans.",
|
||||
"about.who.bar.age": "25–44",
|
||||
"about.who.row.education": "Études",
|
||||
"about.who.row.education_text": "{pct} % ont au moins une licence.",
|
||||
"about.who.bar.education": "Licence ou plus",
|
||||
"about.who.row.gender": "Genre",
|
||||
"about.who.bar.gender": "Hommes",
|
||||
"about.who.row.religion": "Religion",
|
||||
"about.who.row.diet": "Alimentation",
|
||||
"about.who.row.looking": "Recherche",
|
||||
"about.who.row.languages": "Langues",
|
||||
"about.public.label": "Ce qui est public",
|
||||
"about.public.eyebrow": "Ton profil",
|
||||
"about.public.title": "Ton profil est une page web. C’est tout l’intérêt — et c’est un interrupteur.",
|
||||
"about.public.p1": "Compass est un annuaire : par défaut, ton profil est donc une page publique que tout le monde peut lire et que les moteurs de recherche peuvent indexer. C’est ce qui rend la recherche possible : tout le monde peut être trouvé, donc tu peux trouver tout le monde.",
|
||||
"about.public.p2": "Si ce n’est pas ce que tu veux, un seul geste rend ton profil visible uniquement par les membres — les visiteurs non connectés ne voient rien, et on demande aux moteurs de recherche de ne pas l’indexer. Tu peux revenir en arrière quand tu veux.",
|
||||
"about.public.p3": "Le reste t’appartient aussi : masquer des profils, bloquer quelqu’un, le signaler aux modérateurs, télécharger tout ce qu’on garde sur toi, ou supprimer définitivement ton compte et ses données. Les messages sont chiffrés au repos. Rien de ce que tu écris n’est vendu — il n’y a personne à qui le vendre.",
|
||||
"about.public.mock.public": "Public",
|
||||
"about.public.mock.public_sub": "Trouvable par tout le monde",
|
||||
"about.public.mock.members": "Membres uniquement",
|
||||
"about.public.mock.members_sub": "Caché, non indexé",
|
||||
"about.public.mock.caption": "Un tap suffit pour basculer, à tout moment.",
|
||||
"about.vote.kicker": "Douze votants, c’est une faible participation. On préfère te montrer la vraie plutôt qu’un chiffre plus flatteur qui serait faux.",
|
||||
"about.vote.constitution": "La constitution →",
|
||||
"about.vote.votes": "Votes en cours →",
|
||||
"about.vote.members": "Membres votants →",
|
||||
"about.who_runs.label": "Qui s’en occupe",
|
||||
"about.founder.eyebrow": "Qui est derrière",
|
||||
"about.founder.title": "Il faut bien que quelqu’un commence.",
|
||||
"about.founder.p1": "Compass a été lancé par Martin Braquet, ingénieur belge, après des années passées entre l’Europe, les États-Unis, l’Inde et l’Indonésie, et le constat que les liens qui comptent le plus sont les plus difficiles à créer volontairement. Il reste l’intendant du projet, mais il ne peut pas décider seul : la constitution place la direction entre les mains des membres, et le code est public, donc rien ici ne dépend de sa bonne volonté.",
|
||||
"about.founder.p2": "Toutes les personnes qui ont construit Compass sont bénévoles. Personne n’a jamais été payé.",
|
||||
"about.founder.link": "Le profil de Martin →",
|
||||
"about.costs.eyebrow": "Ce que ça coûte",
|
||||
"about.costs.title": "Tout le budget, en trois chiffres.",
|
||||
"about.costs.p1": "Depuis son lancement, Compass a coûté {spent} $ — hébergement, infrastructure, noms de domaine. Les membres ont donné {donated} $. Les {deficit} $ restants sont sortis de la poche du fondateur. Il n’y a ni salaires, ni budget marketing, ni dépenses privées, et chaque ligne est publiée.",
|
||||
"about.costs.chart.title": "Depuis le lancement",
|
||||
"about.costs.chart.donated": "Donné",
|
||||
"about.costs.chart.deficit": "Couvert par le fondateur",
|
||||
"about.costs.books": "Voir les comptes →",
|
||||
"about.costs.donate": "Faire un don →",
|
||||
"about.platforms.eyebrow": "Où ça tourne",
|
||||
"about.platforms.title": "Navigateur, Android, et une app sur l’écran d’accueil de l’iPhone.",
|
||||
"about.platforms.p1": "L’application web fonctionne dans n’importe quel navigateur. Il existe une application Android sur Google Play. Sur iPhone, ajoute Compass à ton écran d’accueil depuis Safari et elle se comporte comme une app, notifications comprises — depuis Safari uniquement, car iOS ne l’autorise nulle part ailleurs ; une app iOS native attend que quelqu’un la développe. L’interface est en anglais, français et allemand, et il existe une API publique.",
|
||||
"about.platforms.android": "Application Android →",
|
||||
"about.platforms.mock.browser": "Navigateur",
|
||||
"about.platforms.mock.browser_sub": "Tout appareil",
|
||||
"about.platforms.mock.android": "Android",
|
||||
"about.platforms.mock.android_sub": "Google Play",
|
||||
"about.platforms.mock.iphone": "iPhone",
|
||||
"about.platforms.mock.iphone_sub": "Ajouter à l’écran d’accueil",
|
||||
"about.press.label": "Presse",
|
||||
"about.press.text": "Compass a été couvert par RCF, La DH, L’Avenir et Matélé.",
|
||||
"about.press.link": "Lire les articles →",
|
||||
"about.block.public_good.title": "Bien public numérique",
|
||||
"about.block.public_good.text": "Construit par celles et ceux qui l’utilisent, pour le bénéfice de tous.",
|
||||
"about.donate.text.v2": "{spent} $ dépensés, {donated} $ reçus, aucun salaire. Chaque dépense est publiée avant qu’on redemande.",
|
||||
"press.eyebrow": "Presse & médias",
|
||||
"press.headline": "Compass dans la presse.",
|
||||
"press.band.caption": "{stories} parutions · {outlets} médias · depuis {since}",
|
||||
"press.coverage.label": "Couverture médiatique",
|
||||
"press.latest": "Dernière parution",
|
||||
"press.medium.article": "Article",
|
||||
"press.medium.radio": "Radio",
|
||||
"press.medium.video": "Vidéo",
|
||||
"press.action.article": "Lire l’article",
|
||||
"press.action.radio": "Écouter l’interview",
|
||||
"press.action.video": "Voir le reportage",
|
||||
"press.boilerplate.eyebrow": "Texte de présentation",
|
||||
"press.boilerplate.title": "À reprendre tel quel.",
|
||||
"press.boilerplate.text": "Compass (compassmeet.com) est un annuaire gratuit et open source pour rencontrer des personnes qui partagent tes valeurs, tes centres d’intérêt et tes intentions — amis, partenaires ou collaborateurs. Plutôt que de swiper dans un fil algorithmique, les membres lisent et fouillent tous les profils : par valeurs, centres d’intérêt, convictions, ou un simple mot-clé. Ni publicité, ni abonnement, ni capital-risque. Compass est développé par des bénévoles, financé par des dons, et gouverné par ses membres selon une constitution publique. Le projet a été lancé en Belgique par l’ingénieur Martin Braquet.",
|
||||
"press.boilerplate.copy": "Copier le texte",
|
||||
"press.boilerplate.copied": "Copié !",
|
||||
"press.facts.title": "En bref",
|
||||
"press.facts.members": "Membres",
|
||||
"press.facts.members_live": "(en direct)",
|
||||
"press.facts.founder": "Fondateur",
|
||||
"press.facts.based": "Basé en",
|
||||
"press.facts.based_value": "Belgique",
|
||||
"press.facts.price": "Prix",
|
||||
"press.facts.price_value": "Gratuit — sans publicité ni abonnement",
|
||||
"press.facts.platforms": "Plateformes",
|
||||
"press.facts.platforms_value": "Web, Android, iPhone",
|
||||
"press.facts.languages": "Interface",
|
||||
"press.facts.languages_value": "Anglais, français, allemand",
|
||||
"press.facts.source": "Code source"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,26 @@ export const formLink = 'https://forms.gle/tKnXUMAbEreMK6FC6'
|
||||
export const ANDROID_APP_URL =
|
||||
'https://play.google.com/store/apps/details?id=com.compassconnections.app'
|
||||
|
||||
/**
|
||||
* What Compass has cost and what it has been given, in USD, since launch.
|
||||
*
|
||||
* Lives here rather than in either page because the home strip and the about page both state it, and two
|
||||
* copies of a number that is the whole point of the transparency argument would be the one place it must
|
||||
* not drift. The authoritative breakdown is `web/public/md/financials.md` and the spreadsheet it links;
|
||||
* update all of them together.
|
||||
*
|
||||
* Not queried live: unlike the member count there is no endpoint behind it, and inventing one to avoid a
|
||||
* constant would put a bookkeeping figure on a page-load path.
|
||||
*/
|
||||
export const FINANCIALS = {
|
||||
spent: 457,
|
||||
donated: 145,
|
||||
/** Covered out of pocket by the founder — kept derived so it can never disagree with the two above. */
|
||||
get deficit() {
|
||||
return this.spent - this.donated
|
||||
},
|
||||
}
|
||||
|
||||
export const IS_MAINTENANCE = false // set to true to enable the maintenance mode banner
|
||||
|
||||
export const MIN_BIO_LENGTH = 250
|
||||
|
||||
527
docs/marketing-copy.md
Normal file
527
docs/marketing-copy.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# Marketing copy — home & about
|
||||
|
||||
Replacement copy for the logged-out home page (`web/components/home/home.tsx`) and the about page
|
||||
(`web/pages/about.tsx`). Companion to [marketing-visuals.md](marketing-visuals.md), which covers what those
|
||||
pages _show_; this one covers what they _say_.
|
||||
|
||||
**Status: draft.** Nothing here is in the app yet.
|
||||
|
||||
## Why this document exists
|
||||
|
||||
Both pages sell a mechanism ("search, don't swipe") and a politics ("free, open, democratic"). Neither says
|
||||
who is actually on Compass, what a profile contains, how the compatibility score works, what happens when
|
||||
you write to someone, or how early the project is — even though all five are shipped, measurable, and more
|
||||
persuasive than anything currently on the page. Three specific problems drove the rewrite:
|
||||
|
||||
1. **"No matching algorithms" is false.** `common/src/profiles/compatibility-score.ts` is 133 lines of
|
||||
scoring. The truthful version is also the stronger one: every competitor claims a great algorithm, none
|
||||
can say _you set its weights and can read its source_. The onboarding already says this correctly
|
||||
(`web/pages/onboarding/index.tsx:234`); the marketing page contradicts it.
|
||||
2. **Nobody says who is here.** The population is the sharpest differentiator Compass has — secular, highly
|
||||
educated, globally scattered, EA/rationalist-adjacent, a third vegetarian — and it is described nowhere.
|
||||
"Find people who share your values, ideas, and intentions" describes every social product ever built.
|
||||
3. **The logged-out page is less honest than the logged-in one.** Members see "Compass is in its early
|
||||
growth phase — 700+ members and ~100 new people joining every month" (`profiles-home.tsx:337`).
|
||||
Visitors see a finished product. The gap between what the page implies and what a new member finds in
|
||||
their city is the biggest churn driver available, and it is fixable in copy alone.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Never hardcode a measured number.** `StatBand` and `MemberGrowth` both query live and render _nothing_
|
||||
on an empty response rather than a zero or a skeleton. Sections 1, 4 and 5 below carry numbers and must
|
||||
follow the same rule — `stats` already returns everything needed (`common/src/stats.ts`), so the
|
||||
percentages are arithmetic on `stats.demographics`, not a new endpoint.
|
||||
- **State the denominator.** Most demographic fields are optional, so every percentage below is of _people
|
||||
who answered_, not of the membership. Saying so is not a hedge to bury; it is the one sentence no
|
||||
competitor can write, on the page arguing for transparency.
|
||||
- **New keys for reworded strings.** `useT` resolves `common/messages/{fr,de}.json` _ahead of_ the English
|
||||
fallback (`web/lib/locale.ts:21-41`). Changing the English under an existing key leaves French and German
|
||||
silently showing the old meaning. Roughly 20 new keys × 2 locales.
|
||||
- **Never fabricate social proof.** Same rule as H3 in marketing-visuals.md.
|
||||
- **Say the size once per page, and never in the hero.** 726 is a small number next to what a visitor is
|
||||
reflexively comparing it to, and repeating it makes headcount the dominant fact about Compass. Three
|
||||
kinds of number, three different jobs:
|
||||
- _Size_ (members, countries) invites the losing comparison. Once per page, late, where it does work.
|
||||
- _Activity_ (378 conversations, 2,283 messages) answers the real fear — "is this dead?" — without
|
||||
stating how small the pool is. Use freely.
|
||||
- _Composition_ (70% with a degree, two-thirds atheist or agnostic, INTJ and INFJ most common) describes
|
||||
a club rather than a queue, and reads identically at 726 members or 7,000. This is where the
|
||||
persuasion belongs.
|
||||
- **Country count is not a reach stat.** Fifty-five countries across 726 people is dispersion — the number
|
||||
that quietly says "nobody near you". It should not headline anything.
|
||||
|
||||
## Which page gets what
|
||||
|
||||
**Home answers "why would I bother?" in thirty seconds. About answers "can I trust this, and how does it
|
||||
actually work?" in five minutes.** Two rules fall out of that, and they settle almost every allocation
|
||||
question below:
|
||||
|
||||
- Anything that needs a denominator, a caveat, or a link to a source document belongs on **about**. A
|
||||
stranger will not read "of the 394 members who answered"; someone deciding whether to publish a profile
|
||||
about themselves will, and will trust the page more for it.
|
||||
- **The same claim must never appear on both pages at the same depth.** Today "free, open, community-owned"
|
||||
is stated three times on each page in nearly identical words, which is most of why both read as thin.
|
||||
Home states it once; about _proves_ it, with the books, the constitution and a real ballot.
|
||||
|
||||
| Content | Home | About | Note |
|
||||
| -------------------------------------- | ------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| What Compass is | one line, a pitch | one line, factual | Different registers on purpose |
|
||||
| Who's here | 3 lines, headline % | full, with bases | The qualifier on home; the evidence on about |
|
||||
| Search demo clip | owns it | — | It is the hero's proof; a second copy would compete with itself |
|
||||
| What's in a profile | short list | full field list | |
|
||||
| Compatibility score | headline claim | the mechanism | Home: "you set the weights". About: the three inputs and the source link |
|
||||
| Saved searches / email alerts | one clause | owns it | The phone clip and the three steps already live on about |
|
||||
| 200-character first message | headline claim | with the email gate and where it's enforced | |
|
||||
| Profile visibility, public vs. members | one reassuring line | owns it | Too heavy for home; the main objection, so it needs a real answer somewhere |
|
||||
| Safety, blocking, data controls | — | owns it | Nobody signs up _because_ of it; plenty decline for lack of it |
|
||||
| Early-stage honesty | owns it | short version | It has to be pre-signup or it isn't honesty |
|
||||
| Free, no ads, no VC | one strip | — | Stated on home, proven below |
|
||||
| The books ($457 / $145) | one line in strip | owns it | |
|
||||
| Governance, constitution, real votes | — | owns it | `VoteEvidence` is already there and is the right home for it |
|
||||
| Who's behind it | — | owns it | Currently only in the FAQ; neither page has a human on it |
|
||||
| Where it runs (Android, PWA, langs) | — | owns it | Nobody joins for the platform list, but they look for it before trusting |
|
||||
| Press | one line | full list | |
|
||||
| Linux / Wikipedia / Firefox | remove | owns it | A vision claim a stranger can't verify; it belongs on the reference page |
|
||||
| Events | — | — | `upcomingEvents` is 0. Neither page, until there is supply |
|
||||
| Ways to help, donate, share | join CTA only | owns it | Already correct today |
|
||||
|
||||
---
|
||||
|
||||
# Home page
|
||||
|
||||
## Hero
|
||||
|
||||
**Badge** — `home.eyebrow`
|
||||
|
||||
| | |
|
||||
| --- | --------------------------------------------------- |
|
||||
| Now | Free forever · Open source · No matching algorithms |
|
||||
| New | **Free forever · Open source · No swiping** |
|
||||
|
||||
The third claim was the false one. "No swiping" is true, keeps the rhythm, and is what the headline already
|
||||
says.
|
||||
|
||||
**Headline** — unchanged. `Don't Swipe.` + typewriter `Search.` / `Connect.` The demo clip beside it proves
|
||||
the claim; leave it alone.
|
||||
|
||||
**Subtitle** — `home.subtitle.*`
|
||||
|
||||
> Read and search every profile — by values, interests, politics, or a single word like _meditation_. You
|
||||
> decide who's worth writing to. No algorithm decides for you.
|
||||
|
||||
**Social proof** — `home.proof.*`, live
|
||||
|
||||
> 378 conversations started · 2,283 messages sent
|
||||
|
||||
Deliberately activity rather than headcount, and deliberately no country count. The hero is where a visitor
|
||||
reflexively compares Compass to apps with millions of users, and it is the worst possible place to hand them
|
||||
the number that loses that comparison. Conversations answer the question they are actually asking at this
|
||||
point in the page — is anyone here, does anyone reply — and they will meet the membership figure once,
|
||||
lower down, where it comes with context.
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — Who's here
|
||||
|
||||
New section. The single biggest gap on the page.
|
||||
|
||||
**Label:** Who's here
|
||||
**Heading:** You can see exactly who you'd be joining.
|
||||
|
||||
> No generic adjectives. Most members are
|
||||
> between 25 and 45. Among those who filled the field: 70% hold a bachelor's degree or higher, two-thirds
|
||||
> are atheist or agnostic, and 44% are vegetarian or vegan. 70% are looking for a relationship, 50% for friendship, 40%
|
||||
> for collaborators — and most for
|
||||
> more than one of those.
|
||||
|
||||
**Link:** See the full breakdown → `/stats`
|
||||
|
||||
Composition, not headcount: this section describes a club, and it reads the same at 726 members as at 7,000.
|
||||
**Check the percentages before shipping** — 373 / 341 / 263 against the 477 who answered is 78 / 71 / 55%,
|
||||
and against the whole membership 51 / 47 / 36%; the draft's 70 / 50 / 40 matches neither base.
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — What a profile contains
|
||||
|
||||
**Label:** What you're searching
|
||||
**Heading:** A profile here takes twenty minutes to write, not two.
|
||||
|
||||
> Not a photo and a one-liner. A bio in your own words, answers to prompts, the causes you care about, your
|
||||
> politics, religion, diet, languages, education, personality type, and what kind of connection you're
|
||||
> after. More than twenty filters run across all of it — and free-text search reads the prose, so
|
||||
> "meditation" finds the person who wrote about it in their bio, not just the one who tagged it.
|
||||
|
||||
The bio-vs-tag detail is real and worth keeping: it is exactly why the hero clip uses that query (two
|
||||
results, one matching on a visible chip and one only through prose — see marketing-visuals.md, H1).
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Compatibility
|
||||
|
||||
Replaces the "Radically Transparent / No algorithms" feature card.
|
||||
|
||||
**Label:** The only algorithm
|
||||
**Heading:** One algorithm. You set the weights, and you can read the source.
|
||||
|
||||
> For each compatibility question you give three things: your answer, the answers you'd accept from someone
|
||||
> else, and how much it matters to you. The score is those three things compared — 130 lines of open code,
|
||||
> no engagement optimization, no hidden ranking, no boost for paying. There is no paying.
|
||||
|
||||
**Link:** Read the implementation → `common/src/profiles/compatibility-score.ts` on GitHub
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Messaging
|
||||
|
||||
**Label:** How people talk here
|
||||
**Heading:** The shortest message you can send is 200 characters.
|
||||
|
||||
> No "hey". To open a conversation you have to say something real about the person you're writing to — the
|
||||
> composer won't send until you do, and you need a verified email before your first message. So far: 2,283
|
||||
> messages across 378 conversations. About six messages each, which means people are answering.
|
||||
|
||||
The 200-character minimum is `MIN_CHARS` in `web/components/messaging/send-message-button.tsx:173`; the
|
||||
email gate is enforced server-side in `backend/api/src/create-private-user-message-channel.ts`, not just in
|
||||
the UI. This is the most concrete anti-swipe-app fact in the codebase and it currently appears on no
|
||||
marketing page.
|
||||
|
||||
---
|
||||
|
||||
## Section 5 — Where it stands
|
||||
|
||||
Replaces the Linux / Wikipedia / Firefox quote block, which moves to `/about` under "Why we exist" — it is a
|
||||
vision claim, and about is the reference page.
|
||||
|
||||
**Label:** Where we are
|
||||
**Heading:** Compass is early, and we'd rather you hear it from us.
|
||||
|
||||
> Compass has 726 members and is growing every month, which is enough to have started 378 conversations and
|
||||
> not enough to guarantee somebody in your town. So do two things: save a search, and we'll email you the
|
||||
> day someone who fits joins. And if you know someone who belongs here, bring them — at this size, one
|
||||
> person you introduce changes your odds more than any feature we could ship.
|
||||
|
||||
**This is the only place on the home page that states the membership**, and it earns it: the number arrives
|
||||
attached to what the reader should do about it, rather than as a boast that invites a comparison. An earlier
|
||||
draft named the largest national group (88 members) and the country count here as well. Both are cut — that
|
||||
is candour spent on nothing, and it hands a hesitant reader the two most discouraging figures available.
|
||||
"Not enough to guarantee somebody in your town" sets the same expectation without quantifying the
|
||||
disappointment.
|
||||
|
||||
**Verify the growth claim before it ships.** The draft says "growing every month" rather than the in-app
|
||||
"~100 new people joining every month" (`profiles-home.tsx:337`) because the two data points available
|
||||
disagree with it: the press page reports "just over 400 users" in January 2026, and prod reported 726 in
|
||||
July 2026 — about 54 a month. Check the member-growth series and use the real figure, which is a good number
|
||||
and does not need inflating.
|
||||
|
||||
---
|
||||
|
||||
## Section 6 — Open source strip
|
||||
|
||||
Title unchanged: _Open Source & Free Forever_.
|
||||
|
||||
**Description** — `home.strip.description`
|
||||
|
||||
| | |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Now | No venture capital. No ads. No subscription fees. Built transparently by the community, for the community. |
|
||||
| New | **No venture capital, no ads, no subscriptions, and nobody on payroll. Compass has cost $457 to run since launch; members have donated $145. One person covers the difference. Every expense is published.** |
|
||||
|
||||
**Badges:** GitHub · Discord · **See the books →** (`/financials`) · Join Now →
|
||||
|
||||
"Every contribution keeps the lights on" is a charity cliché. The deficit is the same ask, is true, is
|
||||
verifiable one click away, and is the only version consistent with a transparency pitch.
|
||||
|
||||
---
|
||||
|
||||
## Section 7 — Press
|
||||
|
||||
One line above the footer. Five items sit in `web/pages/press.tsx` — including an RCF radio interview — and
|
||||
neither marketing page links to any of them.
|
||||
|
||||
> Covered by RCF, La DH, L'Avenir and Matélé → `/press`
|
||||
|
||||
---
|
||||
|
||||
# About page
|
||||
|
||||
The page's job, per marketing-visuals.md, is to be a reference doc rather than a pitch — it needs evidence,
|
||||
not a hero. The copy below takes that seriously: every section is either a mechanism the reader can try or a
|
||||
claim they can check, and the page says so up front.
|
||||
|
||||
Order of sections, with the existing blocks kept where they already work:
|
||||
|
||||
1. Header · 2. `StatBand` (existing) · 3. How it works, with `NotifySpotlight` as step 3 (existing) · 4.
|
||||
Who's here · 5. What's public and what's yours · 6. Why we exist, with `MissionStatement` (existing) · 7.
|
||||
How a decision gets made, with `VoteEvidence` + `RepoActivity` (existing) · 8. Who's behind it · 9. What
|
||||
it costs · 10. Where it runs · 11. Press · 12. Help Compass grow (existing)
|
||||
|
||||
## Header
|
||||
|
||||
**Eyebrow** — `about.eyebrow`, unchanged: _About Compass_
|
||||
|
||||
**Headline** — `about.title`
|
||||
|
||||
| | |
|
||||
| --- | -------------------------------------- |
|
||||
| Now | Why Choose Compass? |
|
||||
| New | **What Compass is, and how it works.** |
|
||||
|
||||
"Why choose" is a shopping-comparison frame on a page whose job is evidence, and it argues Compass is better
|
||||
before saying what it is — which is exactly the reader arriving cold from a press article.
|
||||
|
||||
**Subtitle** — `about.subtitle`
|
||||
|
||||
> A free, public directory of people looking for depth — friends, partners, or collaborators. Built by
|
||||
> volunteers, funded by donations, governed by its members. This is the long version: nothing on this page
|
||||
> is a claim you can't check.
|
||||
|
||||
That last clause is the page's contract with the reader, and everything below has to keep it.
|
||||
|
||||
## Stat band
|
||||
|
||||
The existing `StatBand` shows Members · Countries · Conversations · $0. **Drop Countries and keep three
|
||||
stats.** Fifty-five countries across 726 people is dispersion, not reach — it is the figure that says
|
||||
"nobody near you" while presenting itself as an achievement, and it is the second thing on the page. The
|
||||
component already hides it conditionally (`countryCount > 1`), so removing it is a small change.
|
||||
|
||||
Replace it with new members this month if the growth series supports the claim, or with messages sent, which
|
||||
sits naturally beside conversations. Either way this is the one place on either page that a country
|
||||
breakdown belongs — `/stats`, where someone came to read numbers.
|
||||
|
||||
**Caption, one line under the band:**
|
||||
|
||||
> Read live from the database, not from a press kit.
|
||||
|
||||
An earlier draft added a second sentence here calling out how thin the membership is spread. Cut: it is the
|
||||
page's second element, the reader has not yet been given a single reason to want in, and self-criticism
|
||||
before value is just discouragement. The same point lands properly in "Where it stands" on home, attached to
|
||||
an action.
|
||||
|
||||
## Section A — How it works
|
||||
|
||||
New. The about page currently jumps from the stat band into saved-search alerts without ever explaining the
|
||||
basic loop, so a reader who has not used the product is being sold step three of four.
|
||||
|
||||
**Label:** How it works
|
||||
**Heading:** Four steps, and you control all four.
|
||||
|
||||
1. **Write a profile.** Twenty minutes, not two: a bio in your own words, prompt answers, the causes you
|
||||
care about, your politics, religion, diet, languages, education, personality type, and what you're
|
||||
looking for. Every field is optional — what you leave blank stays blank.
|
||||
2. **Search everyone.** No feed, no queue, no daily allowance of people. More than twenty filters, plus
|
||||
free-text search that reads the prose in people's bios — so "meditation" finds the person who wrote about
|
||||
it, not only the one who tagged it.
|
||||
3. **Save the search.** _(the existing `NotifySpotlight` block owns this step — keep its copy and its phone
|
||||
clip)_
|
||||
4. **Write something real.** A first message is 200 characters minimum and needs a verified email address.
|
||||
No "hey": the composer will not send until you've said something about the person you're writing to.
|
||||
|
||||
## Section B — Who's here
|
||||
|
||||
The full version of the home page's Section 1. Home qualifies the reader in three lines; this one shows the
|
||||
work.
|
||||
|
||||
**Label:** Who's here
|
||||
**Heading:** The membership, in public.
|
||||
|
||||
> Most profile fields are optional, so each figure below is a share of the people who answered that
|
||||
> question, not of the membership — the number who answered is in brackets.
|
||||
>
|
||||
> - **Age** (481) — 63% are 25–44, 21% are 18–24, 16% are 45 or older.
|
||||
> - **Education** (394) — 70% hold a bachelor's degree or higher, including 35 doctorates.
|
||||
> - **Religion** (304) — two-thirds atheist or agnostic; after those, the most common answers are
|
||||
> "spiritual" and Christian.
|
||||
> - **Diet** (251) — 44% vegetarian or vegan.
|
||||
> - **Personality** (122) — INTJ and INFJ are the two most common types.
|
||||
> - **Looking for** (477) — 373 a relationship, 341 friendship, 263 collaborators; most people chose more
|
||||
> than one.
|
||||
> - **Languages** (725) — 670 speak English, 103 French, 37 Spanish, 31 German.
|
||||
>
|
||||
> Every distribution, including the ones we haven't picked out here → `/stats`
|
||||
|
||||
The closing line matters: it pre-empts the obvious objection that these seven were cherry-picked.
|
||||
|
||||
No total here — the stat band states it once, four sections up, and that is the about page's single
|
||||
allowance. Everything in this list is composition, which is the part that persuades and the part that does
|
||||
not get weaker as the number gets smaller.
|
||||
|
||||
## Section C — What's public, and what's yours
|
||||
|
||||
New. The main objection to a "radically transparent directory" and currently unanswered on either page,
|
||||
although the product answers it well.
|
||||
|
||||
**Label:** What's public
|
||||
**Heading:** Your profile is a web page. That's the point — and it's a switch.
|
||||
|
||||
> Compass is a directory, so by default your profile is a public page that anyone can read and search
|
||||
> engines can index. That is the deal that makes searching work at all: everyone can be found, so you can
|
||||
> find anyone.
|
||||
>
|
||||
> If that isn't what you want, one tap makes your profile members-only — logged-out visitors see nothing,
|
||||
> and we tell search engines not to index it. You can switch back whenever you like.
|
||||
>
|
||||
> The rest is yours too: hide profiles you'd rather not see, block someone outright, report them to
|
||||
> moderators, download everything we hold about you, or delete your account and its data for good. Messages
|
||||
> are encrypted at rest. Nothing you write is sold — there is nobody to sell it to.
|
||||
|
||||
## Section D — Why we exist
|
||||
|
||||
Keep the existing `MissionStatement` block ("One Mission") unchanged. Bring back the vision line underneath
|
||||
it — it is already written and translated, sitting commented out at `web/pages/about.tsx:679-683` under
|
||||
`about.block.vision.*`:
|
||||
|
||||
> Compass is to human connection what Linux is to software, Wikipedia to knowledge, and Firefox to browsing
|
||||
> — a public digital good designed to serve people, not profit.
|
||||
|
||||
This is the block moved off the home page. It reads as self-congratulation in front of a stranger who has
|
||||
not decided anything yet; it reads as positioning next to a mission statement on the reference page.
|
||||
|
||||
## Section E — How a decision gets made
|
||||
|
||||
Keep `VoteEvidence` and `RepoActivity`. Add the copy the section is missing — today it shows a ballot with
|
||||
no explanation of the machinery behind it.
|
||||
|
||||
**Label:** How a decision gets made _(unchanged)_
|
||||
**Intro:**
|
||||
|
||||
> Compass runs on a written constitution. Members who contribute — five hours of work, or $20 a year — can
|
||||
> become voting members; new administrators need a unanimous vote of the current ones. Proposals are public,
|
||||
> ballots are public, and the outcome binds the project. The vote below is a real one: require email
|
||||
> verification before messaging, 11 for, 1 against, shipped.
|
||||
|
||||
**Kicker, under the tally:**
|
||||
|
||||
> Twelve voters is a small turnout. We would rather show you the real one than a bigger number that isn't
|
||||
> real.
|
||||
>
|
||||
> → `/constitution` · `/vote` · `/members`
|
||||
|
||||
That kicker is the resolution of the turnout tension flagged in marketing-visuals.md (A1). The tension does
|
||||
not go away by hiding it; naming it converts the page's weakest number into its strongest demonstration.
|
||||
|
||||
## Section F — Who's behind it
|
||||
|
||||
New. Neither page has a human on it; the founder story lives only in the FAQ.
|
||||
|
||||
**Label:** Who's behind it
|
||||
**Heading:** Somebody has to start these things.
|
||||
|
||||
> Compass was started by Martin Braquet, an engineer from Belgium, after years of living across Europe, the
|
||||
> U.S., India and Indonesia and finding that the connections that mattered most were the hardest to make on
|
||||
> purpose. He still stewards the project, but he cannot decide it alone: the constitution puts direction in
|
||||
> the members' hands, and the source is public, so nothing here depends on his goodwill.
|
||||
>
|
||||
> Everyone who has built Compass is a volunteer. Nobody has ever been paid.
|
||||
|
||||
**Verify before shipping:** the founding year is not asserted above because I couldn't confirm it from the
|
||||
repo. The press page implies a launch around September 2025 ("four months after launch… just over 400
|
||||
users", January 2026). Add the year if you want it.
|
||||
|
||||
Link Martin Braquet to his profile: /Martin.
|
||||
|
||||
## Section G — What it costs, and who pays
|
||||
|
||||
New. Today this argument exists only as a help card reading "Every contribution keeps the lights on".
|
||||
|
||||
**Label:** What it costs
|
||||
**Heading:** The entire budget, in three numbers.
|
||||
|
||||
> Since launch, Compass has cost $457 to run — hosting, infrastructure, domains. Members have donated $145.
|
||||
> The remaining $312 came out of the founder's pocket. There are no salaries, no marketing budget and no
|
||||
> private costs, and every line of it is published.
|
||||
>
|
||||
> → See the books `/financials` · Donate `/support`
|
||||
|
||||
Make it read from a constant JS dict so it doesn't duplicate across pages.
|
||||
|
||||
## Section H — Where it runs
|
||||
|
||||
New, short.
|
||||
|
||||
**Label:** Where it runs
|
||||
**Heading:** Browser, Android, and a home-screen app on iPhone.
|
||||
|
||||
> The web app works in any browser. There's an Android app on Google Play. On iPhone, add Compass to your
|
||||
> home screen from Safari and it behaves like an app, notifications included — Safari only, because iOS
|
||||
> allows it nowhere else; a native iOS app is waiting for someone to build it. The interface is in English,
|
||||
> French and German, and there is a public API.
|
||||
|
||||
## Section I — Press
|
||||
|
||||
> Compass has been covered by RCF, La DH, L'Avenir and Matélé. → `/press`
|
||||
|
||||
## Section J — Help Compass grow
|
||||
|
||||
Keep the section as it stands: `MemberGrowth`, then `ShareStrip`, then the featured help card and the row of
|
||||
three. One change — the donate card (`about.donate.text`) should stop saying "Every contribution keeps the
|
||||
lights on" and point at Section G instead, so the ask and the numbers agree:
|
||||
|
||||
| | |
|
||||
| --- | ------------------------------------------------------------------------------------------ |
|
||||
| Now | Support our not-for-profit infrastructure. Every contribution keeps the lights on. |
|
||||
| New | **$457 spent, $145 donated, no salaries. Every expense is published before we ask again.** |
|
||||
|
||||
---
|
||||
|
||||
## Number provenance
|
||||
|
||||
Fetched from `https://api.compassmeet.com/stats` on 2026-07-27. These drift; re-check before shipping, and
|
||||
render them live rather than pasting them in.
|
||||
|
||||
| Claim | Source | Value at capture |
|
||||
| ---------------------------------- | -------------------------------------------- | ----------------------- |
|
||||
| Members / countries | `profiles`, `countryCount` | 726 / 55 |
|
||||
| Conversations / messages | `conversations`, `messages` | 378 / 2,283 |
|
||||
| Most between 25 and 44 | `demographics.age`, base 481 | 302 / 481 = 63% |
|
||||
| Bachelor's or higher | `demographics.education_level`, base 394 | 277 / 394 = 70% |
|
||||
| Atheist or agnostic | `demographics.religion`, base 304 | 205 / 304 = 67% |
|
||||
| Vegetarian or vegan | `demographics.diet`, base 251 | 110 / 251 = 44% |
|
||||
| INTJ / INFJ most common | `demographics.mbti`, base 122 | 26 / 25 |
|
||||
| Relationship / friendship / collab | `demographics.pref_relation_styles` | 373 / 341 / 263 |
|
||||
| Largest national group | `countries[0]` | United States, 88 |
|
||||
| Age split 63 / 21 / 16% | `demographics.age`, base 481 | 302 / 100 / 79 |
|
||||
| 35 doctorates | `demographics.education_level` | 35 |
|
||||
| "Spiritual" then Christian | `demographics.religion` | 50 / 49 |
|
||||
| Languages 670 / 103 / 37 / 31 | `demographics.languages`, base 725 | en / fr / es / de |
|
||||
| Vote tally 11 for, 1 against | marketing-visuals.md A1, captured from prod | shipped |
|
||||
| 5 hours or $20/year to vote | `web/public/md/constitution.md`, Article II | — |
|
||||
| $312 out of pocket | `web/public/md/financials.md` | −$311.95 |
|
||||
| ~100 new members per month | existing in-app claim, `profiles-home.tsx` | unverified against data |
|
||||
| $457 spent / $145 donated | `web/public/md/financials.md` | $456.65 / $144.70 |
|
||||
| 130 lines of scoring code | `common/src/profiles/compatibility-score.ts` | 133 lines |
|
||||
| More than twenty filters | `web/components/filters/` | 25 filter components |
|
||||
|
||||
One of these deserves a second look before it goes on a public page: the ~100/month growth claim is asserted
|
||||
in the app but was not verified against the member-growth series.
|
||||
|
||||
The gender split (73% male / 27% female among the 512 who answered) was originally kept off both pages —
|
||||
see below for why that call was reversed.
|
||||
|
||||
## Deliberately out of scope
|
||||
|
||||
- **Events.** `upcomingEvents` is **0**. Neither page should promote them until there is supply — and the
|
||||
FAQ deserves a second look too, since it calls events "at the core of Compass's mission" and sends readers
|
||||
to an empty `/events`.
|
||||
- **Real member photos.** Still the right end state for social proof (option 1 in marketing-visuals.md, H3),
|
||||
still blocked on consent that has to be collected by a human.
|
||||
|
||||
## Reversed: the gender split
|
||||
|
||||
The original draft kept the gender split (73% male / 27% female among the 512 who answered) off both pages
|
||||
entirely, on the reasoning that it's a motivator for writing the safety and profile-visibility copy, not a
|
||||
number to lead with. That call has since been reversed: the home page's Who's here paragraph now includes it
|
||||
as one plain clause ("...and N% are men"), sourced live from `stats.genderRatio` the same way every other
|
||||
figure in that section is. The original reasoning still constrains _how_ it's shown — one clause among
|
||||
several rather than a headline stat or a full breakdown — but no longer whether it's shown at all.
|
||||
|
||||
## Source files this copy touches
|
||||
|
||||
| Section | File |
|
||||
| --------------------------- | ------------------------------------------------------------------- |
|
||||
| Home, all sections | `web/components/home/home.tsx` |
|
||||
| About header, sections A–J | `web/pages/about.tsx` |
|
||||
| Stat band caption | `web/components/about/platform-stats.tsx` |
|
||||
| Saved-search step | `NotifySpotlight` in `web/pages/about.tsx:163` |
|
||||
| Vote section copy | `web/components/about/vote-evidence.tsx`, `web/pages/about.tsx:690` |
|
||||
| Translations for both pages | `common/messages/de.json`, `common/messages/fr.json` |
|
||||
@@ -58,7 +58,7 @@ const DEFAULT_AVATAR = '/images/default-avatar.png'
|
||||
* Missing portraits are not fatal: the profile still seeds, it just wears the default avatar.
|
||||
*
|
||||
* `pinned_url` and `photo_urls` are disjoint: the pinned photo must NOT also appear in `photo_urls`.
|
||||
* `ProfileCarousel` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
|
||||
* `ProfileGallery` renders `buildArray(pinned_url, photo_urls)` without deduping, so a shared entry
|
||||
* shows up twice. (The photo editor wraps the same call in `uniq`, which masks it while editing.)
|
||||
*/
|
||||
function resolvePhotos(profile: ShowcaseProfile) {
|
||||
|
||||
@@ -88,8 +88,10 @@ export class ProfilePage {
|
||||
this.ethnicityAboutSection = page.getByTestId('profile-content')
|
||||
this.dietAboutSection = page.getByTestId('profile-content')
|
||||
this.languagesAboutSection = page.getByTestId('profile-content')
|
||||
this.seekingAboutSection = page.getByTestId('profile-content')
|
||||
this.relationshipStatusAboutSection = page.getByTestId('profile-content')
|
||||
// Connection goals moved out of the Details rail into the hero callout: they decide whether the
|
||||
// rest of the profile is relevant at all, so they sit above the fold.
|
||||
this.seekingAboutSection = page.getByTestId('profile-connection-goals')
|
||||
this.relationshipStatusAboutSection = page.getByTestId('profile-connection-goals')
|
||||
this.educationAboutSection = page.getByTestId('profile-content')
|
||||
this.occupationAboutSection = page.getByTestId('profile-content')
|
||||
this.workAreaAboutSection = page.getByTestId('profile-content')
|
||||
|
||||
@@ -136,9 +136,14 @@ export function StatBand() {
|
||||
return () => observer.disconnect()
|
||||
}, [armed, node, run])
|
||||
|
||||
const showCountries = !!data && data.countryCount > 1
|
||||
// Countries used to be the second stat here and is now gone. Fifty-five countries across ~700 members is
|
||||
// *dispersion*, not reach — it is the figure that quietly says "nobody near you" while presenting itself
|
||||
// as an achievement, and it was the second thing on the page. Messages sits beside conversations instead
|
||||
// and answers the question a visitor is actually asking of a small platform: does anyone reply. The full
|
||||
// country breakdown still lives on /stats, where someone came to read numbers.
|
||||
const showConversations = !!data && data.conversations > 0
|
||||
const lastDelay = STAGGER_MS * ((showCountries ? 1 : 0) + (showConversations ? 1 : 0))
|
||||
const showMessages = !!data && data.messages > 0
|
||||
const lastDelay = STAGGER_MS * ((showConversations ? 1 : 0) + (showMessages ? 1 : 0))
|
||||
|
||||
useEffect(() => {
|
||||
if (!run) return
|
||||
@@ -163,14 +168,6 @@ export function StatBand() {
|
||||
delay={0}
|
||||
run={run}
|
||||
/>
|
||||
{showCountries && (
|
||||
<CountingStat
|
||||
target={data.countryCount}
|
||||
label={t('about.stat.countries', 'Countries')}
|
||||
delay={next()}
|
||||
run={run}
|
||||
/>
|
||||
)}
|
||||
{showConversations && (
|
||||
<CountingStat
|
||||
target={data.conversations}
|
||||
@@ -179,6 +176,14 @@ export function StatBand() {
|
||||
run={run}
|
||||
/>
|
||||
)}
|
||||
{showMessages && (
|
||||
<CountingStat
|
||||
target={data.messages}
|
||||
label={t('about.stat.messages', 'Messages')}
|
||||
delay={next()}
|
||||
run={run}
|
||||
/>
|
||||
)}
|
||||
{/* Not a queried number, and deliberately so: it is the one claim here that is a policy rather
|
||||
than a measurement, and it is the whole argument of the "Completely Free" card. */}
|
||||
<Stat
|
||||
@@ -198,6 +203,13 @@ export function StatBand() {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{/* One line, and only one. An earlier draft added a sentence here about how thinly the membership is
|
||||
spread; it was cut because this is the page's second element and the reader has not yet been given
|
||||
a single reason to want in — self-criticism before value is just discouragement. The same point is
|
||||
made properly on the home page, attached to something to do about it. */}
|
||||
<p className="w-full text-sm text-ink-500">
|
||||
{t('about.stat.caption', 'Read live from the database, not from a press kit.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ export function AnswerCompatibilityQuestionButton(props: {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="bg-ink-100 dark:bg-ink-300 text-ink-1000 hover:bg-ink-200 hover:dark:bg-ink-400 w-28 rounded-full px-2 py-0.5 text-xs transition-colors"
|
||||
// Matches the outlined importance/compatibility pills it sits beside in a prompt. It was the
|
||||
// only solid one of the three, which read as the loudest thing in a block it is not about.
|
||||
className="font-dm-sans border-canvas-300 text-ink-600 hover:border-primary-400 hover:bg-primary-50 h-fit whitespace-nowrap rounded-full border px-2.5 py-1 uppercase transition-colors"
|
||||
style={{fontSize: '10px', letterSpacing: '0.12em'}}
|
||||
>
|
||||
{t('answers.answer.answer_yourself', 'Answer yourself')}
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@ import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
import {CompatibleBadge} from '../widgets/compatible-badge'
|
||||
import {CompatibilityScoreBar} from '../widgets/compatible-badge'
|
||||
import {Subtitle} from '../widgets/profile-subtitle'
|
||||
import {
|
||||
AnswerCompatibilityQuestionButton,
|
||||
@@ -176,19 +176,21 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
{pinnedAnswers.length > 0 && (
|
||||
<Col className="gap-3">
|
||||
<PinIcon />
|
||||
{shownPinnedAnswers.map((answer) => (
|
||||
<CompatibilityAnswerBlock
|
||||
key={`pinned-${answer.question_id}`}
|
||||
answer={answer}
|
||||
yourQuestions={answeredQuestions}
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
profile={profile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
showCommunityInfo={showCommunityInfo}
|
||||
/>
|
||||
))}
|
||||
<Col>
|
||||
{shownPinnedAnswers.map((answer) => (
|
||||
<CompatibilityAnswerBlock
|
||||
key={`pinned-${answer.question_id}`}
|
||||
answer={answer}
|
||||
yourQuestions={answeredQuestions}
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
profile={profile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
showCommunityInfo={showCommunityInfo}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
{NUM_PINNED_QUESTIONS_TO_SHOW < pinnedAnswers.length && (
|
||||
<Pagination
|
||||
page={pinnedPage}
|
||||
@@ -200,10 +202,14 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
<div className="border-canvas-200 border-b" />
|
||||
</Col>
|
||||
)}
|
||||
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
{/* Score first and full width — it is the number the whole section exists to produce, and it was
|
||||
previously a badge squeezed between the search box and the sort control. No caption: the
|
||||
section heading directly above already reads "Compatibility prompts". */}
|
||||
{compatibilityScore && <CompatibilityScoreBar compatibility={compatibilityScore} hideLabel />}
|
||||
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
{answeredQuestions.length > 0 && (
|
||||
<>
|
||||
<div className="relative mt-3 w-full max-w-[50%] xl:max-w-[400px]">
|
||||
<div className="relative min-w-[180px] flex-1 sm:max-w-[340px]">
|
||||
{/*<input*/}
|
||||
{/* type="text"*/}
|
||||
{/* placeholder={t('answers.search_placeholder', 'Search prompts...')}*/}
|
||||
@@ -217,7 +223,9 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
<Input
|
||||
value={searchTerm}
|
||||
placeholder={t('answers.search_placeholder', 'Search prompts...')}
|
||||
className={'w-full'}
|
||||
className={
|
||||
'w-full !h-10 !rounded-none !border-0 !border-b !border-canvas-300 !bg-transparent !px-0 !shadow-none [&_input]:!bg-transparent'
|
||||
}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value)
|
||||
}}
|
||||
@@ -225,7 +233,7 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
/>
|
||||
</div>
|
||||
<CompatibilitySortWidget
|
||||
className="text-sm sm:flex mt-4"
|
||||
className="text-sm sm:flex"
|
||||
sort={sort}
|
||||
setSort={setSort}
|
||||
user={user}
|
||||
@@ -233,9 +241,6 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{compatibilityScore && (
|
||||
<CompatibleBadge compatibility={compatibilityScore} className={'mt-5 mr-4'} />
|
||||
)}
|
||||
</Row>
|
||||
{answeredQuestions.length == 0 ? (
|
||||
<span className="text-ink-600 text-sm">
|
||||
@@ -260,21 +265,23 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{shownAnswers.map((answer) => {
|
||||
return (
|
||||
<CompatibilityAnswerBlock
|
||||
key={answer.question_id}
|
||||
answer={answer}
|
||||
yourQuestions={answeredQuestions}
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
profile={profile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
showCommunityInfo={showCommunityInfo}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<Col>
|
||||
{shownAnswers.map((answer) => {
|
||||
return (
|
||||
<CompatibilityAnswerBlock
|
||||
key={answer.question_id}
|
||||
answer={answer}
|
||||
yourQuestions={answeredQuestions}
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
profile={profile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
showCommunityInfo={showCommunityInfo}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Col>
|
||||
{shownAnswers.length === 0 && (
|
||||
<Col className="items-center py-8 text-center">
|
||||
<div className="text-ink-600 mb-2">
|
||||
@@ -415,16 +422,30 @@ export function CompatibilityAnswerBlock(props: {
|
||||
<Col
|
||||
data-testid="profile-compatibility-section"
|
||||
className={clsx(
|
||||
'bg-canvas-200/20 border border-canvas-200 flex-grow gap-2 whitespace-pre-line rounded-xl p-4 leading-relaxed',
|
||||
// No card. A prompt is question → answer → alternatives, which is structurally the same object
|
||||
// as a Details row, so it gets the same treatment: a rule, not a box.
|
||||
'group border-canvas-200 flex-grow gap-2 whitespace-pre-line border-b py-6 leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Row
|
||||
className="justify-between gap-1 font-medium"
|
||||
data-testid="profile-compatibility-question"
|
||||
>
|
||||
{question.question}
|
||||
<Row className="gap-4 font-normal" data-testid="profile-compatibility-importance">
|
||||
<Row className="items-baseline justify-between gap-4">
|
||||
{/* Body sans, not the serif. The serif marks text a person wrote — the tagline, the bio, the
|
||||
explanation below — and this question is platform boilerplate that reads identically on
|
||||
every profile. At 19px Newsreader it also outweighed the answer, which is the part anyone
|
||||
is actually here to read. It is the label of this row; the answer is the value. */}
|
||||
<h3
|
||||
// `font-figtree` is the body face: globals.css sets every h1–h6 to Newsreader at weight 700,
|
||||
// so a heading element opts out of the serif explicitly or it does not opt out at all.
|
||||
className="font-figtree text-ink-600 min-w-0 font-normal"
|
||||
style={{fontSize: '16px', lineHeight: '1.45'}}
|
||||
data-testid="profile-compatibility-question"
|
||||
>
|
||||
{question.question}
|
||||
</h3>
|
||||
<Row
|
||||
className="shrink-0 items-center gap-2 font-normal"
|
||||
data-testid="profile-compatibility-importance"
|
||||
>
|
||||
{comparedProfile && isAnswered && (
|
||||
<div className="hidden sm:block">
|
||||
<CompatibilityDisplay
|
||||
@@ -437,7 +458,15 @@ export function CompatibilityAnswerBlock(props: {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!currentUser && <PinQuestionButton questionId={question.id} />}
|
||||
{/* Pinning is a rare action that was occupying permanent space in every row. It appears on
|
||||
hover where there is a pointer, and stays put where there is not — a touch device has no
|
||||
hover state to reveal it with. Focus reveals it too, so it stays keyboard-reachable. */}
|
||||
{!!currentUser && (
|
||||
<PinQuestionButton
|
||||
questionId={question.id}
|
||||
className="opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
/>
|
||||
)}
|
||||
{isCurrentUser && isAnswered && (
|
||||
<>
|
||||
<ImportanceButton
|
||||
@@ -496,41 +525,54 @@ export function CompatibilityAnswerBlock(props: {
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
{/* The chosen answer is the value of the question above it, exactly as "Student" is the value of
|
||||
"Work" — so it gets the value treatment rather than a pill. Pills in this design mean
|
||||
"clickable", and this one never was. */}
|
||||
{answerText && (
|
||||
<Row
|
||||
className="border border-primary-200 bg-primary-50 text-primary-700 w-fit gap-1 rounded-full px-3 py-1 text-sm"
|
||||
<div
|
||||
className="text-primary-900 mt-1"
|
||||
style={{fontSize: '18px', lineHeight: '1.35'}}
|
||||
data-testid="profile-compatibility-question-answer"
|
||||
>
|
||||
{answerText}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
{/* Free text the member wrote themselves — the only sentence in the block that is theirs, so it
|
||||
keeps the serif italic the tagline uses. The keyline it used to hang off is gone. */}
|
||||
{answer?.explanation && (
|
||||
<div
|
||||
className="font-heading text-ink-500 italic"
|
||||
style={{fontSize: '15px', lineHeight: '1.55'}}
|
||||
data-testid="profile-compatibility-question-answer-explanation"
|
||||
>
|
||||
<Linkify text={`“${answer.explanation}”`} />
|
||||
</div>
|
||||
)}
|
||||
<Row
|
||||
className="px-2 text-sm text-ink-500 leading-relaxed pl-2 border-l-2 border-canvas-300 ml-1"
|
||||
data-testid="profile-compatibility-question-answer-explanation"
|
||||
>
|
||||
{answer?.explanation && <Linkify className="" text={`"${answer.explanation}"`} />}
|
||||
</Row>
|
||||
{distinctPreferredAnswersText.length > 0 && (
|
||||
<Col className="gap-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-ink-300">
|
||||
<div
|
||||
className="text-ink-500 mt-1"
|
||||
style={{fontSize: '14px', lineHeight: '1.55'}}
|
||||
data-testid="profile-compatibility-question-acceptable-answer"
|
||||
>
|
||||
<span
|
||||
className="text-ink-400 font-dm-sans mr-2 uppercase"
|
||||
style={{fontSize: '10px', letterSpacing: '0.16em'}}
|
||||
>
|
||||
{preferredDoesNotIncludeAnswerText
|
||||
? t('answers.display.acceptable', 'Acceptable')
|
||||
: t('answers.display.also_acceptable', 'Also acceptable')}
|
||||
</div>
|
||||
<Row
|
||||
className="flex-wrap gap-2 mt-0"
|
||||
data-testid="profile-compatibility-question-acceptable-answer"
|
||||
>
|
||||
{distinctPreferredAnswersText.map((text) => (
|
||||
<Row
|
||||
key={text}
|
||||
className="border border-canvas-200 bg-canvas-100 text-ink-500 w-fit gap-1 rounded-full px-3 py-1 text-sm"
|
||||
>
|
||||
{text}
|
||||
</Row>
|
||||
))}
|
||||
</Row>
|
||||
</Col>
|
||||
</span>
|
||||
{distinctPreferredAnswersText.map((text, i) => (
|
||||
<span key={text}>
|
||||
{i > 0 && (
|
||||
<span aria-hidden className="text-ink-400 select-none">
|
||||
{' · '}
|
||||
</span>
|
||||
)}
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isAnswered && (
|
||||
<Row className="flex-wrap gap-2 mt-0">
|
||||
@@ -545,7 +587,7 @@ export function CompatibilityAnswerBlock(props: {
|
||||
setNewAnswer(_answer)
|
||||
setEditOpen(true)
|
||||
}}
|
||||
className="bg-canvas-100 hover:bg-canvas-200 w-fit gap-1 rounded-xl px-2 py-1 text-sm"
|
||||
className="border-canvas-300 text-ink-700 hover:border-primary-400 hover:bg-primary-50 w-fit gap-1 rounded-full border px-3 py-1 text-sm transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@@ -682,14 +724,18 @@ function CompatibilityDisplay(props: {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Still a button, still opens the comparison modal — only the weight changed. Outlined
|
||||
rather than filled: two solid badges per prompt, stacked down a long column, drowned the
|
||||
questions they were annotating. */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className={clsx(
|
||||
'border text-ink-1000 h-fit w-28 rounded-full px-2 py-0.5 text-xs transition-colors',
|
||||
'font-dm-sans h-fit whitespace-nowrap rounded-full border px-2.5 py-1 uppercase transition-colors',
|
||||
answerCompatibility
|
||||
? 'bg-green-500/10 text-green-800 border-green-500/25 hover:bg-green-500/30'
|
||||
: 'bg-red-500/20 text-red-800 border-red-500/25 hover:bg-red-500/30',
|
||||
? 'border-green-500/40 text-green-700 hover:bg-green-500/15'
|
||||
: 'border-red-500/40 text-red-700 hover:bg-red-500/15',
|
||||
)}
|
||||
style={{fontSize: '10px', letterSpacing: '0.12em'}}
|
||||
>
|
||||
{answerCompatibility
|
||||
? t('answers.compatible', 'Compatible')
|
||||
@@ -773,29 +819,27 @@ function ImportanceDisplay(props: {importance: number}) {
|
||||
function ImportanceButton(props: {importance: number; onClick: () => void; className?: string}) {
|
||||
const {importance, onClick, className} = props
|
||||
|
||||
// Color scheme based on importance level
|
||||
// Outlined rather than filled, and no fixed width: importance is an annotation on the question, and
|
||||
// a solid amber block per prompt read louder than the questions themselves down a long column. The
|
||||
// ramp still steps down, now through border and text weight instead of a background.
|
||||
const importanceColors = {
|
||||
3: {
|
||||
// Very Important — full primary amber
|
||||
background: 'rgb(var(--color-primary-50))',
|
||||
color: 'rgb(var(--color-primary-700))',
|
||||
border: 'rgb(var(--color-primary-200))',
|
||||
border: 'rgb(var(--color-primary-400))',
|
||||
},
|
||||
2: {
|
||||
// Important — softer amber, slightly stepped back
|
||||
background: 'rgb(var(--color-primary-50))',
|
||||
color: 'rgb(var(--color-primary-600))',
|
||||
border: 'rgb(var(--color-primary-100))',
|
||||
border: 'rgb(var(--color-primary-200))',
|
||||
},
|
||||
1: {
|
||||
// Somewhat Important — warm neutral
|
||||
background: 'rgb(var(--color-canvas-100))',
|
||||
color: 'rgb(var(--color-ink-500))',
|
||||
border: 'rgb(var(--color-canvas-300))',
|
||||
},
|
||||
0: {
|
||||
// Not Important — near-invisible
|
||||
background: 'rgb(var(--color-canvas-50))',
|
||||
color: 'rgb(var(--color-ink-300))',
|
||||
border: 'rgb(var(--color-canvas-200))',
|
||||
},
|
||||
@@ -808,12 +852,12 @@ function ImportanceButton(props: {importance: number; onClick: () => void; class
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'h-fit rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
importance === 1 ? 'w-36' : 'w-28',
|
||||
'font-dm-sans hover:bg-canvas-50 h-fit whitespace-nowrap rounded-full px-2.5 py-1 uppercase transition-colors',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: colors.background,
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.12em',
|
||||
color: colors.color,
|
||||
border: `1px solid ${colors.border}`,
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import {AdjustmentsHorizontalIcon, EyeIcon, UsersIcon} from '@heroicons/react/24/outline'
|
||||
import {ChatBubbleLeftRightIcon, ScaleIcon, ViewColumnsIcon} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {discordLink, githubRepo} from 'common/constants'
|
||||
import {discordLink, FINANCIALS, githubRepo} from 'common/constants'
|
||||
import Link from 'next/link'
|
||||
import {ComponentType, ReactNode, SVGProps, useEffect, useRef} from 'react'
|
||||
import {FaDiscord, FaGithub} from 'react-icons/fa'
|
||||
import {SearchDemo} from 'web/components/home/search-demo'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {SignUpButton} from 'web/components/nav/sidebar'
|
||||
import {MemberGrowth} from 'web/components/widgets/charts'
|
||||
import {Reveal} from 'web/components/widgets/reveal'
|
||||
import {DistRow, labelFor} from 'web/components/widgets/stat-distribution'
|
||||
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
|
||||
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {CORE_AGE_BUCKETS, shareOf, topOf, TopValue} from 'web/lib/marketing-stats'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +41,10 @@ function EyebrowBadge({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
}
|
||||
|
||||
// Unused since the three feature tiles became the `ClaimBlock`s below, and kept for the same reason their
|
||||
// copy is kept commented in `LoggedOutHome`: bringing a short-card row back is then an edit rather than a
|
||||
// rewrite. /about still renders its own copy of this shape.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function FeatureCard({icon: Icon, title, text}: FeatureCardProps) {
|
||||
return (
|
||||
<div className={clsx(surface, surfaceHover, 'h-full p-6 sm:p-7')}>
|
||||
@@ -64,7 +71,21 @@ function SocialAvatar({letter, gradient}: SocialAvatarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function SocialProof({label}: {label: React.ReactNode}) {
|
||||
/**
|
||||
* The line under the hero CTAs.
|
||||
*
|
||||
* It used to read "Joined by 700+ real people worldwide" — a *size* claim, in the one place on the site
|
||||
* where a visitor is reflexively comparing Compass to apps with millions of users, which is the comparison
|
||||
* it loses. It now reports activity instead: conversations and messages answer the question actually being
|
||||
* asked this early ("is anyone here, does anyone reply?") without stating how small the pool is. The
|
||||
* membership figure appears exactly once on this page, much further down, attached to what to do about it.
|
||||
*
|
||||
* Live, and renders nothing at all when the stats call comes back empty — same rule as `StatBand`.
|
||||
*/
|
||||
function SocialProof() {
|
||||
const t = useT()
|
||||
const {data} = useAPIGetter('stats', {})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const avatars: SocialAvatarProps[] = [
|
||||
{letter: 'S', gradient: 'linear-gradient(135deg, #C17F3E, #8B5E3C)'},
|
||||
@@ -73,6 +94,8 @@ function SocialProof({label}: {label: React.ReactNode}) {
|
||||
{letter: 'L', gradient: 'linear-gradient(135deg, #C17F3E, #D4955A)'},
|
||||
]
|
||||
|
||||
if (!data?.conversations || !data?.messages) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-ink-600 text-sm">
|
||||
{/*<div className="flex">*/}
|
||||
@@ -80,7 +103,376 @@ function SocialProof({label}: {label: React.ReactNode}) {
|
||||
{/* <SocialAvatar key={av.letter} {...av} />*/}
|
||||
{/* ))}*/}
|
||||
{/*</div>*/}
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
<strong className="text-ink-900">{data.conversations.toLocaleString()}</strong>
|
||||
{t('home.proof.conversations', ' conversations started · ')}
|
||||
<strong className="text-ink-900">{data.messages.toLocaleString()}</strong>
|
||||
{t('home.proof.messages', ' messages sent')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "looking for" bar chart — fills the empty column beside the Who's here paragraph with the one
|
||||
* distribution the prose only gestures at.
|
||||
*
|
||||
* Draws with `DistRow`, the exact bar /stats uses for every distribution — one bar vocabulary everywhere
|
||||
* rather than a second one invented for this card. It departs from `DistRow`'s usual call in one way: width
|
||||
* here is the raw percentage (`widthPct = pct`), not normalised to the leading item, and `rank` is left at
|
||||
* its default of 0 so there's no opacity fade. With only three rows and no item near 100%, a direct
|
||||
* percentage bar is the more literal read and there is no ranking-legibility problem to solve.
|
||||
*
|
||||
* `pref_relation_styles` is multi-select, so percentages are each value's own count against the shared base
|
||||
* of distinct respondents — they will not sum to 100%, which is expected, not a bug. See
|
||||
* `web/lib/marketing-stats.ts`.
|
||||
*/
|
||||
function LookingForChart({looking}: {looking: TopValue[]}) {
|
||||
const t = useT()
|
||||
const base = looking[0]?.base
|
||||
|
||||
if (!base) return null
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('home.who.chart.title', 'Looking for')}
|
||||
</p>
|
||||
<p className="mb-4 text-xs text-ink-500">
|
||||
{t('home.who.chart.subtitle', 'Of {base} who answered', {base: base.toLocaleString()})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{looking.map((v) => {
|
||||
const pct = Math.max(1, Math.round((v.count / base) * 100))
|
||||
return (
|
||||
<DistRow
|
||||
key={v.value}
|
||||
label={labelFor('pref_relation_styles', v.value)}
|
||||
pct={pct}
|
||||
widthPct={pct}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Who's here" — the section the page was most missing.
|
||||
*
|
||||
* Every other block on this page describes a *mechanism*; this one describes the *population*, which is the
|
||||
* sharpest thing Compass has to say and was said nowhere. It is deliberately composition rather than
|
||||
* headcount: a club reads the same at 726 members as at 7,000, whereas a total only shrinks in the reader's
|
||||
* estimation the more places it appears.
|
||||
*
|
||||
* Percentages come only from single-select fields — see `web/lib/marketing-stats.ts` for why summing
|
||||
* multi-select counts against their base would overstate. The multi-select claims here name their leading
|
||||
* answers instead, which is true whatever the overlap. `pref_relation_styles` gets its own chart rather than
|
||||
* a third prose clause — see `LookingForChart` — which also gives the card something to fill the width a
|
||||
* short paragraph leaves empty on wide screens.
|
||||
*/
|
||||
function WhosHere() {
|
||||
const t = useT()
|
||||
const {data} = useAPIGetter('stats', {})
|
||||
|
||||
const age = shareOf(data, 'age', CORE_AGE_BUCKETS)
|
||||
const degree = shareOf(data, 'education_level', ['bachelors', 'masters', 'doctorate'])
|
||||
const manRatio = data?.genderRatio?.male
|
||||
const looking = topOf(data, 'pref_relation_styles', 3)
|
||||
|
||||
// Nothing to say without the two figures the paragraph is built around; a version of this section with
|
||||
// holes in it would read worse than no section.
|
||||
if (!age || !degree) return null
|
||||
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-10')}>
|
||||
<div className="lg:flex lg:items-start lg:justify-between lg:gap-12">
|
||||
<div className="min-w-0">
|
||||
<p className={clsx(eyebrow, 'text-primary-700 mb-4')}>
|
||||
{t('home.who.label', "Who's here")}
|
||||
</p>
|
||||
<h2 className="font-heading text-ink-900 text-[clamp(24px,3vw,36px)] leading-[1.15] tracking-tight mt-0 mb-5 text-balance">
|
||||
{t('home.who.title', "You can see exactly who you'd be joining.")}
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-ink-600 leading-relaxed max-w-2xl">
|
||||
{t('home.who.intro', 'No generic adjectives. Among the members who answered: ')}
|
||||
{age.pct}%{t('home.who.age', ' are between 25 and 44, ')}
|
||||
{degree.pct}%{t('home.who.education', ' hold a bachelor’s degree or higher')}
|
||||
{manRatio != null && t('home.who.gender', ', and {pct}% are men', {pct: manRatio})}
|
||||
{'. '}
|
||||
{looking &&
|
||||
t(
|
||||
'home.who.looking.v2',
|
||||
'Most are looking for a relationship, though many are seeking friendships as well.',
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href="/stats"
|
||||
className="mt-6 inline-flex w-fit items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{t('home.who.link', 'See the full breakdown →')}
|
||||
</Link>
|
||||
</div>
|
||||
{looking && <LookingForChart looking={looking} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "A profile here takes twenty minutes to write, not two." — the field list the prose
|
||||
* already names, as chips instead of a sentence a reader has to parse to see the shape of what a profile
|
||||
* holds. Static: this is the product's schema, not a number that can drift, so there's nothing to fetch.
|
||||
*/
|
||||
function FieldChips() {
|
||||
const t = useT()
|
||||
|
||||
const fields = [
|
||||
t('home.profile.chip.bio', 'Bio'),
|
||||
t('home.profile.chip.prompts', 'Prompts'),
|
||||
t('home.profile.chip.causes', 'Causes'),
|
||||
t('home.profile.chip.politics', 'Politics'),
|
||||
t('home.profile.chip.religion', 'Religion'),
|
||||
t('home.profile.chip.diet', 'Diet'),
|
||||
t('home.profile.chip.languages', 'Languages'),
|
||||
t('home.profile.chip.education', 'Education'),
|
||||
t('home.profile.chip.personality', 'Personality'),
|
||||
t('home.profile.chip.looking', 'Looking for'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('home.profile.chips.title', 'What a profile can hold')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="rounded-full border border-canvas-300 bg-canvas-0 px-3 py-1 text-[13px] text-ink-700"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-ink-500">
|
||||
{t('home.profile.chips.caption', 'Every field is optional.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "One algorithm. You set the weights, and you can read the source." — a worked example
|
||||
* of the three inputs the prose describes, in the same pill vocabulary the real compatibility-question UI
|
||||
* uses (`web/components/answers/compatibility-questions-display.tsx`: an amber pill for the user's own
|
||||
* answer, neutral outline pills for what they'd accept, amber again for importance). Reusing that exact
|
||||
* styling rather than inventing a new one means this mock reads as "what the product actually looks like,"
|
||||
* not as decoration. The 92% is a worked example, not a live figure — nobody's real score, so nothing here
|
||||
* needs to be fetched or can go stale.
|
||||
*/
|
||||
function ScoreDiagram() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<p className="mb-4 text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('home.score.mock.title', 'One question, scored')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-ink-500">{t('home.score.mock.you', 'Your answer')}</div>
|
||||
<span className="inline-flex w-fit rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-[13px] text-primary-700">
|
||||
{t('home.score.mock.you_value', 'Vegetarian')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-ink-500">
|
||||
{t('home.score.mock.accept', "You'd accept")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full border border-canvas-300 bg-canvas-0 px-3 py-1 text-[13px] text-ink-600">
|
||||
{t('home.score.mock.accept1', 'Vegetarian')}
|
||||
</span>
|
||||
<span className="rounded-full border border-canvas-300 bg-canvas-0 px-3 py-1 text-[13px] text-ink-600">
|
||||
{t('home.score.mock.accept2', 'Vegan')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-ink-500">
|
||||
{t('home.score.mock.weight', 'How much it matters')}
|
||||
</div>
|
||||
<span className="inline-flex w-fit rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-[13px] text-primary-700">
|
||||
{t('home.score.mock.weight_value', 'Very important')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-canvas-200 pt-4">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('home.score.mock.result', 'Match')}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-primary-600">92%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "The shortest message you can send is 200 characters." — the composer's two states,
|
||||
* rather than a description of them: a three-word opener stuck under a greyed-out Send button, and a real
|
||||
* one that clears the gate. Dramatizing the actual UI behavior is more convincing than the sentence above
|
||||
* it, which is describing exactly this. Both messages and the counts are illustrative copy, not a real
|
||||
* conversation — nothing here is a claim about who sent what.
|
||||
*/
|
||||
function MessageComposerMock() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('home.message.mock.title', 'First message')}
|
||||
</p>
|
||||
<div className="rounded-lg border border-canvas-300 bg-canvas-0 p-3">
|
||||
<p className="text-[13px] italic text-ink-400">{t('home.message.mock.hey', '“Hey”')}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="text-ink-500">{t('home.message.mock.hey_count', '3 / 200')}</span>
|
||||
<span className="rounded-full bg-canvas-200 px-3 py-1 font-semibold text-ink-400">
|
||||
{t('home.message.mock.send', 'Send')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-primary-200 bg-primary-50 p-3">
|
||||
<p className="text-[13px] text-ink-700">
|
||||
{t(
|
||||
'home.message.mock.real',
|
||||
'“I saw you also read about stoicism — what got you into it?”',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-primary-700">
|
||||
{t('home.message.mock.real_count', '214 / 200')}
|
||||
</span>
|
||||
<span className="rounded-full bg-cta px-3 py-1 font-semibold text-white">
|
||||
{t('home.message.mock.send', 'Send')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the three claims between "Who's here" and the closing strip.
|
||||
*
|
||||
* Full width rather than a third of a card row: these replaced three feature tiles that said the same thing
|
||||
* three times ("not a corporate swipe app"), and what went in their place is specific enough to need a
|
||||
* sentence rather than a fragment. The icon chip and surface are the ones /about already uses, so the two
|
||||
* pages keep reading as one product.
|
||||
*/
|
||||
function ClaimBlock({
|
||||
icon: Icon,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
link,
|
||||
visual,
|
||||
}: {
|
||||
icon: IconType
|
||||
label: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
link?: {href: string; label: string; external?: boolean}
|
||||
// Optional right-hand column, same purpose as `ProseBlock`'s `visual` on /about: text capped at
|
||||
// `max-w-2xl` inside a full-bleed card otherwise leaves a wide empty strip on desktop.
|
||||
visual?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<div className={clsx(visual && 'lg:flex lg:items-start lg:justify-between lg:gap-10')}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-primary-600" strokeWidth={1.8} />
|
||||
</div>
|
||||
<p className={clsx(eyebrow, 'text-primary-700')}>{label}</p>
|
||||
</div>
|
||||
<h3 className="font-heading font-bold text-ink-900 text-[clamp(20px,2.4vw,28px)] leading-[1.2] tracking-tight mt-0 mb-3 max-w-3xl text-balance">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-base text-ink-600 leading-relaxed max-w-2xl">{children}</p>
|
||||
{link && (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noopener noreferrer' : undefined}
|
||||
className="mt-5 inline-flex w-fit items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{visual}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Where it stands" — the honest-stage block, and the only place on this page that states the membership.
|
||||
*
|
||||
* The logged-in app has told members "Compass is in its early growth phase" for a while
|
||||
* (`profiles-home.tsx`); until now the logged-out page implied a finished product, and the gap between what
|
||||
* a visitor expects and what they find in their own town is the cheapest churn to prevent. Saying it before
|
||||
* the signup is the whole point — after it, it is an excuse rather than honesty.
|
||||
*
|
||||
* The number arrives fused to what the reader should do about it. An earlier draft also named the largest
|
||||
* national group and the country count; both were cut as candour spent on nothing.
|
||||
*/
|
||||
function StageBlock() {
|
||||
const t = useT()
|
||||
const {data} = useAPIGetter('stats', {})
|
||||
|
||||
if (!data?.profiles || !data?.conversations) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'relative overflow-hidden rounded-2xl px-7 py-9 sm:px-12 sm:py-12',
|
||||
'bg-gradient-to-br from-primary-100 via-canvas-50 to-canvas-50',
|
||||
'dark:from-primary-900/25 dark:via-canvas-50 dark:to-canvas-50',
|
||||
'ring-1 ring-primary-200',
|
||||
'shadow-[0_1px_2px_rgb(44_36_22/0.04),0_16px_40px_-24px_rgb(44_36_22/0.35)]',
|
||||
'dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05)]',
|
||||
)}
|
||||
>
|
||||
<div className="lg:flex lg:items-start lg:justify-between lg:gap-12">
|
||||
<div className="min-w-0">
|
||||
<p className={clsx(eyebrow, 'text-primary-700 mb-4')}>
|
||||
{t('home.stage.label', 'Where we are')}
|
||||
</p>
|
||||
<h2 className="font-heading text-ink-900 text-[clamp(22px,2.8vw,34px)] leading-[1.2] tracking-tight mt-0 mb-4 max-w-3xl text-balance">
|
||||
{t('home.stage.title', 'Compass is early.')}
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-ink-700 leading-relaxed max-w-2xl">
|
||||
{t(
|
||||
'home.stage.text',
|
||||
'Compass has {members} members and is growing every month, which is enough to have started {conversations} conversations and not enough to guarantee somebody in your town. So do two things: save a search, and we’ll email you the day someone who fits joins. And if you know someone who belongs here, bring them — at this size, one person you introduce changes your odds more than any feature we could ship.',
|
||||
{
|
||||
members: data.profiles.toLocaleString(),
|
||||
conversations: data.conversations.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* The growth curve proves "growing every month" instead of just asserting it — same component
|
||||
/about uses under "Help Compass grow", so the two pages draw the claim identically rather than
|
||||
one showing a chart and the other a sentence. */}
|
||||
<div className="mt-8 lg:mt-0 lg:w-80 lg:flex-shrink-0">
|
||||
<MemberGrowth />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -92,7 +484,12 @@ function SocialProof({label}: {label: React.ReactNode}) {
|
||||
* is an odd way to treat the sentence that states what the project is *for*. It is now the only body
|
||||
* copy on the page set at display size, on the only tinted surface — the same role "One Mission" plays
|
||||
* on /about, and deliberately the same treatment so the two pages read as one product.
|
||||
*
|
||||
* Unused since the manifesto line moved to /about: it is a claim a first-time visitor cannot check, and it
|
||||
* occupied the slot where they most need a concrete reason. `StageBlock` took the slot and borrowed this
|
||||
* treatment. Kept because this is the right shape for any future line that earns display size here.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function QuoteBlock({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<div
|
||||
@@ -139,20 +536,25 @@ function OpenSourceStrip({
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-32 -right-20 w-[440px] h-[440px] rounded-full bg-primary-500/20 blur-3xl"
|
||||
/>
|
||||
<div className="relative flex items-center justify-between gap-8 flex-wrap">
|
||||
<div className="max-w-[520px]">
|
||||
{/* `lg:flex` rather than the old `flex ... flex-wrap`: at this card's actual width, four badges in
|
||||
one row plus the text block no longer fit side by side, so they wrapped onto their own line and
|
||||
left the whole right two-thirds of the strip empty above them. A fixed 2×2 button grid takes a
|
||||
third of the width instead of needing to fit in whatever's left of a single row, so it sits
|
||||
beside the text reliably rather than gambling on wrap. */}
|
||||
<div className="relative flex flex-col gap-8 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-[480px]">
|
||||
<h3 className="font-heading text-white text-[clamp(22px,2.4vw,32px)] font-bold leading-tight tracking-tight mb-3 text-balance">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-white/70 text-base leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<Row className="flex flex-wrap gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:flex-shrink-0">
|
||||
{badges.map((b) => (
|
||||
<Link
|
||||
href={b.url}
|
||||
key={b.label}
|
||||
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',
|
||||
'inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold border transition-all duration-200 ease-out',
|
||||
b.primary
|
||||
? 'bg-cta text-white border-cta hover:bg-cta-hover shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]'
|
||||
: 'bg-white/[0.06] text-white/70 border-white/10 hover:bg-white/[0.12] hover:text-white',
|
||||
@@ -162,7 +564,7 @@ function OpenSourceStrip({
|
||||
{b.label}
|
||||
</Link>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -228,32 +630,39 @@ export function LoggedOutHome() {
|
||||
|
||||
const typewriterRef = useTypewriter(typewriterWords)
|
||||
|
||||
const features: FeatureCardProps[] = [
|
||||
{
|
||||
icon: EyeIcon,
|
||||
title: t('home.feature1.title', 'Radically Transparent'),
|
||||
text: t(
|
||||
'home.feature1.text',
|
||||
'No algorithms. Every profile fully searchable. You decide who to discover — not a black box.',
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: AdjustmentsHorizontalIcon,
|
||||
title: t('home.feature2.title', 'Built for Depth'),
|
||||
text: t(
|
||||
'home.feature2.text',
|
||||
'Filter by values, interests, goals, and keywords — from "meditation" to "sustainable living." Surface connections that truly matter.',
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: t('home.feature3.title', 'Community Owned'),
|
||||
text: t(
|
||||
'home.feature3.text',
|
||||
'Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone.',
|
||||
),
|
||||
},
|
||||
]
|
||||
// The three feature tiles that used to sit here — "Radically Transparent", "Built for Depth", "Community
|
||||
// Owned" — all resolved to the same claim, *not a corporate swipe app*, so the page's best real estate
|
||||
// spent three slots on one negative. They are replaced by the `ClaimBlock`s below, each of which says
|
||||
// something a reader could not have guessed: what a profile holds, how the score works, what it takes to
|
||||
// send a first message. The old copy is kept here rather than deleted; it is still the shortest statement
|
||||
// of the positioning and may want a home elsewhere.
|
||||
//
|
||||
// const features: FeatureCardProps[] = [
|
||||
// {
|
||||
// icon: EyeIcon,
|
||||
// title: t('home.feature1.title', 'Radically Transparent'),
|
||||
// text: t(
|
||||
// 'home.feature1.text',
|
||||
// 'No algorithms. Every profile fully searchable. You decide who to discover — not a black box.',
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// icon: AdjustmentsHorizontalIcon,
|
||||
// title: t('home.feature2.title', 'Built for Depth'),
|
||||
// text: t(
|
||||
// 'home.feature2.text',
|
||||
// 'Filter by values, interests, goals, and keywords — from "meditation" to "sustainable living." Surface connections that truly matter.',
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// icon: UsersIcon,
|
||||
// title: t('home.feature3.title', 'Community Owned'),
|
||||
// text: t(
|
||||
// 'home.feature3.text',
|
||||
// 'Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone.',
|
||||
// ),
|
||||
// },
|
||||
// ]
|
||||
|
||||
const openSourceBadges = [
|
||||
{
|
||||
@@ -266,6 +675,7 @@ export function LoggedOutHome() {
|
||||
url: discordLink,
|
||||
icon: <FaDiscord className="w-4 h-4" />,
|
||||
},
|
||||
{label: t('home.strip.books', 'See the books'), url: '/financials'},
|
||||
{label: t('home.strip.join', 'Join Now →'), url: '/register', primary: true},
|
||||
]
|
||||
|
||||
@@ -300,8 +710,13 @@ export function LoggedOutHome() {
|
||||
className="pointer-events-none absolute inset-x-0 -top-16 -z-10 h-[420px] bg-[radial-gradient(ellipse_60%_60%_at_50%_30%,rgba(193,127,62,0.16),transparent_70%)]"
|
||||
/>
|
||||
|
||||
{/* A new key, not a reword of `home.eyebrow`: the third claim used to be "No matching
|
||||
algorithms", which is false — `common/src/profiles/compatibility-score.ts` is exactly that,
|
||||
and the "The only algorithm" block below now says so. Reusing the old key would have left
|
||||
the French and German files asserting the falsehood, since they resolve ahead of this
|
||||
fallback. */}
|
||||
<EyebrowBadge>
|
||||
{t('home.eyebrow', 'Free forever · Open source · No matching algorithms')}
|
||||
{t('home.eyebrow.v2', 'Free forever · Open source · No swiping')}
|
||||
</EyebrowBadge>
|
||||
|
||||
<h1 className="animate-fade-up text-[clamp(52px,8vw,96px)] lg:text-[clamp(44px,5.4vw,84px)] leading-none tracking-tight mb-2">
|
||||
@@ -329,15 +744,11 @@ export function LoggedOutHome() {
|
||||
className="animate-fade-up text-[clamp(17px,2.2vw,22px)] text-ink-600 leading-relaxed max-w-xl mb-10"
|
||||
style={{animationDelay: '160ms'}}
|
||||
>
|
||||
{t('home.subtitle', 'Find people who share your ')}
|
||||
<strong className="text-ink-900">{t('home.subtitle.values', 'values')}</strong>
|
||||
{', '}
|
||||
<strong className="text-ink-900">{t('home.subtitle.ideas', 'ideas')}</strong>
|
||||
{t('home.subtitle.and', ', and ')}
|
||||
<strong className="text-ink-900">
|
||||
{t('home.subtitle.intentions', 'intentions')}
|
||||
</strong>
|
||||
{t('home.subtitle.end', ' — not just your photos.')}
|
||||
{t(
|
||||
'home.subtitle.v2',
|
||||
'Read and search every profile — by values, interests, or demographics',
|
||||
)}
|
||||
{t('home.subtitle.v2.end', '. No algorithm decides for you who’s worth writing to.')}
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
@@ -367,15 +778,7 @@ export function LoggedOutHome() {
|
||||
|
||||
{/* Social proof */}
|
||||
<div className="animate-fade-up" style={{animationDelay: '320ms'}}>
|
||||
<SocialProof
|
||||
label={
|
||||
<>
|
||||
{t('home.proof.prefix', 'Joined by ')}
|
||||
<strong className="text-ink-900">{t('home.proof.count', '700+')}</strong>
|
||||
{t('home.proof.suffix', ' real people worldwide')}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SocialProof />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,40 +793,91 @@ export function LoggedOutHome() {
|
||||
each block rather than on the blocks themselves. The gradient divider that used to sit here
|
||||
is gone with it — `Section`'s rhythm separates them without a drawn rule. */}
|
||||
<div className="w-full max-w-6xl">
|
||||
{/* ── Features ── */}
|
||||
{/* ── Who's here ── */}
|
||||
<Section>
|
||||
<Reveal>
|
||||
<WhosHere />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── The three claims ──
|
||||
Stacked full-width rather than a three-column card row. Each of these is a specific fact a
|
||||
visitor could not have guessed, which takes a sentence; the tiles they replaced took a
|
||||
fragment each because all three were saying the same general thing. */}
|
||||
<Section>
|
||||
<p className={clsx(eyebrow, 'text-center text-primary-700 mb-3')}>
|
||||
{t('home.features.label', 'Why Compass')}
|
||||
</p>
|
||||
<h2 className="text-center text-[clamp(26px,3.4vw,40px)] text-ink-1000 tracking-tight mb-12 text-balance">
|
||||
{t('home.features.title', 'Built different. On purpose.')}
|
||||
{t('home.features.title.v3', 'Three things worth knowing before you sign up.')}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-4 sm:gap-5">
|
||||
{features.map((f, i) => (
|
||||
<Reveal key={f.title} delay={i * 70}>
|
||||
<FeatureCard {...f} />
|
||||
</Reveal>
|
||||
))}
|
||||
<div className="grid gap-4 sm:gap-5">
|
||||
<Reveal>
|
||||
<ClaimBlock
|
||||
icon={ViewColumnsIcon}
|
||||
label={t('home.profile.label', 'What you’re searching')}
|
||||
title={t(
|
||||
'home.profile.title',
|
||||
'A profile here takes twenty minutes to write, not two.',
|
||||
)}
|
||||
visual={<FieldChips />}
|
||||
>
|
||||
{t(
|
||||
'home.profile.text',
|
||||
'Not a photo and a one-liner. A bio in your own words, answers to prompts, the causes you care about, your politics, religion, diet, languages, education, personality type, and what kind of connection you’re after. More than twenty filters run across all of it — and free-text search reads the prose, so "meditation" finds the person who wrote about it in their bio, not just the one who tagged it.',
|
||||
)}
|
||||
</ClaimBlock>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={70}>
|
||||
<ClaimBlock
|
||||
icon={ScaleIcon}
|
||||
label={t('home.score.label', 'The only algorithm')}
|
||||
title={t(
|
||||
'home.score.title',
|
||||
'One algorithm. You set the weights, and you can read the source.',
|
||||
)}
|
||||
link={{
|
||||
href: `${githubRepo}/blob/main/common/src/profiles/compatibility-score.ts`,
|
||||
label: t('home.score.link', 'Read the implementation →'),
|
||||
external: true,
|
||||
}}
|
||||
visual={<ScoreDiagram />}
|
||||
>
|
||||
{t(
|
||||
'home.score.text',
|
||||
'For each compatibility question you give three things: your answer, the answers you’d accept from someone else, and how much it matters to you. The score is those three things compared — 130 lines of open code, no engagement optimization, no hidden ranking, no boost for paying. There is no paying.',
|
||||
)}
|
||||
</ClaimBlock>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<ClaimBlock
|
||||
icon={ChatBubbleLeftRightIcon}
|
||||
label={t('home.message.label', 'How people talk here')}
|
||||
title={t(
|
||||
'home.message.title',
|
||||
'The shortest message you can send is 200 characters.',
|
||||
)}
|
||||
visual={<MessageComposerMock />}
|
||||
>
|
||||
{t(
|
||||
'home.message.text',
|
||||
'No "hey". To open a conversation you have to say something real about the person you’re writing to — the composer won’t send until you do, and you need a verified email before your first message.',
|
||||
)}
|
||||
</ClaimBlock>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Quote ── */}
|
||||
{/* ── Where it stands ──
|
||||
This replaced the Linux / Wikipedia / Firefox quote, which has moved to /about under "Why we
|
||||
exist". It is a vision claim a stranger cannot verify, and it sat where a first-time visitor
|
||||
most needs one small concrete reason; about is the reference page and the right place for it.
|
||||
`QuoteBlock` is kept — it is the treatment that block wants if it ever returns. */}
|
||||
<Section>
|
||||
<Reveal>
|
||||
<QuoteBlock>
|
||||
{t('home.quote.prefix', 'Compass is to human connection what ')}
|
||||
<strong className="text-primary-700">{t('home.quote.linux', 'Linux')}</strong>
|
||||
{t('home.quote.linux_suffix', ' is to software, ')}
|
||||
<strong className="text-primary-700">
|
||||
{t('home.quote.wikipedia', 'Wikipedia')}
|
||||
</strong>
|
||||
{t('home.quote.wikipedia_suffix', ' is to knowledge, and ')}
|
||||
<strong className="text-primary-700">{t('home.quote.firefox', 'Firefox')}</strong>
|
||||
{t('home.quote.end', ' is to browsing — a public digital good designed to ')}
|
||||
<strong className="text-primary-700">
|
||||
{t('home.quote.mission', 'serve people, not profit.')}
|
||||
</strong>
|
||||
</QuoteBlock>
|
||||
<StageBlock />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
@@ -432,14 +886,34 @@ export function LoggedOutHome() {
|
||||
<Reveal>
|
||||
<OpenSourceStrip
|
||||
title={t('home.strip.title', 'Open Source & Free Forever')}
|
||||
// A new key, and the numbers come from `FINANCIALS` so this line and the about page can
|
||||
// never quote different figures. "Built transparently by the community" was a claim;
|
||||
// the deficit is the same claim with the receipt attached, and it is one click from
|
||||
// /financials.
|
||||
description={t(
|
||||
'home.strip.description',
|
||||
'No venture capital. No ads. No subscription fees. Built transparently by the community, for the community.',
|
||||
'home.strip.description.v2',
|
||||
'No venture capital, no ads, no subscriptions, and nobody on payroll. Compass has cost ${spent} to run since launch; members have donated ${donated}. One person covers the difference. Every expense is published.',
|
||||
{spent: FINANCIALS.spent, donated: FINANCIALS.donated},
|
||||
)}
|
||||
badges={openSourceBadges}
|
||||
/>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── Press ──
|
||||
One line, and the only outbound link on the page that isn't an action: five real items sit in
|
||||
web/pages/press.tsx, including a radio interview, and neither marketing page linked to any of
|
||||
them. Named outlets rather than logos — logos would need permission and would outweigh what
|
||||
is, honestly, local coverage of an early project. */}
|
||||
<p className="pb-4 text-center text-sm text-ink-500">
|
||||
{t('home.press.text', 'Covered by RCF, La DH, L’Avenir and Matélé')}{' '}
|
||||
<Link
|
||||
href="/press"
|
||||
className="font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{t('home.press.link', 'Read the coverage →')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Dialog, Transition} from '@headlessui/react'
|
||||
import {XMarkIcon} from '@heroicons/react/24/outline'
|
||||
import {ChevronLeftIcon, ChevronRightIcon, XMarkIcon} from '@heroicons/react/24/outline'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
Fragment,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react'
|
||||
import {useEvent} from 'web/hooks/use-event'
|
||||
import {isVideo} from 'web/lib/firebase/storage'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
const MIN_SCALE = 1
|
||||
const MAX_SCALE = 4
|
||||
@@ -21,10 +23,64 @@ const ZOOM_ANIMATION_MS = 200
|
||||
|
||||
type Point = {x: number; y: number}
|
||||
|
||||
const SWIPE_MIN_DIST_PX = 50
|
||||
const SWIPE_MAX_OFF_AXIS_RATIO = 0.8
|
||||
|
||||
// Enlarges an image or video to the largest size that fits within 90% of the
|
||||
// viewport in both dimensions, without cropping (aspect ratio preserved).
|
||||
export function MediaModal(props: {url: string; open: boolean; setOpen: (open: boolean) => void}) {
|
||||
const {url, open, setOpen} = props
|
||||
//
|
||||
// Pass `url` for a single item, or `urls` + `index` to browse a gallery in place: arrow keys, the
|
||||
// on-screen chevrons, and a horizontal swipe all move between items. Swiping is ignored while the
|
||||
// image is zoomed in, where the same gesture pans.
|
||||
export function MediaModal(props: {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
/** Single-item form. */
|
||||
url?: string
|
||||
/** Gallery form — supply every item plus the one currently shown. */
|
||||
urls?: string[]
|
||||
index?: number
|
||||
setIndex?: (index: number) => void
|
||||
/** Caption rendered under the media, keyed by url. */
|
||||
descriptions?: Record<string, string>
|
||||
}) {
|
||||
const {open, setOpen, urls, index, setIndex, descriptions} = props
|
||||
const t = useT()
|
||||
|
||||
const items = urls?.length ? urls : props.url ? [props.url] : []
|
||||
const [internalIndex, setInternalIndex] = useState(index ?? 0)
|
||||
|
||||
// The caller may or may not own the index; follow it when it does, and keep our own when it doesn't.
|
||||
useEffect(() => {
|
||||
if (index != null) setInternalIndex(index)
|
||||
}, [index])
|
||||
|
||||
const current = items[clamp(internalIndex, 0, Math.max(0, items.length - 1))]
|
||||
const hasMultiple = items.length > 1
|
||||
const caption = current ? descriptions?.[current] : undefined
|
||||
|
||||
const go = useEvent((delta: number) => {
|
||||
if (!hasMultiple) return
|
||||
const next = (internalIndex + delta + items.length) % items.length
|
||||
setInternalIndex(next)
|
||||
setIndex?.(next)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !hasMultiple) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') go(-1)
|
||||
else if (e.key === 'ArrowRight') go(1)
|
||||
else return
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, hasMultiple, go])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const url = current
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
@@ -61,17 +117,55 @@ export function MediaModal(props: {url: string; open: boolean; setOpen: (open: b
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex h-full w-full items-center justify-center">
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="h-auto max-h-full w-auto max-w-full rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ZoomableImage url={url} active={open} />
|
||||
{/* A column, not a centred box: the caption needs its own row under the media, and the
|
||||
media area has to be allowed to shrink (`min-h-0`) so a long caption cannot push it
|
||||
past the bottom of the screen. */}
|
||||
<Dialog.Panel className="relative flex h-full w-full flex-col items-center justify-center gap-4 px-4 pb-6 pt-16 sm:px-16">
|
||||
{/* Inside the panel, not beside it: Headless UI closes the dialog on any click that
|
||||
lands outside the panel's subtree, which is what made paging dismiss the viewer.
|
||||
They are `fixed`, so they still sit against the viewport edges. */}
|
||||
{hasMultiple && (
|
||||
<>
|
||||
<NavButton
|
||||
side="left"
|
||||
label={t('media.previous', 'Previous')}
|
||||
onClick={() => go(-1)}
|
||||
/>
|
||||
<NavButton side="right" label={t('media.next', 'Next')} onClick={() => go(1)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="h-auto max-h-full w-auto max-w-full rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
url={url}
|
||||
active={open}
|
||||
onSwipe={hasMultiple ? (direction) => go(direction) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(caption || hasMultiple) && (
|
||||
<div className="flex w-full shrink-0 flex-col items-center gap-1.5">
|
||||
{caption && (
|
||||
<p className="text-ink-800 max-w-2xl whitespace-pre-wrap text-center text-sm">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<div className="text-ink-500 text-xs tabular-nums">
|
||||
{internalIndex + 1} / {items.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
@@ -81,6 +175,25 @@ export function MediaModal(props: {url: string; open: boolean; setOpen: (open: b
|
||||
)
|
||||
}
|
||||
|
||||
function NavButton(props: {side: 'left' | 'right'; label: string; onClick: () => void}) {
|
||||
const {side, label, onClick} = props
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={`text-ink-700 hover:text-primary-400 focus:text-primary-400 bg-canvas-50/70 fixed top-1/2 z-10 -translate-y-1/2 rounded-full p-2 outline-none backdrop-blur transition-colors ${
|
||||
side === 'left' ? 'left-2 sm:left-4' : 'right-2 sm:right-4'
|
||||
}`}
|
||||
>
|
||||
{side === 'left' ? (
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, n))
|
||||
}
|
||||
@@ -110,8 +223,13 @@ function anchoredTranslate(
|
||||
// Pinch-to-zoom (mobile), scroll-to-zoom + drag-to-pan (desktop), and
|
||||
// double-tap/double-click-to-zoom, all anchored under the cursor/fingers —
|
||||
// the same interactions as WhatsApp's photo viewer.
|
||||
function ZoomableImage(props: {url: string; active: boolean}) {
|
||||
const {url, active} = props
|
||||
function ZoomableImage(props: {
|
||||
url: string
|
||||
active: boolean
|
||||
/** Called with -1 (previous) or 1 (next) on a horizontal swipe, when not zoomed in. */
|
||||
onSwipe?: (direction: number) => void
|
||||
}) {
|
||||
const {url, active, onSwipe} = props
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imageRef = useRef<HTMLImageElement>(null)
|
||||
const [scale, setScale] = useState(1)
|
||||
@@ -221,6 +339,19 @@ function ZoomableImage(props: {url: string; active: boolean}) {
|
||||
}
|
||||
|
||||
const endPointer = (e: ReactPointerEvent) => {
|
||||
// Only a lone pointer at rest scale is a swipe — at scale > 1 the same drag is a pan, and with two
|
||||
// pointers down it is a pinch.
|
||||
if (onSwipe && scale === 1 && pointers.current.size === 1 && dragStart.current) {
|
||||
const dx = e.clientX - dragStart.current.pointer.x
|
||||
const dy = e.clientY - dragStart.current.pointer.y
|
||||
if (
|
||||
Math.abs(dx) > SWIPE_MIN_DIST_PX &&
|
||||
Math.abs(dy) < Math.abs(dx) * SWIPE_MAX_OFF_AXIS_RATIO
|
||||
) {
|
||||
onSwipe(dx < 0 ? 1 : -1)
|
||||
}
|
||||
}
|
||||
|
||||
pointers.current.delete(e.pointerId)
|
||||
if (pointers.current.size < 2) pinchDistance.current = null
|
||||
|
||||
|
||||
@@ -190,7 +190,10 @@ export const SendMessageButton = (props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* `w-fit`: Tooltip's reference element is a plain span, so in a flex column it stretched to the
|
||||
full width and armed the tooltip across all the empty space beside the button. */}
|
||||
<Tooltip
|
||||
className="w-fit"
|
||||
text={
|
||||
tooltipText ||
|
||||
(putAccent ? t('send_message.', 'Follow Up') : t('send_message.button_label', 'Message'))
|
||||
@@ -200,7 +203,10 @@ export const SendMessageButton = (props: {
|
||||
{text ? (
|
||||
<Button
|
||||
className={clsx('h-fit gap-1', disabled && 'opacity-50 cursor-not-allowed')}
|
||||
color={'primary'}
|
||||
// `cta`, not `primary`: `primary` is the *tinted secondary* pill (see button.tsx), which
|
||||
// in the dark ramp is brown-on-brown and read as disabled on the one control that is the
|
||||
// whole point of the Connect section.
|
||||
color={'cta'}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
onPointerDown?.()
|
||||
|
||||
@@ -91,7 +91,7 @@ export const ViewProfileCardButton = (props: {
|
||||
<rect x="2.5" y="5" width="15" height="11" rx="2" />
|
||||
<path d="M2.5 9h15" />
|
||||
</svg>
|
||||
{t('share_profile.view_profile_card', 'Profile Card')}
|
||||
{t('share_profile.view_profile_card', 'Card')}
|
||||
</button>
|
||||
<Modal open={open} setOpen={setOpen} size={'lg'} className={''}>
|
||||
<Col className="gap-4 bg-canvas-100/75 rounded-2xl justify-center">
|
||||
|
||||
88
web/components/press/press-logos.tsx
Normal file
88
web/components/press/press-logos.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
/**
|
||||
* The outlets that have covered Compass, and the one place their logos are described.
|
||||
*
|
||||
* Extracted out of `pages/about.tsx` because `/press` needs the same four marks — once beside the
|
||||
* article that each one published, and once as the strip at the top of the page. Two copies of the
|
||||
* per-logo height tuning is exactly the kind of thing that drifts.
|
||||
*
|
||||
* All four logos show in their real, native color at all times — no grayscale/invert treatment. The tile
|
||||
* uses the page's own surface tokens (`bg-canvas-100`) rather than a hardcoded white, so it sits quietly on
|
||||
* the card in either theme instead of punching a bright rectangle into a dark page, and each logo's own
|
||||
* colors (DH's navy and red, RCF's orange, L’Avenir and Matélé's green) provide their own contrast against
|
||||
* that neutral tile without needing to be forced into a silhouette.
|
||||
*
|
||||
* All four files are downloaded from each outlet's own official site (not a logo-aggregator or a Wikipedia
|
||||
* fair-use scan) and live in `web/public/images/press/`.
|
||||
*/
|
||||
|
||||
export type OutletId = 'rcf' | 'dh' | 'lavenir' | 'matele'
|
||||
|
||||
export type Outlet = {
|
||||
name: string
|
||||
src: string
|
||||
/** Per-logo height. The marks have wildly different aspect ratios — L’Avenir is a wordmark, DH a
|
||||
* square icon — so a single height would make one of them a stamp and another a hairline. */
|
||||
className: string
|
||||
}
|
||||
|
||||
export const OUTLETS: Record<OutletId, Outlet> = {
|
||||
rcf: {name: 'RCF', src: '/images/press/rcf-logo.png', className: 'h-9'},
|
||||
// Hand-vectorized recreation of DH's square icon mark (navy + red tiles) — see the comment in the
|
||||
// `<svg>` file itself for why it's a recreation rather than a traced official asset.
|
||||
dh: {name: 'La DH', src: '/images/press/la-dh-logo.svg', className: 'h-12'},
|
||||
lavenir: {name: 'L’Avenir', src: '/images/press/lavenir-logo.svg', className: 'h-5'},
|
||||
matele: {name: 'Matélé', src: '/images/press/matele-logo.png', className: 'h-10'},
|
||||
}
|
||||
|
||||
/** Oldest outlet first is meaningless here; this is the order the strip reads best in. */
|
||||
export const OUTLET_ORDER: OutletId[] = ['rcf', 'dh', 'lavenir', 'matele']
|
||||
|
||||
/**
|
||||
* One logo on its neutral tile. `size` exists because the same mark appears as the lead visual of a
|
||||
* featured article and as a small identifier on the rows below it, and scaling the tile without scaling
|
||||
* the logo inside it leaves the mark marooned in whitespace.
|
||||
*/
|
||||
export function OutletLogo({
|
||||
outlet,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
outlet: OutletId
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
}) {
|
||||
const {name, src, className: logoClass} = OUTLETS[outlet]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-shrink-0 items-center justify-center rounded-xl bg-canvas-100 ring-1 ring-canvas-200',
|
||||
size === 'md' ? 'h-16 w-32 p-4' : 'h-12 w-24 p-3',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={clsx(logoClass, size === 'sm' && 'scale-75', 'w-auto object-contain')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The four marks in a row — the "as seen in" strip on `/about` and at the top of `/press`. */
|
||||
export function PressLogos({className}: {className?: string}) {
|
||||
return (
|
||||
<div className={clsx('flex flex-wrap items-center gap-4', className)}>
|
||||
{OUTLET_ORDER.map((id) => (
|
||||
<OutletLogo
|
||||
key={id}
|
||||
outlet={id}
|
||||
className="transition-all duration-200 hover:shadow-md hover:ring-primary-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import {ClockIcon} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
INVERTED_CANNABIS_CHOICES,
|
||||
@@ -24,23 +23,13 @@ import {formatHeight, MeasurementSystem} from 'common/measurement-utils'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {Socials} from 'common/socials'
|
||||
import {UserActivity} from 'common/user'
|
||||
import {Accessibility, Brain, Home, Languages, Leaf, Salad} from 'lucide-react'
|
||||
import React, {ReactNode} from 'react'
|
||||
import {BiSolidDrink} from 'react-icons/bi'
|
||||
import {FaHeart} from 'react-icons/fa'
|
||||
import {FaChild} from 'react-icons/fa6'
|
||||
import {HiOutlineGlobe} from 'react-icons/hi'
|
||||
import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap} from 'react-icons/lu'
|
||||
import {MdNoDrinks} from 'react-icons/md'
|
||||
import {PiGenderIntersexBold, PiHandsPrayingBold, PiMagnifyingGlassBold} from 'react-icons/pi'
|
||||
import {RiScales3Line} from 'react-icons/ri'
|
||||
import {TbBulb, TbCheck, TbHearts, TbMoodSad, TbUsers} from 'react-icons/tb'
|
||||
import {TbBulb, TbCheck, TbMoodSad, TbUsers} from 'react-icons/tb'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {CustomLink} from 'web/components/links'
|
||||
import {UserHandles} from 'web/components/user/user-handles'
|
||||
import {useChoicesContext} from 'web/hooks/use-choices'
|
||||
import {CustomMushroom} from 'web/lib/icons/mushroom'
|
||||
import {useLocale, useT} from 'web/lib/locale'
|
||||
import {getSeekingConnectionText} from 'web/lib/profile/seeking'
|
||||
import {convertRace} from 'web/lib/util/convert-types'
|
||||
@@ -51,27 +40,47 @@ export default function ProfileAbout(props: {
|
||||
profile: Profile
|
||||
userActivity?: UserActivity
|
||||
isCurrentUser: boolean
|
||||
/** Set when the hero already shows connection goals, so the same rows aren't repeated here. */
|
||||
omitConnectionGoals?: boolean
|
||||
}) {
|
||||
const {profile, userActivity, isCurrentUser} = props
|
||||
const {profile, userActivity, isCurrentUser, omitConnectionGoals} = props
|
||||
|
||||
return (
|
||||
<Col className={clsx('relative gap-3 overflow-hidden rounded')}>
|
||||
<SeekingAndRelationship profile={profile} />
|
||||
<GenderIdentity profile={profile} />
|
||||
<Orientation profile={profile} />
|
||||
<Children profile={profile} />
|
||||
<Education profile={profile} />
|
||||
<Col
|
||||
className={clsx(
|
||||
'divide-canvas-200 relative divide-y',
|
||||
// The section supplies the space under its rule and the gap to the next block, so the first
|
||||
// and last rows shed their own padding rather than doubling it.
|
||||
'[&>div:first-child]:pt-0 [&>div:last-child]:pb-0',
|
||||
)}
|
||||
>
|
||||
{/* Ordered by what decides whether someone reaches out, not by field category:
|
||||
1. Goals — whether the rest of this is even relevant.
|
||||
2. What they do — work, education, children.
|
||||
3. What they believe — politics, religion, diet. Diet sits with the values group rather than
|
||||
with the substances below it: on this platform vegan and vegetarian are far more often an
|
||||
ethical position than a dietary habit, and they read as one.
|
||||
4. Substances — real compatibility filters, but they narrow rather than describe.
|
||||
5. Languages — practical.
|
||||
6. Identity nuance — gender and orientation only render when the member wrote something of
|
||||
their own about them, so they are elaboration, not a demographic checkbox to scan.
|
||||
7. Background, then activity metadata. */}
|
||||
{!omitConnectionGoals && <SeekingAndRelationship profile={profile} />}
|
||||
<OccupationAndWork profile={profile} />
|
||||
<Education profile={profile} />
|
||||
<Children profile={profile} />
|
||||
<Politics profile={profile} />
|
||||
<Religion profile={profile} />
|
||||
<Ethnicity profile={profile} />
|
||||
<RaisedIn profile={profile} />
|
||||
<Diet profile={profile} />
|
||||
<Smoker profile={profile} />
|
||||
<Drinks profile={profile} />
|
||||
<Cannabis profile={profile} />
|
||||
<Psychedelics profile={profile} />
|
||||
<Diet profile={profile} />
|
||||
<LanguagesSection profile={profile} />
|
||||
<GenderIdentity profile={profile} />
|
||||
<Orientation profile={profile} />
|
||||
<Ethnicity profile={profile} />
|
||||
<RaisedIn profile={profile} />
|
||||
{!isCurrentUser && (
|
||||
<>
|
||||
<LastOnline lastOnlineTime={userActivity?.last_online_time} />
|
||||
@@ -81,67 +90,23 @@ export default function ProfileAbout(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
height: '1px',
|
||||
background: 'rgb(var(--color-canvas-200))',
|
||||
margin: '12px 0',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel(props: {compact?: boolean; children: ReactNode}) {
|
||||
const {compact, children} = props
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: compact ? '11px' : '12px',
|
||||
fontWeight: '500',
|
||||
color: 'rgb(var(--color-ink-300))',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.07em',
|
||||
marginBottom: compact ? '1px' : '8px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip(props: {variant?: 'default' | 'primary' | 'muted'; children: ReactNode}) {
|
||||
const {variant = 'default', children} = props
|
||||
if (variant === 'muted') {
|
||||
return (
|
||||
<span
|
||||
className="border-canvas-200 bg-canvas-100 text-ink-500"
|
||||
style={{
|
||||
padding: '4px 11px',
|
||||
borderRadius: '100px',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const colorClass =
|
||||
variant === 'primary'
|
||||
? 'border-primary-200 text-primary-700 bg-primary-50'
|
||||
: 'border-canvas-300 text-primary-700 bg-canvas-200'
|
||||
/**
|
||||
* A small qualifier attached to a value it cannot be read without — a romantic style under a
|
||||
* connection goal, an intention under a substance answer. Deliberately quiet: it is a footnote to the
|
||||
* line above it, not a peer of it.
|
||||
*
|
||||
* The louder filled variants this used to offer are gone with the interest and cause pills. Sets of
|
||||
* tags are middot-separated text now (see `TagList`); a pill in this rail means something clickable.
|
||||
*/
|
||||
function Chip(props: {children: ReactNode}) {
|
||||
const {children} = props
|
||||
return (
|
||||
<span
|
||||
className={colorClass}
|
||||
className="border-canvas-200 bg-canvas-100 text-ink-500"
|
||||
style={{
|
||||
padding: '5px 13px',
|
||||
padding: '4px 11px',
|
||||
borderRadius: '100px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.01em',
|
||||
fontSize: '12.5px',
|
||||
borderWidth: '1px',
|
||||
}}
|
||||
>
|
||||
@@ -150,47 +115,60 @@ function Chip(props: {variant?: 'default' | 'primary' | 'muted'; children: React
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One attribute as a label/value pair on a shared baseline, separated from its neighbours by a rule
|
||||
* that spans the full width.
|
||||
*
|
||||
* The 32px bordered icon tiles this used to lead with are gone. Fifteen of them stacked in a narrow
|
||||
* rail formed a column of decoration louder than the values beside it, and none of them disambiguated
|
||||
* anything — the label already says "Education" in words.
|
||||
*
|
||||
* The label column widens on a wide rail and narrows again at `3xl`, where the rail splits into two
|
||||
* sub-columns and each one is back to roughly its original width.
|
||||
*/
|
||||
function AboutRow(props: {
|
||||
icon: ReactNode
|
||||
title: ReactNode
|
||||
text?: ReactNode
|
||||
details?: ReactNode
|
||||
children?: ReactNode
|
||||
testId?: string
|
||||
divider?: boolean
|
||||
}) {
|
||||
const {icon, title, text, details, children, testId, divider = true} = props
|
||||
const {title, text, details, children, testId} = props
|
||||
return (
|
||||
<>
|
||||
<Row className="items-start gap-2.5" data-testid={testId}>
|
||||
<div
|
||||
className="bg-canvas-100 border-canvas-200 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border text-ink-500"
|
||||
style={{width: '32px', height: '32px', marginTop: '1px'}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<Col className={'w-full'}>
|
||||
<SectionLabel compact>{title}</SectionLabel>
|
||||
{text != null && text !== '' && (
|
||||
<div style={{fontSize: '14px', color: 'rgb(var(--color-ink-900))'}}>{text}</div>
|
||||
)}
|
||||
{details && (
|
||||
<div className={'text-ink-500'} style={{fontSize: '12.5px', marginTop: '2px'}}>
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</Col>
|
||||
</Row>
|
||||
{divider && <Divider />}
|
||||
</>
|
||||
<div
|
||||
className="3xl:grid-cols-[84px_minmax(0,1fr)] 3xl:gap-x-4 2xl:grid-cols-[120px_minmax(0,1fr)] 2xl:gap-x-6 grid grid-cols-[84px_minmax(0,1fr)] gap-x-4 py-4"
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="text-ink-500" style={{fontSize: '14px', lineHeight: '1.5'}}>
|
||||
{title}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{text != null && text !== '' && (
|
||||
<div className="text-primary-900" style={{fontSize: '16px', lineHeight: '1.4'}}>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
{details && (
|
||||
<div
|
||||
className="text-ink-500"
|
||||
style={{fontSize: '14px', lineHeight: '1.55', marginTop: '4px'}}
|
||||
>
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SeekingAndRelationship(props: {profile: Profile}) {
|
||||
/**
|
||||
* Shared by the Details rail and the hero callout, so the two can't drift apart.
|
||||
* Returns null when the member has nothing to show for connection goals.
|
||||
*/
|
||||
export function useConnectionGoals(profile: Profile) {
|
||||
const t = useT()
|
||||
const {profile} = props
|
||||
const seekingText = getSeekingText(profile, t)
|
||||
const seekingText = getCompactSeekingText(profile, t)
|
||||
const relationship_status = profile.relationship_status ?? []
|
||||
const relationshipTypes = profile.pref_relation_styles ?? []
|
||||
|
||||
@@ -213,12 +191,69 @@ function SeekingAndRelationship(props: {profile: Profile}) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return {seekingText, relationshipText, romanticStyles}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection goals for the hero — dealbreaker-class information that decides whether the rest of the
|
||||
* profile is even relevant, so it is lifted out of the Details list.
|
||||
*
|
||||
* Presented as labelled facts under a hairline rule rather than a bordered callout. A tinted box says
|
||||
* "notice this", which is the wrong instruction for the last thing in the hero: by the time the eye
|
||||
* arrives it is already reading, and a box makes it stop and re-anchor.
|
||||
*/
|
||||
export function ProfileConnectionGoals(props: {profile: Profile}) {
|
||||
const t = useT()
|
||||
const goals = useConnectionGoals(props.profile)
|
||||
|
||||
if (!goals) return null
|
||||
|
||||
const {seekingText, relationshipText, romanticStyles} = goals
|
||||
|
||||
const status = [relationshipText, ...(romanticStyles ?? [])].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-canvas-300 mt-2 flex max-w-2xl flex-wrap gap-x-16 gap-y-6 border-t pt-6"
|
||||
data-testid="profile-connection-goals"
|
||||
>
|
||||
<HeroFact label={t('profile.looking_for', 'Looking for')}>{seekingText}</HeroFact>
|
||||
{status && <HeroFact label={t('profile.status', 'Status')}>{status}</HeroFact>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeroFact(props: {label: string; children: ReactNode}) {
|
||||
const {label, children} = props
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-ink-400 font-dm-sans uppercase"
|
||||
style={{fontSize: '10px', letterSpacing: '0.18em', marginBottom: '7px'}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-ink-900" style={{fontSize: '15.5px'}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SeekingAndRelationship(props: {profile: Profile}) {
|
||||
const t = useT()
|
||||
const {profile} = props
|
||||
const goals = useConnectionGoals(profile)
|
||||
|
||||
if (!goals) return null
|
||||
|
||||
const {seekingText, relationshipText, romanticStyles} = goals
|
||||
|
||||
// const key = relationship_status[0] as keyof typeof RELATIONSHIP_ICONS
|
||||
// const icon = RELATIONSHIP_ICONS[key] ?? FaHeart
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<PiMagnifyingGlassBold className="h-5 w-5" />}
|
||||
title={t('profile.connection_goals', 'Connection Goals')}
|
||||
text={seekingText}
|
||||
details={relationshipText}
|
||||
@@ -226,9 +261,7 @@ function SeekingAndRelationship(props: {profile: Profile}) {
|
||||
{romanticStyles && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{romanticStyles!.map((r) => (
|
||||
<Chip key={r} variant="muted">
|
||||
{r}
|
||||
</Chip>
|
||||
<Chip key={r}>{r}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -276,6 +309,59 @@ export function getSeekingText(profile: Profile, t: any, short?: boolean | undef
|
||||
return `${getSeekingConnectionText(profile, t, short)} ${seekingGenderText} ${ageRangeText}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same facts as `getSeekingText`, as three middot-separated fragments:
|
||||
* `Relationship · men · 30-40`.
|
||||
*
|
||||
* The sentence form ("Relationship with men between 30 - 40 years old") reads fine in prose but poorly
|
||||
* as a value in a labelled column, where the label already supplies the grammar and the connecting
|
||||
* words are just length.
|
||||
*/
|
||||
export function getCompactSeekingText(profile: Profile, t: any) {
|
||||
const prefGender = profile.pref_gender
|
||||
const min = profile.pref_age_min
|
||||
const max = profile.pref_age_max
|
||||
|
||||
const connection = capitalizeFirst(getSeekingConnectionText(profile, t, true))
|
||||
|
||||
// An unconstrained preference is dropped rather than spelled out. "People" and "any age" restate the
|
||||
// absence of a filter, which every reader already assumes — printing them spends two of the three
|
||||
// slots on nothing. Capitalized when present so each fragment reads as its own fact rather than as
|
||||
// one sentence broken by dots.
|
||||
const anyGender =
|
||||
!prefGender?.length || (prefGender.includes('male') && prefGender.includes('female'))
|
||||
|
||||
const genderText = anyGender
|
||||
? null
|
||||
: prefGender
|
||||
.map((gender) =>
|
||||
capitalizeFirst(
|
||||
t(`profile.gender.plural.${gender}`, convertGenderPlural(gender as Gender)),
|
||||
),
|
||||
)
|
||||
.join(', ')
|
||||
|
||||
const noMin = (min ?? MIN_INT) <= 18
|
||||
const noMax = (max ?? MAX_INT) >= 99
|
||||
|
||||
const ageText =
|
||||
noMin && noMax
|
||||
? null
|
||||
: min == max
|
||||
? `${min}`
|
||||
: noMax
|
||||
? t('profile.age_min_compact', '{min}+', {min})
|
||||
: noMin
|
||||
? t('profile.age_max_compact', 'under {max}', {max})
|
||||
: t('profile.age_range_compact', '{min}-{max}', {min, max})
|
||||
|
||||
return [connection, genderText, ageText].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function capitalizeFirst(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
function Education(props: {profile: Profile}) {
|
||||
const t = useT()
|
||||
const {profile} = props
|
||||
@@ -295,13 +381,7 @@ function Education(props: {profile: Profile}) {
|
||||
}
|
||||
if (text.length === 0) return null
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<LuGraduationCap className="h-5 w-5" />}
|
||||
title={t('profile.education', 'Education')}
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
return <AboutRow title={t('profile.education', 'Education')} text={text} />
|
||||
}
|
||||
|
||||
function OccupationAndWork(props: {profile: Profile}) {
|
||||
@@ -327,14 +407,7 @@ function OccupationAndWork(props: {profile: Profile}) {
|
||||
|
||||
const workText = workAreas?.join(' · ')
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<LuBriefcase className="h-5 w-5" />}
|
||||
title={t('profile.work', 'Work')}
|
||||
text={occupationText}
|
||||
details={workText}
|
||||
/>
|
||||
)
|
||||
return <AboutRow title={t('profile.work', 'Work')} text={occupationText} details={workText} />
|
||||
}
|
||||
|
||||
function Politics(props: {profile: Profile}) {
|
||||
@@ -347,7 +420,6 @@ function Politics(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<RiScales3Line className="h-5 w-5" />}
|
||||
title={t('profile.politics', 'Politics')}
|
||||
text={formatChoiceList(politicalBeliefs, 'political', INVERTED_POLITICAL_CHOICES, t)}
|
||||
details={politicalDetails ? `"${politicalDetails}"` : undefined}
|
||||
@@ -365,7 +437,6 @@ function Religion(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<PiHandsPrayingBold className="h-5 w-5" />}
|
||||
title={t('profile.religion', 'Religion')}
|
||||
text={formatChoiceList(religion, 'religion', INVERTED_RELIGION_CHOICES, t)}
|
||||
details={religiousBeliefs ? `"${religiousBeliefs}"` : undefined}
|
||||
@@ -386,7 +457,6 @@ function GenderIdentity(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<PiGenderIntersexBold className="h-5 w-5" />}
|
||||
title={t('profile.gender_identity', 'Gender Identity')}
|
||||
text={text}
|
||||
details={`"${genderDetails}"`}
|
||||
@@ -409,7 +479,6 @@ function Orientation(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<TbHearts className="h-5 w-5" />}
|
||||
title={t('profile.orientation', 'Orientation')}
|
||||
text={orientationText}
|
||||
details={orientationDetails ? `"${orientationDetails}"` : undefined}
|
||||
@@ -432,7 +501,6 @@ function Neurotype(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<Brain className="h-5 w-5" />}
|
||||
title={t('profile.neurotype', 'Neurotype')}
|
||||
text={neurotypeText}
|
||||
details={neurotypeDetails ? `"${neurotypeDetails}"` : undefined}
|
||||
@@ -449,13 +517,7 @@ function Ethnicity(props: {profile: Profile}) {
|
||||
|
||||
const text = ethnicity.map((r: any) => t(`profile.race.${r}`, convertRace(r))).join(', ')
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<HiOutlineGlobe className="h-5 w-5" />}
|
||||
title={t('profile.ethnicity', 'Ethnicity')}
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
return <AboutRow title={t('profile.ethnicity', 'Ethnicity')} text={text} />
|
||||
}
|
||||
|
||||
function Smoker(props: {profile: Profile}) {
|
||||
@@ -464,13 +526,7 @@ function Smoker(props: {profile: Profile}) {
|
||||
const isSmoker = profile.is_smoker
|
||||
if (isSmoker == null) return null
|
||||
const text = isSmoker ? t('profile.smokes', 'Smokes') : t('profile.doesnt_smoke', "Doesn't smoke")
|
||||
const icon = isSmoker ? (
|
||||
<LuCigarette className="h-5 w-5" />
|
||||
) : (
|
||||
<LuCigaretteOff className="h-5 w-5" />
|
||||
)
|
||||
|
||||
return <AboutRow icon={icon} title={t('profile.smoking', 'Smoking')} text={text} />
|
||||
return <AboutRow title={t('profile.smoking', 'Smoking')} text={text} />
|
||||
}
|
||||
|
||||
function Drinks(props: {profile: Profile}) {
|
||||
@@ -487,10 +543,7 @@ function Drinks(props: {profile: Profile}) {
|
||||
: t('profile.drinks_many', '{count} drinks per month', {
|
||||
count: drinksPerMonth,
|
||||
})
|
||||
const icon =
|
||||
drinksPerMonth === 0 ? <MdNoDrinks className="h-5 w-5" /> : <BiSolidDrink className="h-5 w-5" />
|
||||
|
||||
return <AboutRow icon={icon} title={t('profile.alcohol', 'Alcohol')} text={text} />
|
||||
return <AboutRow title={t('profile.alcohol', 'Alcohol')} text={text} />
|
||||
}
|
||||
|
||||
function Diet(props: {profile: Profile}) {
|
||||
@@ -502,7 +555,6 @@ function Diet(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<Salad className="h-5 w-5" />}
|
||||
title={t('profile.diet', 'Diet')}
|
||||
text={formatChoiceList(diet, 'diet', INVERTED_DIET_CHOICES, t)}
|
||||
/>
|
||||
@@ -518,7 +570,6 @@ function LanguagesSection(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<Languages className="h-5 w-5" />}
|
||||
title={t('profile.languages', 'Languages')}
|
||||
text={formatChoiceList(languages, 'language', INVERTED_LANGUAGE_CHOICES, t)}
|
||||
/>
|
||||
@@ -538,15 +589,11 @@ function Cannabis(props: {profile: Profile}) {
|
||||
const prefText = formatPartnerPreferences(profile.cannabis_pref, t)
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<Leaf className="h-5 w-5" />}
|
||||
title={t('profile.cannabis', 'Cannabis')}
|
||||
text={parts}
|
||||
>
|
||||
<AboutRow title={t('profile.cannabis', 'Cannabis')} text={parts}>
|
||||
{showIntentions && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{profile.cannabis_intention!.map((i) => (
|
||||
<Chip key={i} variant="muted">
|
||||
<Chip key={i}>
|
||||
{t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])}
|
||||
</Chip>
|
||||
))}
|
||||
@@ -577,15 +624,11 @@ function Psychedelics(props: {profile: Profile}) {
|
||||
const prefText = formatPartnerPreferences(profile.psychedelics_pref, t)
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<CustomMushroom className="h-5 w-5" />}
|
||||
title={t('profile.psychedelics', 'Psychedelics')}
|
||||
text={parts}
|
||||
>
|
||||
<AboutRow title={t('profile.psychedelics', 'Psychedelics')} text={parts}>
|
||||
{showIntentions && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{profile.psychedelics_intention!.map((i) => (
|
||||
<Chip key={i} variant="muted">
|
||||
<Chip key={i}>
|
||||
{t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])}
|
||||
</Chip>
|
||||
))}
|
||||
@@ -633,12 +676,10 @@ function LastOnline(props: {lastOnlineTime?: string}) {
|
||||
return (
|
||||
<AboutRow
|
||||
testId="profile-about-last-online"
|
||||
icon={<ClockIcon className="h-5 w-5" />}
|
||||
title={t('profile.activity', 'Activity')}
|
||||
text={t('profile.last_online', 'Active {time}', {
|
||||
time: fromNow(lastOnlineTime, true, t, locale),
|
||||
})}
|
||||
divider={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -678,7 +719,6 @@ function Children(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<FaChild className="h-5 w-5" />}
|
||||
title={t('profile.children', 'Children')}
|
||||
text={hasKidsText}
|
||||
details={wantsKidsText}
|
||||
@@ -722,7 +762,6 @@ function RaisedIn(props: {profile: Profile}) {
|
||||
if (!locationText) return null
|
||||
return (
|
||||
<AboutRow
|
||||
icon={<Home className="h-5 w-5" />}
|
||||
title={t('profile.raised_in', 'Raised In')}
|
||||
text={
|
||||
<CustomLink href={getGoogleMapsUrl(locationText)} className={'hover:text-primary-500'}>
|
||||
@@ -733,55 +772,58 @@ function RaisedIn(props: {profile: Profile}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileInterestsAndCauses(props: {profile: Profile}) {
|
||||
const {profile} = props
|
||||
const t = useT()
|
||||
/**
|
||||
* Interests and causes are separate rail sections rather than one block with an inner heading.
|
||||
*
|
||||
* Combined, "Causes" ended up labelled rule-then-label while its neighbour "Interests" was
|
||||
* label-then-rule — two heading systems stacked against each other. Each is now a peer of Details and
|
||||
* Links, and gets that same treatment from `RailSection`.
|
||||
*/
|
||||
function useSortedChoices(ids: string[] | null | undefined, group: 'interests' | 'causes') {
|
||||
const choices = useChoicesContext()
|
||||
const {locale} = useLocale()
|
||||
|
||||
const interests = profile.interests
|
||||
?.map((id) => choices?.['interests']?.[id])
|
||||
return (ids
|
||||
?.map((id) => choices?.[group]?.[id])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b, locale)) as string[]
|
||||
.sort((a, b) => a.localeCompare(b, locale)) ?? []) as string[]
|
||||
}
|
||||
|
||||
const causes = profile.causes
|
||||
?.map((id) => choices?.['causes']?.[id])
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b, locale)) as string[]
|
||||
export function ProfileInterests(props: {profile: Profile}) {
|
||||
const interests = useSortedChoices(props.profile.interests, 'interests')
|
||||
if (!interests.length) return null
|
||||
return <TagList items={interests} />
|
||||
}
|
||||
|
||||
if (!interests?.length && !causes?.length) return null
|
||||
export function ProfileCauses(props: {profile: Profile}) {
|
||||
const causes = useSortedChoices(props.profile.causes, 'causes')
|
||||
if (!causes.length) return null
|
||||
return <TagList items={causes} />
|
||||
}
|
||||
|
||||
/**
|
||||
* An unordered set of taxonomy tags as one middot-separated run, matching how the Work row already
|
||||
* lists work areas — the same shape of data, so it gets the same shape of presentation.
|
||||
*
|
||||
* These were pills, which read as buttons. Nothing here is clickable, and the Links block below is,
|
||||
* so the affordance was pointing at the wrong things. Pills now mean "you can click this"; text means
|
||||
* "you can read this". Middots rather than commas because several tags carry internal punctuation
|
||||
* ("Animal welfare & rights", "Health & Medicine") that commas would blur together.
|
||||
*/
|
||||
function TagList(props: {items: string[]}) {
|
||||
const {items} = props
|
||||
return (
|
||||
<Col className={clsx('relative gap-3 overflow-hidden rounded')}>
|
||||
{interests && interests.length > 0 && (
|
||||
<>
|
||||
{/*<SectionLabel>{t('profile.interests', 'Interests')}</SectionLabel>*/}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{interests.map((interest, i) => (
|
||||
<Chip key={i}>{interest}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{causes && causes.length > 0 && (
|
||||
<>
|
||||
{interests && interests.length > 0 && (
|
||||
<div
|
||||
className="border-canvas-200"
|
||||
style={{borderBottomWidth: '1px', margin: '12px 0'}}
|
||||
/>
|
||||
<div className="text-primary-900" style={{fontSize: '15px', lineHeight: '1.65'}}>
|
||||
{items.map((item, i) => (
|
||||
<React.Fragment key={item}>
|
||||
{i > 0 && (
|
||||
<span aria-hidden className="text-ink-400 select-none">
|
||||
{' · '}
|
||||
</span>
|
||||
)}
|
||||
<SectionLabel>{t('profile.causes', 'Causes')}</SectionLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{causes.map((cause, i) => (
|
||||
<Chip key={i} variant="primary">
|
||||
{cause}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
{item}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -791,14 +833,22 @@ export function ProfileInterestsAndCauses(props: {profile: Profile}) {
|
||||
*/
|
||||
export function hasPersonality(profile: Profile) {
|
||||
const p = profile as any
|
||||
return !!(
|
||||
profile.mbti ||
|
||||
profile.big5_agreeableness ||
|
||||
p.neurotype?.length ||
|
||||
p.neurotype_details
|
||||
)
|
||||
return !!(profile.mbti || p.neurotype?.length || p.neurotype_details)
|
||||
}
|
||||
|
||||
/** Big Five is its own rail section, so it needs its own emptiness check. */
|
||||
export function hasBigFive(profile: Profile) {
|
||||
return BIG5_KEYS.some((key) => profile[key] != null)
|
||||
}
|
||||
|
||||
const BIG5_KEYS = [
|
||||
'big5_openness',
|
||||
'big5_conscientiousness',
|
||||
'big5_extraversion',
|
||||
'big5_agreeableness',
|
||||
'big5_neuroticism',
|
||||
] as const
|
||||
|
||||
/** Whether the Accessibility card has anything to show. Shared with its call site, as with `hasPersonality`. */
|
||||
export function hasAccessibility(profile: Profile) {
|
||||
return !!(profile as any).accessibility_notes
|
||||
@@ -818,23 +868,12 @@ export function ProfileAccessibility(props: {profile: Profile}) {
|
||||
if (!notes) return null
|
||||
|
||||
return (
|
||||
<Row className="items-start gap-2.5">
|
||||
<div
|
||||
className="bg-canvas-100 border-canvas-200 text-ink-500 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border"
|
||||
style={{width: '32px', height: '32px', marginTop: '1px'}}
|
||||
>
|
||||
<Accessibility className="h-5 w-5" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgb(var(--color-ink-900))',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</div>
|
||||
</Row>
|
||||
<div
|
||||
className="text-primary-900"
|
||||
style={{fontSize: '15px', lineHeight: '1.55', whiteSpace: 'pre-wrap'}}
|
||||
>
|
||||
{notes}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -845,15 +884,19 @@ export function ProfilePersonality(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<Col className={clsx('relative gap-3 overflow-hidden rounded')}>
|
||||
{/* Grouped with MBTI/Big Five rather than with orientation in the Details card: it describes how
|
||||
{/* Grouped with MBTI rather than with orientation in the Details card: it describes how
|
||||
someone's mind works, and matters just as much for friendship and collaboration as for dating. */}
|
||||
<Neurotype profile={profile} />
|
||||
<MBTI profile={profile} />
|
||||
<Big5Traits profile={profile} />
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
/** Big Five as a rail section of its own — see `ProfileInterests` for why the inner labels went away. */
|
||||
export function ProfileBigFive(props: {profile: Profile}) {
|
||||
return <Big5Traits profile={props.profile} />
|
||||
}
|
||||
|
||||
function MBTI(props: {profile: Profile}) {
|
||||
const {profile} = props
|
||||
const t = useT()
|
||||
@@ -864,8 +907,7 @@ function MBTI(props: {profile: Profile}) {
|
||||
const mbtiTypeName = mbtiType ? t(`profile.mbti.${mbtiType}`, MBTI_TYPE_NAMES[mbtiType]) : null
|
||||
|
||||
return (
|
||||
<div style={{marginBottom: '18px'}}>
|
||||
<SectionLabel>{t('profile.mbti', 'MBTI')}</SectionLabel>
|
||||
<div>
|
||||
<div
|
||||
className="border-canvas-200 bg-canvas-100 inline-flex items-center gap-2 rounded-lg border px-4 py-2"
|
||||
style={{
|
||||
@@ -943,7 +985,6 @@ function Big5Traits(props: {profile: Profile}) {
|
||||
|
||||
return (
|
||||
<Col className="gap-3 w-full" data-testid="profile-about-big-five-personality-traits">
|
||||
<SectionLabel>{t('profile.big5', 'Big Five')}</SectionLabel>
|
||||
<div className="flex flex-col gap-3">
|
||||
{traits.map((trait) => {
|
||||
if (trait.value === null || trait.value === undefined) return null
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {buildArray} from 'common/util/array'
|
||||
import Image from 'next/image'
|
||||
import {useState} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {MediaModal} from 'web/components/media-modal'
|
||||
import {Carousel} from 'web/components/widgets/carousel'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {isVideo} from 'web/lib/firebase/storage'
|
||||
|
||||
import {SignUpButton} from './nav/sidebar'
|
||||
|
||||
export default function ProfileCarousel(props: {profile: Profile}) {
|
||||
const {profile} = props
|
||||
const photoNums = profile.photo_urls ? profile.photo_urls.length : 0
|
||||
|
||||
const [lightboxUrl, setLightboxUrl] = useState('')
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
const currentUser = useUser()
|
||||
|
||||
if (photoNums == 0 && !profile.pinned_url) return
|
||||
|
||||
if (!currentUser && profile.visibility !== 'public') {
|
||||
return (
|
||||
<Carousel>
|
||||
{profile.pinned_url && (
|
||||
<div className="h-[300px] w-[300px] flex-none snap-start">
|
||||
<Image
|
||||
priority={true}
|
||||
src={profile.pinned_url}
|
||||
height={300}
|
||||
width={300}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
className="h-full cursor-pointer rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{photoNums > 0 && (
|
||||
<Col className="bg-canvas-100 dark:bg-canvas-50 text-ink-500 relative h-[300px] w-[300px] flex-none items-center rounded text-6xl ">
|
||||
<Col className=" m-auto items-center gap-1">
|
||||
<div className="select-none font-semibold">+{photoNums}</div>
|
||||
<SignUpButton
|
||||
text="Sign up to see"
|
||||
size="xs"
|
||||
color="none"
|
||||
className="dark:text-ink-500 hover:text-primary-500 hover:underline"
|
||||
/>
|
||||
</Col>
|
||||
</Col>
|
||||
)}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Carousel>
|
||||
{buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => (
|
||||
<Col key={url}>
|
||||
<div className="h-[300px] w-[300px] flex-none snap-start">
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
height={300}
|
||||
width={300}
|
||||
className="h-full w-full cursor-pointer rounded-xl object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onClick={() => {
|
||||
setLightboxUrl(url)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
priority={i < 3}
|
||||
src={url}
|
||||
height={300}
|
||||
width={300}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
className="h-full cursor-pointer rounded-xl object-cover"
|
||||
onClick={() => {
|
||||
setLightboxUrl(url)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(profile.image_descriptions as Record<string, string>)?.[url] && (
|
||||
<p className="mt-2 px-4 py-1 text-sm w-[300px] whitespace-pre-wrap">
|
||||
{(profile.image_descriptions as Record<string, string>)?.[url]}
|
||||
</p>
|
||||
)}
|
||||
</Col>
|
||||
))}
|
||||
</Carousel>
|
||||
|
||||
<MediaModal url={lightboxUrl} open={lightboxOpen} setOpen={setLightboxOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -59,48 +59,38 @@ export const ProfileCommentSection = (props: {
|
||||
<>
|
||||
{currentUser && profile.comments_enabled && (
|
||||
<>
|
||||
<div className="mb-4 text-ink-600">
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
{t(
|
||||
'profile.comments.current_user_hint',
|
||||
'Other users can write endorsements of you here.',
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t(
|
||||
'profile.comments.other_user_hint',
|
||||
'If you know them, write something nice that adds to their profile.',
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isCurrentUser && (
|
||||
<>
|
||||
{!showCommentInput ? (
|
||||
{showCommentInput ? (
|
||||
<ProfileCommentInput
|
||||
className="mb-4 mr-px mt-px"
|
||||
onUserId={onUser.id}
|
||||
trackingLocation={'contract page'}
|
||||
/>
|
||||
) : (
|
||||
/* One compact row, not a panel. This is an invitation, and as a 170px dashed box it
|
||||
outweighed the endorsements it was asking for — and a dashed border reads as a drop
|
||||
zone, which is the wrong metaphor. The only card left in this region is the
|
||||
connection-signal one, which actually holds controls. */
|
||||
<Row className="mb-6 flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="text-ink-500 text-sm">
|
||||
{isCurrentUser
|
||||
? t(
|
||||
'profile.comments.current_user_hint',
|
||||
'Other users can write endorsements of you here.',
|
||||
)
|
||||
: t(
|
||||
'profile.comments.other_user_hint',
|
||||
'If you know them, write something nice that adds to their profile.',
|
||||
)}
|
||||
</div>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={() => setShowCommentInput(true)}
|
||||
className="w-fit border-canvas-300 text-primary-700 bg-canvas-200 hover:bg-canvas-300 mb-4 rounded-full border px-4 py-2 text-sm transition-colors"
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '100px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.01em',
|
||||
borderWidth: '1px',
|
||||
}}
|
||||
className="border-canvas-300 text-primary-700 hover:border-primary-400 hover:bg-primary-50 w-fit shrink-0 rounded-full border px-4 py-2 text-sm transition-colors"
|
||||
>
|
||||
{t('profile.comments.write_button', 'Write public endorsement')}
|
||||
</button>
|
||||
) : (
|
||||
<ProfileCommentInput
|
||||
className="mb-4 mr-px mt-px"
|
||||
onUserId={onUser.id}
|
||||
trackingLocation={'contract page'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import clsx from 'clsx'
|
||||
import {INVERTED_RELATIONSHIP_CHOICES, RELATIONSHIP_CHOICES} from 'common/choices'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {CheckIcon, InfoIcon, SparklesIcon} from 'lucide-react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
@@ -7,6 +9,7 @@ import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {SendMessageButton} from 'web/components/messaging/send-message-button'
|
||||
import {Tooltip} from 'web/components/widgets/tooltip'
|
||||
import {shortenName} from 'web/components/widgets/user-link'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {api} from 'web/lib/api'
|
||||
@@ -32,8 +35,8 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
'profile.connect.tips',
|
||||
`
|
||||
- You choose the type of connection you're open to.
|
||||
- They won’t see this unless they choose the same type with you.
|
||||
- If you both choose the same type, you’re both notified.
|
||||
- They won’t see it until you both choose the same type.
|
||||
- You’re both notified.
|
||||
`,
|
||||
)
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false)
|
||||
@@ -81,58 +84,88 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
|
||||
if (isCurrentUser || !currentUser) return null
|
||||
|
||||
const isTypeDisabled = (type: string) =>
|
||||
!(targetSoughtConnections?.includes(type) || (targetSoughtConnections ?? []).length === 0) ||
|
||||
!soughtConnections?.includes(type)
|
||||
|
||||
const connectionTypes = Object.entries(RELATIONSHIP_CHOICES)
|
||||
const noSharedTypes = connectionTypes.every(([, type]) => isTypeDisabled(type))
|
||||
const hasSignalControls = !!profile.allow_interest_indicating && !noSharedTypes
|
||||
|
||||
return (
|
||||
<Col className="w-full gap-6 rounded-xl">
|
||||
<div className="py-2">
|
||||
{/* Primary Action */}
|
||||
<div className="mb-6">
|
||||
{profile.allow_direct_messaging || matches.length > 0 ? (
|
||||
<SendMessageButton
|
||||
toUser={user}
|
||||
profile={profile}
|
||||
text={t('messaging.send_thoughtful_message', 'Send them a thoughtful message')}
|
||||
/>
|
||||
) : (
|
||||
<p className={'guidance'}>
|
||||
{t(
|
||||
'profile.connect.direct_messaging_disabled',
|
||||
'{user} turned off direct messaging',
|
||||
{user: user.name},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Col className="w-full gap-8">
|
||||
{profile.allow_direct_messaging || matches.length > 0 ? (
|
||||
<SendMessageButton
|
||||
toUser={user}
|
||||
profile={profile}
|
||||
text={t('messaging.send_thoughtful_message', 'Send them a thoughtful message')}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-ink-500 text-sm">
|
||||
{t('profile.connect.direct_messaging_disabled', '{user} turned off direct messaging', {
|
||||
user: user.name,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Interest Section */}
|
||||
<div className="prose prose-neutral dark:prose-invert">
|
||||
<h2 className="font-medium text-xl">
|
||||
{/* No surface at all. The chips are pills, and a pill is already this page's signal for "you can
|
||||
click this" — the card was restating it in a heavier way, and it made this block outweigh the
|
||||
two actions above it, which are boxless despite being just as actionable. Nothing on this page
|
||||
is a card now; affordance is carried by the controls themselves. */}
|
||||
<div>
|
||||
<Row className="items-baseline justify-between gap-3">
|
||||
<h3
|
||||
className="text-ink-400 font-dm-sans uppercase"
|
||||
style={{fontSize: '10px', letterSpacing: '0.18em'}}
|
||||
>
|
||||
{t('profile.connect.private_connection_signal', 'Private connection signal')}
|
||||
</h2>
|
||||
</h3>
|
||||
{/* Explaining the mechanism is only worth the row when the mechanism is available. */}
|
||||
{hasSignalControls && (
|
||||
<button
|
||||
className="text-ink-500 hover:text-primary-600 flex flex-none items-center gap-1 text-xs transition-colors"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
aria-expanded={showHelp}
|
||||
>
|
||||
<InfoIcon className="h-3.5 w-3.5" />
|
||||
{t('profile.connect.how_this_works', 'How this works')}
|
||||
</button>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{profile.allow_interest_indicating ? (
|
||||
<>
|
||||
<Row className={'gap-2 my-4'}>
|
||||
<div className="text-ink-500">
|
||||
{t(
|
||||
'profile.wont_be_notified_unless_mutual',
|
||||
'They won’t be notified unless the interest is mutual.',
|
||||
)}
|
||||
</div>
|
||||
<button className="text-primary-600" onClick={() => setShowHelp(!showHelp)}>
|
||||
{t('profile.connect.how_this_works', 'How this works')}
|
||||
</button>
|
||||
</Row>
|
||||
{showHelp && <ReactMarkdown>{tips}</ReactMarkdown>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(RELATIONSHIP_CHOICES).map(([label, type]) => {
|
||||
{profile.allow_interest_indicating ? (
|
||||
<>
|
||||
{hasSignalControls && (
|
||||
<p className="text-ink-500 mt-2 text-sm">
|
||||
{t(
|
||||
'profile.wont_be_notified_unless_mutual',
|
||||
'They won’t be notified unless the interest is mutual.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<div className="border-canvas-200 text-ink-500 mt-4 rounded-xl border border-dashed px-4 py-3 text-sm [&_li]:mt-1 [&_ul]:list-disc [&_ul]:pl-4">
|
||||
<ReactMarkdown>{tips}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Every option locked means the chips can only say "no" three times over, in a shape that
|
||||
still looks pressable. Say it once instead, and give back the height. */}
|
||||
{noSharedTypes ? (
|
||||
<p className="text-ink-500 mt-3 text-sm">
|
||||
{t(
|
||||
'profile.connect.no_shared_types',
|
||||
"You and {name} aren't open to the same kind of connection.",
|
||||
{name: shortenName(user.name)},
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<Row className="mt-4 flex-wrap gap-2.5">
|
||||
{connectionTypes.map(([label, type]) => {
|
||||
const isSelected = interests.includes(type)
|
||||
const isMutual = interests.includes(type) && targetInterests.includes(type)
|
||||
|
||||
const isDisabled =
|
||||
!(
|
||||
targetSoughtConnections?.includes(type) ||
|
||||
(targetSoughtConnections ?? []).length === 0
|
||||
) || !soughtConnections?.includes(type)
|
||||
const isMutual = isSelected && targetInterests.includes(type)
|
||||
const isDisabled = isTypeDisabled(type)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -148,28 +181,40 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleInterestChange(type, !isSelected)}
|
||||
className={`
|
||||
px-4 py-2 rounded-full text-sm font-medium transition
|
||||
border
|
||||
${
|
||||
isMutual
|
||||
? 'bg-primary-200 border-primary-500 border-2'
|
||||
: isSelected
|
||||
? 'bg-primary-100 border-primary-300'
|
||||
: 'bg-canvas-50 text-ink-700 border-ink-300'
|
||||
}
|
||||
${isDisabled ? 'opacity-30 pointer-events-none' : 'hover:border-primary-400'}
|
||||
`}
|
||||
aria-pressed={isSelected}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 rounded-full border px-4 py-2 text-sm transition-all',
|
||||
// A muted, unmistakably inert style rather than a faded copy of the live one:
|
||||
// 30% opacity reads as a rendering fault, not as "not available to you".
|
||||
isDisabled
|
||||
? 'border-canvas-200 bg-canvas-100 text-ink-300 pointer-events-none'
|
||||
: isMutual
|
||||
? 'border-primary-500 bg-primary-200 text-primary-900 font-medium shadow-sm'
|
||||
: isSelected
|
||||
? 'border-primary-400 bg-primary-100 text-primary-800'
|
||||
: 'border-canvas-300 bg-canvas-50 text-ink-700 hover:border-primary-400 hover:bg-primary-50',
|
||||
)}
|
||||
>
|
||||
{/* No lock glyph: the muted style already says inert, and the tooltip says why.
|
||||
Repeating a padlock on every unavailable option was heavier than the labels. */}
|
||||
{isSelected && <CheckIcon className="h-3.5 w-3.5 flex-none" />}
|
||||
{t(`profile.relationship.${type}`, label)}
|
||||
{isMutual && ` • ${t('profile.mutual', 'Mutual')}`}
|
||||
{isMutual && (
|
||||
<span className="bg-primary-500 ml-0.5 rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-white">
|
||||
{t('profile.mutual', 'Mutual')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<div className="my-4">
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{matches.length > 0 && (
|
||||
<Row className="border-primary-300 bg-primary-50 text-primary-800 mt-4 items-start gap-2 rounded-xl border px-4 py-3 text-sm">
|
||||
<SparklesIcon className="mt-0.5 h-4 w-4 flex-none" />
|
||||
<span>
|
||||
{t(
|
||||
'profile.mutual_interests',
|
||||
'You both have mutual interests: {matches}. Contact them above to connect!',
|
||||
@@ -184,19 +229,17 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
|
||||
.join(', '),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className={'guidance'}>
|
||||
{t(
|
||||
'profile.turned_off_interest_indicators',
|
||||
'{user} turned off interest indicators',
|
||||
{user: user.name},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink-500 mt-2 text-sm">
|
||||
{t('profile.turned_off_interest_indicators', '{user} turned off interest indicators', {
|
||||
user: user.name,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
|
||||
142
web/components/profile/profile-gallery.tsx
Normal file
142
web/components/profile/profile-gallery.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import clsx from 'clsx'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {buildArray} from 'common/util/array'
|
||||
import Image from 'next/image'
|
||||
import {useState} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {MediaModal} from 'web/components/media-modal'
|
||||
import {SignUpButton} from 'web/components/nav/sidebar'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {isVideo} from 'web/lib/firebase/storage'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
/**
|
||||
* Square photo gallery for the profile hero: one main tile plus a thumbnail strip.
|
||||
*
|
||||
* Photos are the second thing a visitor checks, so they sit at the top — but the tile is capped at a
|
||||
* modest size on purpose. A full-bleed hero image would push the bio, which ranks right behind them,
|
||||
* off the first screen.
|
||||
*/
|
||||
const MAIN_SIZE = 300
|
||||
const THUMB_SIZE = 64
|
||||
|
||||
export default function ProfileGallery(props: {profile: Profile; className?: string}) {
|
||||
const {profile, className} = props
|
||||
const t = useT()
|
||||
const currentUser = useUser()
|
||||
|
||||
const urls = buildArray(profile.pinned_url, profile.photo_urls)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
if (urls.length === 0) return null
|
||||
|
||||
// Signed-out visitors on a members-only profile see the pinned photo and a locked count.
|
||||
const isLocked = !currentUser && profile.visibility !== 'public'
|
||||
const visibleUrls = isLocked ? urls.slice(0, 1) : urls
|
||||
|
||||
const selectedUrl = visibleUrls[Math.min(selected, visibleUrls.length - 1)]
|
||||
|
||||
// Every photo gets a thumbnail — they wrap onto as many rows as it takes. The only "+N" left is the
|
||||
// locked one, where the count is all a signed-out visitor is allowed to know.
|
||||
const lockedCount = isLocked ? urls.length - 1 : 0
|
||||
|
||||
return (
|
||||
<Col className={clsx('flex-none gap-3', className)} style={{width: MAIN_SIZE}}>
|
||||
<div
|
||||
className="border-canvas-300 relative aspect-square w-full overflow-hidden rounded-[4px] border"
|
||||
style={{maxWidth: MAIN_SIZE}}
|
||||
>
|
||||
<MediaTile
|
||||
url={selectedUrl}
|
||||
priority
|
||||
onClick={() => !isLocked && setLightboxOpen(true)}
|
||||
className={isLocked ? 'cursor-default' : 'cursor-pointer'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(visibleUrls.length > 1 || lockedCount > 0) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!isLocked &&
|
||||
visibleUrls.map((url, i) => (
|
||||
<button
|
||||
key={url}
|
||||
onClick={() => setSelected(i)}
|
||||
aria-label={t('profile.gallery.show_photo', 'Show photo {number}', {number: i + 1})}
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-[3px] border transition-colors',
|
||||
i === selected
|
||||
? 'border-primary-500'
|
||||
: 'border-canvas-300 hover:border-primary-400',
|
||||
)}
|
||||
style={{width: THUMB_SIZE, height: THUMB_SIZE}}
|
||||
>
|
||||
<MediaTile url={url} />
|
||||
</button>
|
||||
))}
|
||||
{lockedCount > 0 && (
|
||||
<div
|
||||
className="bg-canvas-100 border-canvas-300 text-ink-500 flex items-center justify-center rounded-[3px] border border-dashed text-sm"
|
||||
style={{width: THUMB_SIZE, height: THUMB_SIZE}}
|
||||
>
|
||||
<SignUpButton
|
||||
text={`+${lockedCount}`}
|
||||
size="xs"
|
||||
color="none"
|
||||
className="dark:text-ink-500 hover:text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The whole set goes to the modal, not just the tile that was clicked, so it can page through
|
||||
them and show each one's description. Descriptions live only there — under the grid they
|
||||
changed height as you moved between thumbnails and shoved the hero around. */}
|
||||
<MediaModal
|
||||
urls={visibleUrls}
|
||||
index={Math.min(selected, visibleUrls.length - 1)}
|
||||
setIndex={setSelected}
|
||||
descriptions={profile.image_descriptions as Record<string, string>}
|
||||
open={lightboxOpen}
|
||||
setOpen={setLightboxOpen}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaTile(props: {
|
||||
url: string
|
||||
priority?: boolean
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}) {
|
||||
const {url, priority, onClick, className} = props
|
||||
|
||||
if (isVideo(url)) {
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
className={clsx('h-full w-full object-cover', className)}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
priority={priority}
|
||||
src={url}
|
||||
height={MAIN_SIZE}
|
||||
width={MAIN_SIZE}
|
||||
sizes={`${MAIN_SIZE}px`}
|
||||
alt=""
|
||||
className={clsx('h-full w-full object-cover', className)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,19 @@
|
||||
import {
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
LockClosedIcon,
|
||||
PencilIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {debug} from 'common/logger'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {User, UserActivity} from 'common/user'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {User} from 'common/user'
|
||||
import Router from 'next/router'
|
||||
import React, {useState} from 'react'
|
||||
import React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {MoreOptionsUserButton} from 'web/components/buttons/more-options-user-button'
|
||||
import DropdownMenu from 'web/components/comments/dropdown-menu'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {MediaModal} from 'web/components/media-modal'
|
||||
import {SendMessageButton} from 'web/components/messaging/send-message-button'
|
||||
import {ViewProfileCardButton} from 'web/components/photos-modal'
|
||||
import {linkClass} from 'web/components/widgets/site-link'
|
||||
import {StarButton} from 'web/components/widgets/star-button'
|
||||
import {Tooltip} from 'web/components/widgets/tooltip'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
@@ -29,163 +21,8 @@ import {updateProfile} from 'web/lib/api'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {disableProfile} from 'web/lib/util/disable'
|
||||
import {capitalizePure} from 'web/lib/util/time'
|
||||
|
||||
import {ShareProfileButton} from '../widgets/share-profile-button'
|
||||
import ProfilePrimaryInfo from './profile-primary-info'
|
||||
|
||||
export default function ProfileHeader(props: {
|
||||
user: User
|
||||
userActivity?: UserActivity
|
||||
profile: Profile
|
||||
simpleView?: boolean
|
||||
isHiddenFromMe: boolean | undefined
|
||||
}) {
|
||||
const {user, profile, userActivity, simpleView, isHiddenFromMe} = props
|
||||
const currentUser = useUser()
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const disabled = profile.disabled
|
||||
const t = useT()
|
||||
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
debug('ProfileProfileHeader', {
|
||||
user,
|
||||
profile,
|
||||
userActivity,
|
||||
currentUser,
|
||||
})
|
||||
|
||||
return (
|
||||
<Row className={'flex-wrap gap-4'}>
|
||||
<Col>
|
||||
{currentUser && !isCurrentUser && isHiddenFromMe && (
|
||||
<div className="w-fit flex items-center gap-2 rounded-lg bg-canvas-200 px-4 py-3 text-sm text-primary-800 mb-4">
|
||||
<EyeSlashIcon className="h-4 w-4 flex-none" />
|
||||
{t(
|
||||
'profile_grid.hidden_notice',
|
||||
"You hid this person, so they don't appear in your search results.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentUser && isCurrentUser && disabled && (
|
||||
<div className="w-fit flex items-center gap-2 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 mb-4">
|
||||
<LockClosedIcon className="h-4 w-4 flex-none" />
|
||||
{t(
|
||||
'profile.header.disabled_notice',
|
||||
'You disabled your profile, so no one else can access it.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Row className="w-full gap-6 flex-wrap">
|
||||
{profile.pinned_url && (
|
||||
<div className="h-[108px] w-[108px] flex-none">
|
||||
<Image
|
||||
priority={true}
|
||||
src={profile.pinned_url}
|
||||
height={300}
|
||||
width={300}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
className="h-full w-full cursor-pointer rounded-2xl object-cover"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Col
|
||||
className={clsx(
|
||||
profile.pinned_url
|
||||
? 'max-w-[160px] sm:max-w-[300px]'
|
||||
: 'max-w-[260px] sm:max-w-[400px]',
|
||||
)}
|
||||
>
|
||||
<Row className={clsx('flex-wrap justify-between gap-2 py-1')}>
|
||||
<Row className="items-center gap-1">
|
||||
<Col className="gap-1">
|
||||
<Row className="items-center gap-1" data-testid="profile-display-name-age">
|
||||
{/*{!isCurrentUser && <OnlineIcon last_online_time={userActivity?.last_online_time}/>}*/}
|
||||
<span>
|
||||
{simpleView ? (
|
||||
<Link className={linkClass} href={`/${user.username}`}>
|
||||
<span
|
||||
className="font-heading text-4xl font-medium"
|
||||
style={{lineHeight: '1.1', letterSpacing: '-0.01em'}}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="font-heading text-4xl font-medium"
|
||||
style={{lineHeight: '1.1', letterSpacing: '-0.01em'}}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Row>
|
||||
<ProfilePrimaryInfo profile={profile} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
className={clsx('gap-2 flex-wrap py-2', 'max-w-full sm:max-w-[500px]')}
|
||||
data-testid="profile-keywords"
|
||||
>
|
||||
{profile.keywords?.map(capitalizePure)?.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={'border-canvas-300 text-primary-700 bg-canvas-200'}
|
||||
style={{
|
||||
padding: '5px 13px',
|
||||
borderRadius: '100px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.01em',
|
||||
borderWidth: '1px',
|
||||
}}
|
||||
>
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
</Col>
|
||||
{profile.headline && (
|
||||
<div
|
||||
className="relative max-w-xl px-4 py-3 text-ink-600 flex items-center justify-center"
|
||||
data-testid="profile-headline"
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.65',
|
||||
borderLeft: '1.5px solid rgb(var(--color-primary-300))',
|
||||
paddingLeft: '40px',
|
||||
}}
|
||||
>
|
||||
<div className="h-fit relative">
|
||||
<span
|
||||
className="absolute -mt-6 text-3xl text-primary-300"
|
||||
style={{fontFamily: 'serif'}}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
<span className="italic">{profile.headline}</span>
|
||||
<span
|
||||
className="absolute -bottom-8 text-3xl text-primary-300"
|
||||
style={{fontFamily: 'serif'}}
|
||||
>
|
||||
"
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile.pinned_url && (
|
||||
<MediaModal url={profile.pinned_url} open={lightboxOpen} setOpen={setLightboxOpen} />
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileHeaderActions(props: {
|
||||
user: User
|
||||
|
||||
149
web/components/profile/profile-hero.tsx
Normal file
149
web/components/profile/profile-hero.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {EyeSlashIcon, LockClosedIcon} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {User, UserActivity} from 'common/user'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import ProfileGallery from 'web/components/profile/profile-gallery'
|
||||
import {ProfileConnectionGoals} from 'web/components/profile-about'
|
||||
import {linkClass} from 'web/components/widgets/site-link'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {capitalizePure} from 'web/lib/util/time'
|
||||
|
||||
import ProfilePrimaryInfo from './profile-primary-info'
|
||||
|
||||
/**
|
||||
* The first screen of a profile: photos, identity, connection goals, tagline.
|
||||
*
|
||||
* These are the things a visitor decides on before reading anything else, so they share one band at the
|
||||
* top instead of being spread across the page. Everything ranked below them lives in `ProfileContent`.
|
||||
*
|
||||
* No call to action here on purpose: reaching out belongs to the top bar (always reachable) and the
|
||||
* Connect section at the end, not to the moment someone is still deciding who they are looking at.
|
||||
*/
|
||||
export default function ProfileHero(props: {
|
||||
user: User
|
||||
profile: Profile
|
||||
userActivity?: UserActivity
|
||||
simpleView?: boolean
|
||||
isHiddenFromMe: boolean | undefined
|
||||
}) {
|
||||
const {user, profile, isHiddenFromMe, simpleView} = props
|
||||
const currentUser = useUser()
|
||||
const isCurrentUser = currentUser?.id === user.id
|
||||
const t = useT()
|
||||
|
||||
const name = (
|
||||
<span
|
||||
className="font-heading text-ink-1000 font-normal"
|
||||
style={{
|
||||
fontSize: 'clamp(2.75rem, 4.4vw, 4.25rem)',
|
||||
lineHeight: '1.02',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<Col className="gap-5">
|
||||
{currentUser && !isCurrentUser && isHiddenFromMe && (
|
||||
<Notice icon={<EyeSlashIcon className="h-4 w-4 flex-none" />}>
|
||||
{t(
|
||||
'profile_grid.hidden_notice',
|
||||
"You hid this person, so they don't appear in your search results.",
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
{currentUser && isCurrentUser && profile.disabled && (
|
||||
<Notice icon={<LockClosedIcon className="h-4 w-4 flex-none" />} tone="danger">
|
||||
{t(
|
||||
'profile.header.disabled_notice',
|
||||
'You disabled your profile, so no one else can access it.',
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-10 md:flex-row md:items-start md:gap-16">
|
||||
<ProfileGallery profile={profile} />
|
||||
|
||||
<Col className="min-w-0 flex-1 gap-7">
|
||||
<Col className="gap-3">
|
||||
<div data-testid="profile-display-name-age">
|
||||
{simpleView ? (
|
||||
<Link className={linkClass} href={`/${user.username}`}>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</div>
|
||||
<ProfilePrimaryInfo profile={profile} />
|
||||
</Col>
|
||||
|
||||
{/* Outlined rather than filled, squared rather than pill: five filled pills read as five
|
||||
buttons. These are labels, and the eye should pass over them on the way to the tagline. */}
|
||||
{profile.keywords && profile.keywords.length > 0 && (
|
||||
<Row className="max-w-3xl flex-wrap gap-2" data-testid="profile-keywords">
|
||||
{profile.keywords.map(capitalizePure).map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="border-canvas-300 text-primary-800 font-dm-sans rounded-[3px] border uppercase"
|
||||
style={{
|
||||
padding: '6px 11px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.13em',
|
||||
}}
|
||||
>
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* The tagline is the one thing on this page written to be read as a voice, so it gets the
|
||||
serif italic at display size instead of a quoted aside pinned behind a rule. */}
|
||||
{profile.headline && (
|
||||
<div
|
||||
className="font-heading text-primary-900 max-w-[38ch] italic sm:max-w-[52ch]"
|
||||
data-testid="profile-headline"
|
||||
style={{
|
||||
fontSize: 'clamp(1.15rem, 1.5vw, 1.5rem)',
|
||||
lineHeight: '1.5',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
“{profile.headline}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileConnectionGoals profile={profile} />
|
||||
</Col>
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function Notice(props: {
|
||||
icon: React.ReactNode
|
||||
tone?: 'default' | 'danger'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {icon, tone = 'default', children} = props
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-fit items-center gap-2 rounded-lg px-4 py-3 text-sm',
|
||||
tone === 'danger' ? 'bg-red-50 text-red-700' : 'bg-canvas-200 text-primary-800',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,22 +9,26 @@ import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {SignUpButton} from 'web/components/nav/sidebar'
|
||||
import {ConnectActions} from 'web/components/profile/connect-actions'
|
||||
import ProfileHeader, {ProfileHeaderActions} from 'web/components/profile/profile-header'
|
||||
import {ProfileHeaderActions} from 'web/components/profile/profile-header'
|
||||
import ProfileHero from 'web/components/profile/profile-hero'
|
||||
import ProfileAbout, {
|
||||
hasAccessibility,
|
||||
hasBigFive,
|
||||
hasPersonality,
|
||||
ProfileAccessibility,
|
||||
ProfileInterestsAndCauses,
|
||||
ProfileBigFive,
|
||||
ProfileCauses,
|
||||
ProfileInterests,
|
||||
ProfileLinks,
|
||||
ProfilePersonality,
|
||||
} from 'web/components/profile-about'
|
||||
import ProfileCarousel from 'web/components/profile-carousel'
|
||||
import {ProfileCommentSection} from 'web/components/profile-comment-section'
|
||||
import {Subtitle} from 'web/components/widgets/subtitle'
|
||||
import {ScrollPanel} from 'web/components/widgets/scroll-panel'
|
||||
import {shortenName} from 'web/components/widgets/user-link'
|
||||
import {useGetter} from 'web/hooks/use-getter'
|
||||
import {useHiddenProfiles} from 'web/hooks/use-hidden-profiles'
|
||||
import {useCompatibilityQuestionGroups} from 'web/hooks/use-questions'
|
||||
import {useScrolledPast} from 'web/hooks/use-scrolled-past'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useUserActivity} from 'web/hooks/use-user-activity'
|
||||
import {User} from 'web/lib/firebase/users'
|
||||
@@ -84,12 +88,14 @@ export function ProfileInfo(props: {
|
||||
|
||||
const {data: userActivity} = useUserActivity(user?.id)
|
||||
|
||||
const {ref: heroRef, scrolledPast: scrolledPastHero} = useScrolledPast<HTMLDivElement>()
|
||||
|
||||
// const isCurrentUser = currentUser?.id === user.id
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="bg-canvas-50 border-canvas-300 mb-6 flex items-center border-b px-4 sm:px-9 py-4"
|
||||
className="bg-canvas-50/95 border-canvas-300 sticky top-0 z-20 mb-6 flex items-center border-b px-4 sm:px-9 py-4 backdrop-blur"
|
||||
style={{
|
||||
borderBottomWidth: '1px',
|
||||
gap: '10px',
|
||||
@@ -98,6 +104,28 @@ export function ProfileInfo(props: {
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<BackButton className={'hidden sm:flex'} />
|
||||
|
||||
{/* Identity fades in once the hero is gone, so who you are reading about — and how to reach
|
||||
them — stays on screen all the way down the page. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex min-w-0 items-center gap-2.5 transition-opacity duration-200',
|
||||
scrolledPastHero ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!scrolledPastHero}
|
||||
>
|
||||
{/*{profile.pinned_url && (*/}
|
||||
{/* <Image*/}
|
||||
{/* src={profile.pinned_url}*/}
|
||||
{/* height={64}*/}
|
||||
{/* width={64}*/}
|
||||
{/* sizes="32px"*/}
|
||||
{/* alt=""*/}
|
||||
{/* className="h-8 w-8 flex-none rounded-lg object-cover"*/}
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
<span className="font-heading truncate text-lg font-medium">{user.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ProfileHeaderActions
|
||||
user={user}
|
||||
@@ -113,7 +141,7 @@ export function ProfileInfo(props: {
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full px-4 sm:px-8 pb-6 pt-0">
|
||||
<Row className="relative items-start gap-6">
|
||||
<div className="relative" ref={heroRef}>
|
||||
{/* Gradient overlay */}
|
||||
{/*<div*/}
|
||||
{/* style={{*/}
|
||||
@@ -141,10 +169,8 @@ export function ProfileInfo(props: {
|
||||
>
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx('min-w-0 flex-1 animate-profile-appear', !profile.pinned_url && 'ml-6')}
|
||||
>
|
||||
<ProfileHeader
|
||||
<div className="animate-profile-appear relative min-w-0">
|
||||
<ProfileHero
|
||||
user={user}
|
||||
userActivity={userActivity}
|
||||
profile={profile}
|
||||
@@ -152,7 +178,7 @@ export function ProfileInfo(props: {
|
||||
isHiddenFromMe={isHiddenFromMe}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
{isProfileVisible ? (
|
||||
<ProfileContent
|
||||
user={user}
|
||||
@@ -226,79 +252,180 @@ export function ProfileInfoSkeleton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mirrors the hero (square gallery + identity) and the two-column body, so nothing jumps on load. */}
|
||||
<div className="mx-auto w-full px-4 sm:px-8 pb-6 pt-0 animate-pulse">
|
||||
<Row className="gap-6 flex-wrap">
|
||||
<div className="bg-canvas-200 h-[108px] w-[108px] flex-none rounded-2xl" />
|
||||
<Col className="flex-1 gap-3 py-1 min-w-[160px]">
|
||||
<div className="bg-canvas-200 h-8 w-2/3 max-w-[240px] rounded" />
|
||||
<div className="bg-canvas-200 h-4 w-1/2 max-w-[180px] rounded" />
|
||||
<div className="bg-canvas-200 h-4 w-2/5 max-w-[140px] rounded" />
|
||||
<div className="flex flex-col gap-8 md:flex-row md:gap-10">
|
||||
<Col className="flex-none gap-3" style={{width: 260}}>
|
||||
<div className="bg-canvas-200 aspect-square w-full rounded-2xl" />
|
||||
<Row className="gap-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-canvas-200 h-[52px] w-[52px] rounded-lg" />
|
||||
))}
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
<Col className="min-w-[160px] flex-1 gap-3 py-1">
|
||||
<div className="bg-canvas-200 h-11 w-2/3 max-w-[320px] rounded" />
|
||||
<div className="bg-canvas-200 h-4 w-1/2 max-w-[220px] rounded" />
|
||||
<Row className="gap-2 flex-wrap py-2 max-w-full sm:max-w-[500px]">
|
||||
<div className="bg-canvas-200 h-7 w-16 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-20 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-14 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-24 rounded-full" />
|
||||
</Row>
|
||||
<div className="bg-canvas-200 h-16 w-full max-w-[340px] rounded-xl" />
|
||||
</Col>
|
||||
</div>
|
||||
|
||||
<Row className="gap-2 flex-wrap py-3 mt-2 max-w-full sm:max-w-[500px]">
|
||||
<div className="bg-canvas-200 h-7 w-16 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-20 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-14 rounded-full" />
|
||||
<div className="bg-canvas-200 h-7 w-24 rounded-full" />
|
||||
</Row>
|
||||
|
||||
<div
|
||||
className="bg-canvas-50 border-canvas-300 border mt-6"
|
||||
style={{borderRadius: '14px', padding: '22px 24px'}}
|
||||
>
|
||||
<div className="bg-canvas-200 h-6 w-24 rounded mb-5" />
|
||||
<Col className="gap-4">
|
||||
<div className="mt-10 grid grid-cols-1 items-start gap-x-12 gap-y-12 lg:grid-cols-[minmax(0,700px)_minmax(300px,1fr)]">
|
||||
<Col className="gap-3">
|
||||
<div className="bg-canvas-200 h-3 w-20 rounded mb-3" />
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Row key={i} className="items-center gap-2.5">
|
||||
<div className="bg-canvas-200 h-8 w-8 flex-none rounded-lg" />
|
||||
<Col className="flex-1 gap-1.5">
|
||||
<div className="bg-canvas-200 h-3 w-20 rounded" />
|
||||
<div className="bg-canvas-200 h-4 w-3/5 rounded" />
|
||||
</Col>
|
||||
</Row>
|
||||
<div key={i} className="bg-canvas-200 h-4 w-full rounded" />
|
||||
))}
|
||||
<div className="bg-canvas-200 h-4 w-2/3 rounded" />
|
||||
</Col>
|
||||
|
||||
<div>
|
||||
<div className="bg-canvas-200 h-3 w-20 rounded mb-3" />
|
||||
<div className="border-canvas-300 mb-4 border-t" />
|
||||
<Col className="gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Row key={i} className="items-center gap-2.5">
|
||||
<div className="bg-canvas-200 h-8 w-8 flex-none rounded-lg" />
|
||||
<Col className="flex-1 gap-1.5">
|
||||
<div className="bg-canvas-200 h-3 w-20 rounded" />
|
||||
<div className="bg-canvas-200 h-4 w-3/5 rounded" />
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard(props: {title?: ReactNode; children: ReactNode; className?: string}) {
|
||||
const {title, children, className} = props
|
||||
// Check if children is null or undefined
|
||||
if (children == null) {
|
||||
return null
|
||||
}
|
||||
/**
|
||||
* The attribute rail's contents. Extracted so the rail can sit in its own grid area between the two
|
||||
* halves of the prose column without the JSX becoming unreadable.
|
||||
*/
|
||||
function RailBlocks(props: {
|
||||
profile: Profile
|
||||
userActivity?: UserActivity
|
||||
isCurrentUser: boolean
|
||||
}) {
|
||||
const {profile, userActivity, isCurrentUser} = props
|
||||
const t = useT()
|
||||
|
||||
// Blocks are spaced apart rather than divided inside a shared panel — with no card there is nothing
|
||||
// for a divider to belong to, and whitespace is what separates the sections on the left too.
|
||||
return (
|
||||
<div
|
||||
className={clsx('bg-canvas-50 border-canvas-300 border', className)}
|
||||
style={{borderRadius: '14px', padding: '22px 24px'}}
|
||||
>
|
||||
{title != null && <CardTitle>{title}</CardTitle>}
|
||||
{children}
|
||||
<div className="3xl:grid-cols-2 grid grid-cols-1 gap-x-10 gap-y-10">
|
||||
<div className="flex min-w-0 flex-col gap-10">
|
||||
<RailSection title={t('profile.details', 'Details')}>
|
||||
<ProfileAbout
|
||||
profile={profile}
|
||||
userActivity={userActivity}
|
||||
isCurrentUser={isCurrentUser}
|
||||
omitConnectionGoals
|
||||
/>
|
||||
</RailSection>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-10">
|
||||
{!!profile.interests?.length && (
|
||||
<RailSection title={t('profile.interests', 'Interests')}>
|
||||
<ProfileInterests profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{!!profile.causes?.length && (
|
||||
<RailSection title={t('profile.causes', 'Causes')}>
|
||||
<ProfileCauses profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{hasPersonality(profile) && (
|
||||
<RailSection title={t('profile.personality', 'Personality')}>
|
||||
<ProfilePersonality profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{hasBigFive(profile) && (
|
||||
<RailSection title={t('profile.big5', 'Big Five')}>
|
||||
<ProfileBigFive profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{profile.links && Object.keys(profile.links).length > 0 && (
|
||||
<RailSection title={t('profile.links', 'Links')}>
|
||||
<ProfileLinks profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{hasAccessibility(profile) && (
|
||||
<RailSection title={t('profile.accessibility_notes', 'Accessibility')}>
|
||||
<ProfileAccessibility profile={profile} />
|
||||
</RailSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle(props: {children: ReactNode; className?: string}) {
|
||||
/**
|
||||
* A section of the main reading column. No border and no card — the column is prose, and whitespace
|
||||
* plus a small-caps label separates sections better than five stacked boxes do.
|
||||
*/
|
||||
function Section(props: {title?: ReactNode; id?: string; children: ReactNode; testId?: string}) {
|
||||
const {title, id, children, testId} = props
|
||||
if (children == null) return null
|
||||
return (
|
||||
<section id={id} className="min-w-0 scroll-mt-24" data-testid={testId}>
|
||||
{title != null && <SectionHeading>{title}</SectionHeading>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A block of the attribute rail: label, rule, content — the same object as `Section` on the left, with
|
||||
* a rule added because the rail's rows are also rule-separated and the heading needs to sit outside
|
||||
* that run rather than look like the first of them.
|
||||
*
|
||||
* No card. `bg-canvas-50` is the elevated-surface token and should mean "raised, or you can act on
|
||||
* this"; the rail is reference text, the same class of thing as the bio beside it. Boxing one and not
|
||||
* the other was the last place two visual languages ran side by side on this page.
|
||||
*/
|
||||
function RailSection(props: {title?: ReactNode; children: ReactNode}) {
|
||||
const {title, children} = props
|
||||
if (children == null) return null
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{title != null && (
|
||||
<>
|
||||
<SectionHeading className="!mb-3">{title}</SectionHeading>
|
||||
<div className="border-canvas-300 border-t" />
|
||||
</>
|
||||
)}
|
||||
{/* Every section gets the same breathing room under its rule. Details used to get it from its
|
||||
first row's own padding, which left the sections that are not row-based — interests, causes,
|
||||
Big Five, links — sitting hard against the rule. */}
|
||||
<div className={clsx(title != null && 'pt-4')}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeading(props: {children: ReactNode; className?: string}) {
|
||||
const {children, className} = props
|
||||
return (
|
||||
// <div
|
||||
// className={clsx(
|
||||
// 'text-ink-900 mb-3.5 font-heading text-xl font-medium tracking-wide',
|
||||
// className,
|
||||
// )}
|
||||
// style={{letterSpacing: '0.01em'}}
|
||||
// >
|
||||
// {children}
|
||||
// </div>
|
||||
<Subtitle
|
||||
className={clsx('!mt-0 !mb-4 font-heading text-xl font-medium tracking-wide', className)}
|
||||
<h2
|
||||
className={clsx('text-ink-400 font-dm-sans mb-5 font-normal uppercase', className)}
|
||||
style={{fontSize: '10px', letterSpacing: '0.18em'}}
|
||||
>
|
||||
{children}
|
||||
</Subtitle>
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -336,60 +463,50 @@ function ProfileContent(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Prose on the left, attributes in a sticky rail on the right. Prose is capped at a readable
|
||||
measure and the rail takes whatever is left, so wide screens widen the attributes instead of
|
||||
opening a gutter between the two columns.
|
||||
|
||||
The prose is split into two grid areas rather than one column so that stacking order on
|
||||
mobile can differ from column order on desktop. Flattened into a single column the old DOM
|
||||
order put the entire rail after Connect, which buried Details — a top-five thing to check —
|
||||
under everything ranked below it. Explicit row/column placement at `lg` keeps the desktop
|
||||
layout identical while mobile reads bio → details → prompts → endorsements → connect. */}
|
||||
<div
|
||||
className="mt-4 grid grid-cols-1 items-start gap-6 md:grid-cols-[1fr_500px] xl:grid-cols-[1fr_800px] 3xl:grid-cols-[1fr_900px]"
|
||||
className="mt-10 flex flex-col gap-14 lg:grid lg:grid-cols-[minmax(0,700px)_minmax(300px,1fr)] lg:items-start lg:gap-x-12 lg:gap-y-14"
|
||||
data-testid="profile-content"
|
||||
>
|
||||
<Col className="gap-6">
|
||||
<ProfileCard title={t('profile.details', 'Details')} className="p-5">
|
||||
<ProfileAbout
|
||||
profile={profile}
|
||||
userActivity={userActivity}
|
||||
isCurrentUser={isCurrentUser}
|
||||
/>
|
||||
</ProfileCard>
|
||||
|
||||
{(!!profile.interests?.length || !!profile.causes?.length) && (
|
||||
<ProfileCard title={t('profile.interests_and_causes', 'Interests')} className="p-5">
|
||||
<ProfileInterestsAndCauses profile={profile} />
|
||||
</ProfileCard>
|
||||
)}
|
||||
|
||||
{hasPersonality(profile) && (
|
||||
<ProfileCard title={t('profile.personality', 'Personality')} className="p-5">
|
||||
<ProfilePersonality profile={profile} />
|
||||
</ProfileCard>
|
||||
)}
|
||||
|
||||
{hasAccessibility(profile) && (
|
||||
<ProfileCard title={t('profile.accessibility_notes', 'Accessibility')} className="p-5">
|
||||
<ProfileAccessibility profile={profile} />
|
||||
</ProfileCard>
|
||||
)}
|
||||
|
||||
{profile.links && Object.keys(profile.links).length > 0 && (
|
||||
<ProfileCard title={t('profile.links', 'Links')} className="p-5">
|
||||
<ProfileLinks profile={profile} />
|
||||
</ProfileCard>
|
||||
)}
|
||||
</Col>
|
||||
<Col className="gap-6">
|
||||
<div className="min-w-0 lg:col-start-1 lg:row-start-1">
|
||||
{profile.bio && (
|
||||
<ProfileCard title={t('profile.bio.about_me', 'About Me')} className="p-0">
|
||||
<Section title={t('profile.bio.about_me', 'About Me')}>
|
||||
<ProfileBio
|
||||
isCurrentUser={isCurrentUser}
|
||||
profile={profile}
|
||||
refreshProfile={refreshProfile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
/>
|
||||
</ProfileCard>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfileCarousel profile={profile} />
|
||||
{/* Pinned beside the prose, and scrollable within itself — see ScrollPanel for the cues that
|
||||
make the second half of that obvious. Once wide enough for two readable columns it splits,
|
||||
which roughly halves its height and leaves far less to scroll.
|
||||
`row-span-2` gives the sticky element a containing block taller than itself, which is what
|
||||
it needs to have anywhere to travel. */}
|
||||
<ScrollPanel
|
||||
className="lg:sticky lg:top-24 lg:col-start-2 lg:row-span-2 lg:row-start-1"
|
||||
// `lg:pr-5` is the gutter the card's padding used to provide: without it the values, the
|
||||
// Big Five numbers in particular, sit hard against the scrollbar and get clipped by it.
|
||||
scrollClassName="lg:overflow-y-auto lg:max-h-[calc(100vh-8rem)] lg:pr-5"
|
||||
fadeColorClass="from-canvas-100 to-transparent"
|
||||
>
|
||||
<RailBlocks profile={profile} userActivity={userActivity} isCurrentUser={isCurrentUser} />
|
||||
</ScrollPanel>
|
||||
|
||||
<Col className="min-w-0 gap-14 lg:col-start-1 lg:row-start-2">
|
||||
{showCompatibilityPrompts && (
|
||||
<ProfileCard
|
||||
className="p-5"
|
||||
<Section
|
||||
title={
|
||||
isCurrentUser
|
||||
? t('answers.display.your_prompts', 'Compatibility Prompts')
|
||||
@@ -405,27 +522,26 @@ function ProfileContent(props: {
|
||||
fromProfilePage={fromProfilePage}
|
||||
profile={profile}
|
||||
/>
|
||||
</ProfileCard>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* No wrapper surface: the section supplies its own — a dashed invitation while empty, the
|
||||
endorsements themselves once there are any. A box around either just doubles the border. */}
|
||||
{currentUser && (
|
||||
<ProfileCard
|
||||
className="p-5"
|
||||
title={t('profile.comments.section_title', 'Endorsements')}
|
||||
>
|
||||
<Section title={t('profile.comments.section_title', 'Endorsements')}>
|
||||
<ProfileCommentSection
|
||||
onUser={user}
|
||||
profile={profile}
|
||||
currentUser={currentUser}
|
||||
simpleView={!!fromProfilePage}
|
||||
/>
|
||||
</ProfileCard>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{!isCurrentUser && currentUser && (
|
||||
<ProfileCard title={t('profile.connect.title', 'Connect')}>
|
||||
<Section id="connect" title={t('profile.connect.title', 'Connect')}>
|
||||
<ConnectActions user={user} profile={profile} />
|
||||
</ProfileCard>
|
||||
</Section>
|
||||
)}
|
||||
{!currentUser && (
|
||||
<SignUpButton
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
import {convertGender, Gender} from 'common/gender'
|
||||
import {getLocationText} from 'common/geodb'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {buildArray} from 'common/util/array'
|
||||
import {capitalize} from 'lodash'
|
||||
import {Calendar} from 'lucide-react'
|
||||
import {MdHeight} from 'react-icons/md'
|
||||
import {IconWithInfo} from 'web/components/icons'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import React from 'react'
|
||||
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
import GenderIcon from '../gender-icon'
|
||||
import {formatProfileValue} from '../profile-about'
|
||||
import {ProfileLocation} from './profile-location'
|
||||
|
||||
/**
|
||||
* Place, age, height, gender as one editorial line separated by hairline slashes.
|
||||
*
|
||||
* The icons this used to carry were doing no work — nobody needs a pictogram to parse "32" as an age
|
||||
* once it sits in this sequence — and four of them turned the line under the name into visual clutter
|
||||
* at exactly the point the eye should be moving fastest.
|
||||
*/
|
||||
export default function ProfilePrimaryInfo(props: {profile: Profile; short?: boolean}) {
|
||||
const {profile, short = false} = props
|
||||
const t = useT()
|
||||
const {measurementSystem} = useMeasurementSystem()
|
||||
|
||||
const parts = buildArray<React.ReactNode>(
|
||||
// Checked here rather than left to ProfileLocation: a component that renders null still counts as
|
||||
// an entry, which would leave the line starting on a stray separator.
|
||||
!!getLocationText(profile) && <ProfileLocation profile={profile} hideIcon />,
|
||||
profile.age && String(profile.age),
|
||||
!short &&
|
||||
profile.height_in_inches != null &&
|
||||
formatProfileValue('height_in_inches', profile.height_in_inches, measurementSystem, t),
|
||||
!short &&
|
||||
profile.gender &&
|
||||
capitalize(t(`profile.gender.${profile.gender}`, convertGender(profile.gender as Gender))),
|
||||
)
|
||||
|
||||
if (parts.length === 0) return null
|
||||
|
||||
return (
|
||||
<Row
|
||||
className="text-ink-500 gap-4 flex-wrap"
|
||||
<div
|
||||
className="text-ink-500 flex flex-wrap items-center"
|
||||
data-testid="profile-gender-location-height-inches"
|
||||
style={{fontSize: '13.5px', gap: '6px 18px'}}
|
||||
style={{fontSize: '15px'}}
|
||||
>
|
||||
<ProfileLocation profile={profile} />
|
||||
{profile.age && (
|
||||
<IconWithInfo
|
||||
text={t('profile.header.age', '{age} years old', {age: profile.age})}
|
||||
icon={<Calendar className="text-ink-300" style={{width: '14px', height: '14px'}} />}
|
||||
/>
|
||||
)}
|
||||
{!short && profile.height_in_inches != null && (
|
||||
<IconWithInfo
|
||||
text={formatProfileValue(
|
||||
'height_in_inches',
|
||||
profile.height_in_inches,
|
||||
measurementSystem,
|
||||
t,
|
||||
{parts.map((part, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<span aria-hidden className="text-ink-400 px-3.5 select-none">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
icon={<MdHeight className="text-ink-300" style={{width: '14px', height: '14px'}} />}
|
||||
/>
|
||||
)}
|
||||
{!short && profile.gender && (
|
||||
<IconWithInfo
|
||||
text={capitalize(
|
||||
t(`profile.gender.${profile.gender}`, convertGender(profile.gender as Gender)),
|
||||
)}
|
||||
icon={
|
||||
<GenderIcon
|
||||
gender={profile.gender as Gender}
|
||||
className="text-ink-300"
|
||||
// style={{width: '14px', height: '14px'}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
<span>{part}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,54 @@ export const CompatibleBadge = (props: {compatibility: CompatibilityScore; class
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same score as {@link CompatibleBadge}, given the room it deserves: a large figure over a filled
|
||||
* track, headed by a label.
|
||||
*
|
||||
* This is the most decision-relevant number on a profile, and as a badge it was a 12px pill wedged
|
||||
* between a search box and a dropdown. The bar carries the part the number alone does not — how far
|
||||
* along the range it sits — using the same track and fill as the Big Five rows.
|
||||
*/
|
||||
export const CompatibilityScoreBar = (props: {
|
||||
compatibility: CompatibilityScore
|
||||
/** Drop the caption where the surrounding heading already says "compatibility". */
|
||||
hideLabel?: boolean
|
||||
className?: string
|
||||
}) => {
|
||||
const {compatibility, hideLabel, className} = props
|
||||
const t = useT()
|
||||
const score = clamp(compatibility.score ?? 0, 0, 1)
|
||||
|
||||
return (
|
||||
<Tooltip text={t('compatibility.tooltip', 'Compatibility score between you two')}>
|
||||
<div className={clsx('w-full', className)} data-testid="compatibility-score-bar">
|
||||
<Row className="items-baseline justify-between gap-3">
|
||||
{!hideLabel && (
|
||||
<span
|
||||
className="text-ink-400 font-dm-sans uppercase"
|
||||
style={{fontSize: '10px', letterSpacing: '0.18em'}}
|
||||
>
|
||||
{t('compatibility.label', 'Compatibility')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-heading text-primary-900 tabular-nums"
|
||||
style={{fontSize: '26px', lineHeight: 1}}
|
||||
>
|
||||
{Math.round(score * 100)}%
|
||||
</span>
|
||||
</Row>
|
||||
<div className="bg-canvas-200 mt-2 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary-500 h-full rounded-full transition-[width] duration-500"
|
||||
style={{width: `${score * 100}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same score as {@link CompatibleBadge}, drawn as a progress ring. Used on profile cards where the
|
||||
* number has to be readable at a glance without competing with the name for attention.
|
||||
|
||||
113
web/components/widgets/scroll-panel.tsx
Normal file
113
web/components/widgets/scroll-panel.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import clsx from 'clsx'
|
||||
import {ChevronDownIcon} from 'lucide-react'
|
||||
import React, {ReactNode, useCallback, useEffect, useRef, useState} from 'react'
|
||||
|
||||
/**
|
||||
* A scrollable region that announces itself as one.
|
||||
*
|
||||
* A pinned panel with hidden overflow reads as a dead end — people assume what they see is all there
|
||||
* is. So three cues stack up here: a slim scrollbar that is always drawn (never an auto-hiding
|
||||
* overlay), a fade at whichever edge has content beyond it, and a chevron that sits over the bottom
|
||||
* fade until the visitor reaches the end. All three disappear on their own once the content fits or
|
||||
* the end is reached, so nothing is decorating a region that has nothing left to show.
|
||||
*/
|
||||
export function ScrollPanel(props: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/**
|
||||
* Applied to the scrolling element itself — put `overflow-y-auto` and the max-height here, together
|
||||
* and behind the same breakpoint. Where the panel is stacked in normal flow rather than pinned
|
||||
* beside something, it should not be a scroll container at all: with no height cap there is nothing
|
||||
* to scroll, and an `overflow` value alone still clips whatever tries to escape it.
|
||||
*/
|
||||
scrollClassName?: string
|
||||
/** Matches the panel's own background so the edge fades blend into it. */
|
||||
fadeColorClass?: string
|
||||
}) {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
scrollClassName,
|
||||
fadeColorClass = 'from-canvas-50 to-transparent',
|
||||
} = props
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [{atTop, atBottom, scrollable}, setState] = useState({
|
||||
atTop: true,
|
||||
atBottom: true,
|
||||
scrollable: false,
|
||||
})
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const overflow = el.scrollHeight - el.clientHeight
|
||||
const next = {
|
||||
scrollable: overflow > 4,
|
||||
atTop: el.scrollTop <= 4,
|
||||
// A 4px slack keeps sub-pixel layouts from leaving the cue stuck on at the very bottom.
|
||||
atBottom: overflow - el.scrollTop <= 4,
|
||||
}
|
||||
// Bail when nothing moved: this runs on every scroll event and from a ResizeObserver, and a state
|
||||
// object that is new each time would re-render the whole rail on each wheel tick.
|
||||
setState((prev) =>
|
||||
prev.scrollable === next.scrollable &&
|
||||
prev.atTop === next.atTop &&
|
||||
prev.atBottom === next.atBottom
|
||||
? prev
|
||||
: next,
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
|
||||
measure()
|
||||
|
||||
el.addEventListener('scroll', measure, {passive: true})
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
for (const child of Array.from(el.children)) observer.observe(child)
|
||||
window.addEventListener('resize', measure)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', measure)
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [measure])
|
||||
|
||||
const showTopFade = scrollable && !atTop
|
||||
const showBottomFade = scrollable && !atBottom
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<div ref={scrollRef} className={clsx('scrollbar-visible', scrollClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-x-0 top-0 h-10 rounded-t-[4px] bg-gradient-to-b transition-opacity duration-200',
|
||||
fadeColorClass,
|
||||
showTopFade ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 flex h-16 items-end justify-center rounded-b-[4px] bg-gradient-to-t pb-2 transition-opacity duration-200',
|
||||
fadeColorClass,
|
||||
showBottomFade ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<span className="border-canvas-300 bg-canvas-50 text-ink-500 flex h-6 w-6 items-center justify-center rounded-full border shadow-sm">
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,10 @@ function orderItems(field: DemographicField, items: Distribution['items']): Dist
|
||||
)
|
||||
}
|
||||
|
||||
function labelFor(field: DemographicField, value: string): string {
|
||||
// Exported so the home and about pages label the same raw values the same way this card does — they name a
|
||||
// few of these distributions in prose, and a "Bachelor's" here against a "bachelors" there would be the
|
||||
// kind of drift the shared choices maps exist to prevent.
|
||||
export function labelFor(field: DemographicField, value: string): string {
|
||||
// MBTI reads as the four-letter code plus its archetype — "INTJ" alone is jargon; "INTJ Architect"
|
||||
// tells the reader what it means without a second lookup.
|
||||
if (field === 'mbti') {
|
||||
@@ -77,16 +80,21 @@ function labelFor(field: DemographicField, value: string): string {
|
||||
return LABELS[field]?.[value] ?? capitalize(value)
|
||||
}
|
||||
|
||||
function DistRow({
|
||||
// Exported so the compact mini-charts on the home and about pages (a single field's top values, with no
|
||||
// surrounding card) draw the exact same bar as this one's own rows — one bar vocabulary everywhere a count
|
||||
// is shown as a fraction of a base, rather than a second one invented per page. `rank` defaults to 0
|
||||
// (no fade) for callers whose bar width is already the true percentage rather than normalised to a leader,
|
||||
// where the fade's job of distinguishing near-equal bars doesn't apply.
|
||||
export function DistRow({
|
||||
label,
|
||||
pct,
|
||||
widthPct,
|
||||
rank,
|
||||
rank = 0,
|
||||
}: {
|
||||
label: string
|
||||
pct: number
|
||||
widthPct: number
|
||||
rank: number
|
||||
rank?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
30
web/hooks/use-scrolled-past.ts
Normal file
30
web/hooks/use-scrolled-past.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
|
||||
/**
|
||||
* Tracks whether the observed element has scrolled out of view *above* the viewport.
|
||||
*
|
||||
* Used by the profile page to fade the identity + actions into the sticky top bar once the hero is
|
||||
* gone, so connecting stays one click away no matter how far down someone has read.
|
||||
*/
|
||||
export function useScrolledPast<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T | null>(null)
|
||||
const [scrolledPast, setScrolledPast] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// An element that has not been reached yet is also not intersecting, hence the top check.
|
||||
setScrolledPast(!entry.isIntersecting && entry.boundingClientRect.top < 0)
|
||||
},
|
||||
{threshold: 0},
|
||||
)
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return {ref, scrolledPast}
|
||||
}
|
||||
76
web/lib/marketing-stats.ts
Normal file
76
web/lib/marketing-stats.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {DemographicField, Stats} from 'common/stats'
|
||||
|
||||
/**
|
||||
* Turns the raw `stats` payload into the handful of claims the home and about pages make about who is on
|
||||
* Compass.
|
||||
*
|
||||
* Both pages describe the same population and must never disagree about it, so the arithmetic lives here
|
||||
* once rather than inline in two page components. Nothing is hardcoded, for the same reason `StatBand` and
|
||||
* `MemberGrowth` are not: a membership figure that quietly goes stale on a page arguing for transparency is
|
||||
* the failure worth engineering against. Every function returns `undefined` rather than a zero when the
|
||||
* data is missing, so a caller can render nothing at all — an empty frame claims more than it shows.
|
||||
*
|
||||
* ## Percentages only for single-select fields
|
||||
*
|
||||
* This is the load-bearing rule. `base` on a multi-select distribution counts *distinct answerers*, but the
|
||||
* per-value counts can overlap — one member who selects both "atheist" and "agnostic" is one in the base
|
||||
* and two in the sum. Adding those counts and dividing by the base therefore overstates, by an amount that
|
||||
* is not knowable from this payload.
|
||||
*
|
||||
* So: `shareOf` refuses to compute for a multi-select field, and multi-select claims are made with `topOf`,
|
||||
* which reports each value's own count against the base and lets the reader add them up. Both pages promise
|
||||
* that nothing on them is a claim you can't check; a percentage that could be silently inflated is exactly
|
||||
* the kind of claim that promise rules out.
|
||||
*/
|
||||
|
||||
export type Share = {
|
||||
/** Whole percent of the members who answered this field. */
|
||||
pct: number
|
||||
/** How many answered — the denominator, which both pages print next to the figure. */
|
||||
base: number
|
||||
}
|
||||
|
||||
export type TopValue = {
|
||||
/** Raw stored value (`'atheist'`, `'bachelors'`), for labelling by the caller. */
|
||||
value: string
|
||||
count: number
|
||||
base: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of respondents whose answer is one of `values`. Single-select fields only — see the note above on
|
||||
* why the multi-select case is deliberately not supported rather than approximated.
|
||||
*/
|
||||
export function shareOf(
|
||||
stats: Stats | undefined,
|
||||
field: DemographicField,
|
||||
values: string[],
|
||||
): Share | undefined {
|
||||
const dist = stats?.demographics?.[field]
|
||||
if (!dist?.base || dist.multi) return undefined
|
||||
const count = dist.items.reduce((n, i) => (values.includes(i.value) ? n + i.count : n), 0)
|
||||
if (!count) return undefined
|
||||
return {pct: Math.round((count / dist.base) * 100), base: dist.base}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `n` most common answers to a field, in the order the backend already ranked them. Safe for
|
||||
* multi-select fields because each count stands on its own instead of being summed.
|
||||
*/
|
||||
export function topOf(
|
||||
stats: Stats | undefined,
|
||||
field: DemographicField,
|
||||
n: number,
|
||||
): TopValue[] | undefined {
|
||||
const dist = stats?.demographics?.[field]
|
||||
if (!dist?.base || !dist.items.length) return undefined
|
||||
return dist.items.slice(0, n).map((i) => ({value: i.value, count: i.count, base: dist.base}))
|
||||
}
|
||||
|
||||
/** How many members answered a field at all. */
|
||||
export function baseOf(stats: Stats | undefined, field: DemographicField): number | undefined {
|
||||
return stats?.demographics?.[field]?.base || undefined
|
||||
}
|
||||
|
||||
/** The age buckets that make up "between 25 and 44". The backend emits these labels verbatim. */
|
||||
export const CORE_AGE_BUCKETS = ['25–34', '35–44']
|
||||
@@ -1,37 +1,57 @@
|
||||
import {
|
||||
ArrowsUpDownIcon,
|
||||
BanknotesIcon,
|
||||
BellIcon,
|
||||
BookmarkIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
CodeBracketIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
EnvelopeIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
FlagIcon,
|
||||
GiftIcon,
|
||||
HeartIcon,
|
||||
LightBulbIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MegaphoneIcon,
|
||||
PencilSquareIcon,
|
||||
SparklesIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import {GlobeAltIcon} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import {discordLink, formLink, githubRepo, OG_DESCRIPTION} from 'common/constants'
|
||||
import {
|
||||
ANDROID_APP_URL,
|
||||
discordLink,
|
||||
FINANCIALS,
|
||||
formLink,
|
||||
githubRepo,
|
||||
OG_DESCRIPTION,
|
||||
} from 'common/constants'
|
||||
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||
import {DemographicField} from 'common/stats'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {ComponentType, ReactNode, SVGProps} from 'react'
|
||||
import {FaAndroid, FaApple} from 'react-icons/fa'
|
||||
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 {PageBase} from 'web/components/page-base'
|
||||
import {PressLogos} from 'web/components/press/press-logos'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {MemberGrowth} from 'web/components/widgets/charts'
|
||||
import {Reveal} from 'web/components/widgets/reveal'
|
||||
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
|
||||
import {DistRow, labelFor} from 'web/components/widgets/stat-distribution'
|
||||
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
|
||||
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
import {CORE_AGE_BUCKETS, shareOf, topOf} from 'web/lib/marketing-stats'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,26 +109,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" ─────────────────────────────────
|
||||
|
||||
@@ -261,6 +281,439 @@ function MissionStatement({title, text}: {title: string; text: string}) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Prose blocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The shape every new text-only section on this page uses: eyebrow label, heading, body, optional links.
|
||||
*
|
||||
* The page had no vocabulary for prose — everything was a card with a fragment in it, which is why it
|
||||
* could show a ballot without explaining the machinery behind it, or state "detailed profiles" without ever
|
||||
* listing a field. Several of the things a reader most wants here (what's public, who runs it, what it
|
||||
* costs) are three sentences, not a fragment, and this is where they live.
|
||||
*/
|
||||
function ProseBlock({
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
links,
|
||||
visual,
|
||||
}: {
|
||||
icon: IconType
|
||||
label: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
links?: {href: string; label: string; external?: boolean}[]
|
||||
// Optional right-hand column for a block that has something to show, not just say — e.g. the costs
|
||||
// block's funding bar. Without it, text capped at `max-w-2xl` inside a full-bleed card leaves a wide
|
||||
// empty strip on desktop; `visual` is where that space goes to work instead of sitting blank.
|
||||
visual?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<div className={clsx(visual && 'lg:flex lg:items-start lg:justify-between lg:gap-10')}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<IconChip icon={icon} />
|
||||
<p className={clsx(eyebrow, 'text-primary-700')}>{label}</p>
|
||||
</div>
|
||||
<h3 className="font-heading font-bold text-ink-900 text-[clamp(20px,2.4vw,28px)] leading-[1.2] tracking-tight mt-0 mb-4 max-w-2xl text-balance">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="text-base text-ink-600 leading-relaxed max-w-2xl [&>p+p]:mt-4">
|
||||
{children}
|
||||
</div>
|
||||
{links && (
|
||||
<div className="mt-5 flex flex-wrap gap-x-5 gap-y-2">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target={l.external ? '_blank' : undefined}
|
||||
rel={l.external ? 'noopener noreferrer' : undefined}
|
||||
className="inline-flex w-fit items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visual}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "Four steps, and you control all four." — the one concrete number already stated in
|
||||
* each step's own text (20 minutes, 20+ filters, one tap, 200 characters), pulled out as a 2×2 grid rather
|
||||
* than left buried in the paragraphs. Static: these are product facts, not measurements, so nothing here is
|
||||
* fetched or can go stale. Deliberately not a screenshot or mockup — `NotifySpotlight` right below already
|
||||
* carries the visual weight for step 3, and reusing a phone clip or composer mock here would just repeat a
|
||||
* visual the page (or /) already shows.
|
||||
*
|
||||
* Currently disabled (see the commented-out call in `HowItWorks`) — kept rather than deleted in case the
|
||||
* empty column next to the step list wants filling again later.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function StepStats() {
|
||||
const t = useT()
|
||||
|
||||
const stats = [
|
||||
{value: '20', label: t('about.how.stat.write', 'min to write a profile')},
|
||||
{value: '20+', label: t('about.how.stat.search', 'filters to search with')},
|
||||
{value: '1', label: t('about.how.stat.save', 'tap to save a search')},
|
||||
{value: '200', label: t('about.how.stat.message', 'char minimum to message')},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-8 grid grid-cols-2 gap-3 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-lg border border-canvas-300 bg-canvas-0 p-3">
|
||||
<div className="text-2xl font-bold text-primary-600">{s.value}</div>
|
||||
<div className="mt-1 text-xs leading-snug text-ink-500">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "How it works" — the loop, in four steps.
|
||||
*
|
||||
* The page used to open on the stat band and go straight into saved-search alerts, which is step three of
|
||||
* four: a reader who has never used Compass was being sold the clever part before the basic one. Step 3 is
|
||||
* deliberately one line, because `NotifySpotlight` immediately below it is that step at full length with
|
||||
* the recording attached.
|
||||
*/
|
||||
function HowItWorks() {
|
||||
const t = useT()
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: PencilSquareIcon,
|
||||
title: t('about.how.step1.title', 'Write a profile'),
|
||||
text: t(
|
||||
'about.how.step1.text',
|
||||
'Twenty minutes, not two: a bio in your own words, prompt answers, the causes you care about, your politics, religion, diet, languages, education, personality type, and what you’re looking for. Every field is optional — what you leave blank stays blank.',
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: MagnifyingGlassIcon,
|
||||
title: t('about.how.step2.title', 'Search everyone'),
|
||||
text: t(
|
||||
'about.how.step2.text',
|
||||
'No feed, no queue, no daily allowance of people. More than twenty filters, plus free-text search that reads the prose in people’s bios — so "meditation" finds the person who wrote about it, not only the one who tagged it.',
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: BookmarkIcon,
|
||||
title: t('about.how.step3.title', 'Save the search'),
|
||||
text: t(
|
||||
'about.how.step3.text',
|
||||
'Nobody fits today? Save it, and we email you the day someone who does joins — the block below shows exactly how that works.',
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: ChatBubbleLeftRightIcon,
|
||||
title: t('about.how.step4.title', 'Write something real'),
|
||||
text: t(
|
||||
'about.how.step4.text',
|
||||
'A first message is 200 characters minimum and needs a verified email address. No "hey": the composer will not send until you’ve said something about the person you’re writing to.',
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<div className="lg:flex lg:items-start lg:justify-between lg:gap-10">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading font-bold text-ink-900 text-[clamp(20px,2.4vw,28px)] leading-[1.2] tracking-tight mt-0 mb-6 text-balance">
|
||||
{t('about.how.title', 'Four steps, and you control all four.')}
|
||||
</h3>
|
||||
<ol className="max-w-2xl">
|
||||
{steps.map((s, i) => (
|
||||
<FlowStep
|
||||
key={s.title}
|
||||
icon={s.icon}
|
||||
title={s.title}
|
||||
text={s.text}
|
||||
last={i === steps.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
{/*<StepStats />*/}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Who's here" — the membership, with its denominators.
|
||||
*
|
||||
* The long version of the home page's three-line qualifier. Home has to persuade a stranger in seconds and
|
||||
* cannot spend a clause on "of the 394 who answered"; someone reading this page is deciding whether to
|
||||
* publish a page about themselves, and the bases are the reason to believe the rest.
|
||||
*
|
||||
* No total here — `StatBand` states it once at the top of the page, and that is this page's whole allowance
|
||||
* for it. Everything below is composition, which persuades and does not shrink.
|
||||
*
|
||||
* Percentages only for single-select fields; multi-select rows report each answer's own count. See
|
||||
* `web/lib/marketing-stats.ts` — summing overlapping selections against a distinct-answerer base would
|
||||
* overstate by an unknowable amount, and this page has just promised the reader it will not do that.
|
||||
*/
|
||||
function WhosHere() {
|
||||
const t = useT()
|
||||
const {data} = useAPIGetter('stats', {})
|
||||
|
||||
const age = shareOf(data, 'age', CORE_AGE_BUCKETS)
|
||||
const gender = shareOf(data, 'gender', ['male'])
|
||||
const degree = shareOf(data, 'education_level', ['bachelors', 'masters', 'doctorate'])
|
||||
const rows: {label: string; base: number; barLabel: string; pct: number}[] = []
|
||||
|
||||
if (age) {
|
||||
rows.push({
|
||||
label: t('about.who.row.age', 'Age'),
|
||||
base: age.base,
|
||||
barLabel: t('about.who.bar.age', '25–44'),
|
||||
pct: age.pct,
|
||||
})
|
||||
}
|
||||
if (gender) {
|
||||
rows.push({
|
||||
label: t('about.who.row.gender', 'Gender'),
|
||||
base: gender.base,
|
||||
barLabel: t('about.who.bar.gender', 'Men'),
|
||||
pct: gender.pct,
|
||||
})
|
||||
}
|
||||
if (degree) {
|
||||
rows.push({
|
||||
label: t('about.who.row.education', 'Education'),
|
||||
base: degree.base,
|
||||
barLabel: t('about.who.bar.education', 'Bachelor’s+'),
|
||||
pct: degree.pct,
|
||||
})
|
||||
}
|
||||
|
||||
// Religion and looking-for are multi-select, so only the single leading answer is shown — one line per
|
||||
// field, matching age/gender/education, rather than the full ranked list. The complete breakdown for
|
||||
// every field (including the ones cut here) is one click away at /stats; see `web/lib/marketing-stats.ts`
|
||||
// for why a multi-select percentage is of respondents and can't be summed with the others.
|
||||
const leading: [DemographicField, string][] = [
|
||||
['religion', t('about.who.row.religion', 'Religion')],
|
||||
['pref_relation_styles', t('about.who.row.looking', 'Looking for')],
|
||||
]
|
||||
for (const [field, label] of leading) {
|
||||
const top = topOf(data, field, 1)?.[0]
|
||||
if (!top) continue
|
||||
rows.push({
|
||||
label,
|
||||
base: top.base,
|
||||
barLabel: labelFor(field, top.value),
|
||||
pct: Math.max(1, Math.round((top.count / top.base) * 100)),
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length < 3) return null
|
||||
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<h3 className="font-heading font-bold text-ink-900 text-[clamp(20px,2.4vw,28px)] leading-[1.2] tracking-tight mt-0 mb-4 text-balance">
|
||||
{t('about.who.title', 'The membership, in public.')}
|
||||
</h3>
|
||||
<p className="text-base text-ink-600 leading-relaxed max-w-2xl">
|
||||
{t(
|
||||
'about.who.intro.v3',
|
||||
'Members skew single, highly educated, secular, and more plant-based than average — spread across more than fifty countries, with no single hub.',
|
||||
)}
|
||||
</p>
|
||||
<dl className="mt-6 max-w-xl divide-y divide-canvas-200">
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex items-center gap-6 py-3">
|
||||
<dt className="w-24 flex-shrink-0 font-semibold text-ink-900 sm:w-32">
|
||||
{r.label}{' '}
|
||||
{/*<span className="font-normal text-ink-500">({r.base.toLocaleString()})</span>*/}
|
||||
</dt>
|
||||
<dd className="m-0 flex-1 min-w-0">
|
||||
<DistRow label={r.barLabel} pct={r.pct} widthPct={r.pct} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Link
|
||||
href="/stats"
|
||||
className="mt-5 inline-flex w-fit items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{t('about.who.link', 'Every distribution out here →')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The funding bar beside "What it costs" — one stacked bar showing how the total spent breaks down between
|
||||
* what members donated and what the founder covered, instead of leaving a reader to do that subtraction
|
||||
* from three numbers in a paragraph.
|
||||
*
|
||||
* Donated and founder-covered are two shares of one measure (dollars spent), not two independent series, so
|
||||
* this stays single-hue rather than reaching for a second color: the accent at full strength is "covered by
|
||||
* the community", the same accent faded is "covered by the founder" — the exact opacity convention `DistRow`
|
||||
* already uses to relate values within one field. A small flex gap between the two segments keeps them from
|
||||
* reading as one continuous bar. Values come from `FINANCIALS`, the same constant the surrounding prose and
|
||||
* the home-page strip read from, so the bar can never disagree with the sentence beside it.
|
||||
*/
|
||||
function CostsChart() {
|
||||
const t = useT()
|
||||
const {spent, donated, deficit} = FINANCIALS
|
||||
const donatedPct = Math.round((donated / spent) * 100)
|
||||
const deficitPct = 100 - donatedPct
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-ink-500">
|
||||
{t('about.costs.chart.title', 'Since launch')}
|
||||
</p>
|
||||
<p className="mb-4 text-2xl font-bold text-ink-900">${spent}</p>
|
||||
<div className="flex h-2.5 w-full gap-0.5 overflow-hidden rounded-full bg-canvas-200">
|
||||
<div className="h-full rounded-full bg-primary-500" style={{width: `${donatedPct}%`}} />
|
||||
<div className="h-full rounded-full bg-primary-500/30" style={{width: `${deficitPct}%`}} />
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 text-[13px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-primary-500" />
|
||||
<span className="text-ink-700">{t('about.costs.chart.donated', 'Donated')}</span>
|
||||
<span className="ml-auto tabular-nums text-ink-500">
|
||||
${donated} · {donatedPct}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-primary-500/30" />
|
||||
<span className="text-ink-700">{t('about.costs.chart.deficit', 'Founder-covered')}</span>
|
||||
<span className="ml-auto tabular-nums text-ink-500">
|
||||
${deficit} · {deficitPct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "Your profile is a web page... and it's a switch." — the two states the prose
|
||||
* describes, stacked as they'd actually appear, rather than left as an adjective ("public", "members-only")
|
||||
* a reader has to picture. Static: this is a UI mechanism, not a number, so nothing here is fetched.
|
||||
*/
|
||||
function VisibilityToggleMock() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-canvas-300 bg-canvas-0 p-3">
|
||||
<GlobeAltIcon className="h-4 w-4 flex-shrink-0 text-primary-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="whitespace-nowrap text-[13px] font-semibold text-ink-900">
|
||||
{t('about.public.mock.public', 'Public')}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
{t('about.public.mock.public_sub', 'Anyone can find it')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-2 flex justify-center">
|
||||
<ArrowsUpDownIcon className="h-4 w-4 text-ink-400" strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-canvas-300 bg-canvas-0 p-3">
|
||||
<EyeSlashIcon className="h-4 w-4 flex-shrink-0 text-ink-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="whitespace-nowrap text-[13px] font-semibold text-ink-900">
|
||||
{t('about.public.mock.members', 'Members-only')}
|
||||
</div>
|
||||
<div className="text-xs text-ink-500">
|
||||
{t('about.public.mock.members_sub', 'Hidden, unindexed')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-ink-500">
|
||||
{t('about.public.mock.caption', 'One tap switches between them, any time.')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The Press section's logo strip now lives in `components/press/press-logos.tsx` — `/press` needs the same
|
||||
* four marks, both as this strip and beside each individual article, and two copies of the per-logo height
|
||||
* tuning would drift. See that file for why the logos keep their native color.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The visual beside "Browser, Android, and a home-screen app on iPhone." — the three platforms named as
|
||||
* rows instead of left as a sentence to parse. Static: this is where the product runs, not a number, so
|
||||
* nothing here is fetched.
|
||||
*/
|
||||
function PlatformGlyphs() {
|
||||
const t = useT()
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
icon: GlobeAltIcon,
|
||||
name: t('about.platforms.mock.browser', 'Browser'),
|
||||
sub: t('about.platforms.mock.browser_sub', 'Any device'),
|
||||
},
|
||||
{
|
||||
icon: FaAndroid,
|
||||
name: t('about.platforms.mock.android', 'Android'),
|
||||
sub: t('about.platforms.mock.android_sub', 'Google Play'),
|
||||
},
|
||||
{
|
||||
icon: FaApple,
|
||||
name: t('about.platforms.mock.iphone', 'iPhone'),
|
||||
sub: t('about.platforms.mock.iphone_sub', 'Add to Home Screen'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-2 rounded-xl bg-canvas-100 ring-1 ring-canvas-200 p-5 sm:p-6 lg:mt-0 lg:w-72 lg:flex-shrink-0">
|
||||
{platforms.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-3 rounded-lg border border-canvas-300 bg-canvas-0 p-3"
|
||||
>
|
||||
<p.icon className="h-4 w-4 flex-shrink-0 text-primary-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="whitespace-nowrap text-[13px] font-semibold text-ink-900">{p.name}</div>
|
||||
<div className="text-xs text-ink-500">{p.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The visual beside "Somebody has to start these things." — Martin's own photo, fetched live from his
|
||||
* public profile rather than a static asset, so it can't drift if he changes it. This is the one member
|
||||
* photo either marketing page shows: the "no real photos without consent" rule (see the "Deliberately out
|
||||
* of scope" section of `docs/marketing-copy.md`) is about the membership generally, and obviously doesn't
|
||||
* apply to the founder writing about himself in his own section. Renders nothing if the profile or its
|
||||
* photo comes back empty rather than a broken image or an initials placeholder — same rule as `StatBand`.
|
||||
*/
|
||||
function FounderPhoto() {
|
||||
const {data} = useAPIGetter('get-user-and-profile', {username: 'Martin'})
|
||||
const photoUrl = data?.profile?.pinned_url
|
||||
|
||||
if (!photoUrl) return null
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 h-40 w-40 flex-shrink-0 overflow-hidden rounded-2xl ring-1 ring-canvas-200 lg:mt-0">
|
||||
<Image src={photoUrl} alt="Martin Braquet" fill sizes="160px" className="object-cover" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Help Cards ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -295,7 +748,7 @@ function FeaturedHelpCard({icon, title, text, buttonLabel, buttonUrl, id}: HelpC
|
||||
href={buttonUrl}
|
||||
target={buttonUrl.startsWith('http') ? '_blank' : undefined}
|
||||
rel={buttonUrl.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="shrink-0 px-7 py-3.5 rounded-xl bg-transparent text-ink-900 font-semibold text-[15px] border-2 border-canvas-200 hover:border-primary-500 hover:text-primary-500 hover:-translate-y-0.5 transition-all duration-200 ease-out"
|
||||
className="inline-flex w-fit shrink-0 px-7 py-3.5 rounded-xl bg-transparent text-ink-900 font-semibold text-[15px] border-2 border-canvas-200 hover:border-primary-500 hover:text-primary-500 hover:-translate-y-0.5 transition-all duration-200 ease-out"
|
||||
>
|
||||
{buttonLabel}
|
||||
</Link>
|
||||
@@ -533,9 +986,13 @@ export default function About() {
|
||||
},
|
||||
{
|
||||
icon: GlobeAltIcon,
|
||||
title: t('about.block.vision.title', 'Digital Public Good'),
|
||||
// Its own keys. This card was borrowing `about.block.vision.*`, whose fr/de values are the longer
|
||||
// Linux / Wikipedia / Firefox sentence — so French and German readers saw that sentence on a card
|
||||
// whose English reads "Built by the people who use it". The vision line now has that key back, in
|
||||
// the mission section where it belongs, and this card says only what it says.
|
||||
title: t('about.block.public_good.title', 'Digital Public Good'),
|
||||
text: t(
|
||||
'about.block.vision.text',
|
||||
'about.block.public_good.text',
|
||||
'Built by the people who use it, for the benefit of everyone.',
|
||||
),
|
||||
},
|
||||
@@ -588,9 +1045,12 @@ export default function About() {
|
||||
icon: HeartIcon,
|
||||
id: 'donate',
|
||||
title: t('about.donate.title', 'Donate'),
|
||||
// "Every contribution keeps the lights on" is a charity cliché that the "What it costs" block above
|
||||
// makes redundant and slightly evasive. Same ask, with the receipt.
|
||||
text: t(
|
||||
'about.donate.text',
|
||||
'Support our not-for-profit infrastructure. Every contribution keeps the lights on.',
|
||||
'about.donate.text.v2',
|
||||
'${spent} spent, ${donated} donated, no salaries. Every expense is published before we ask again.',
|
||||
{spent: FINANCIALS.spent, donated: FINANCIALS.donated},
|
||||
),
|
||||
buttonLabel: t('about.donate.button', 'Donation Options →'),
|
||||
buttonUrl: '/support',
|
||||
@@ -614,13 +1074,17 @@ export default function About() {
|
||||
<p className={clsx(eyebrow, 'text-primary-700 mb-4')}>
|
||||
{t('about.eyebrow', 'About Compass')}
|
||||
</p>
|
||||
{/* "Why choose" is a shopping-comparison frame on a page whose job is evidence, and it argued
|
||||
Compass was better before saying what it is — which is precisely the reader arriving cold
|
||||
from a press article. New keys rather than reworded ones, since fr/de resolve ahead of these
|
||||
fallbacks. */}
|
||||
<h1 className="text-[clamp(34px,5vw,56px)] text-ink-900 tracking-tight leading-[1.08] mb-5 max-w-3xl text-balance">
|
||||
{t('about.title', 'Why Choose Compass?')}
|
||||
{t('about.title.v2', 'What Compass is, and how it works.')}
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-ink-700 max-w-xl leading-relaxed">
|
||||
<p className="text-lg sm:text-xl text-ink-700 max-w-2xl leading-relaxed">
|
||||
{t(
|
||||
'about.subtitle',
|
||||
'To find your people with ease — based on who they are, not how they look.',
|
||||
'about.subtitle.v2',
|
||||
'A free, public directory of people looking for depth — friends, partners, or collaborators. Built by volunteers, funded by donations, governed by its members.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -630,6 +1094,16 @@ export default function About() {
|
||||
empty, in which case the header simply meets the feature grid as it did before. */}
|
||||
<StatBand />
|
||||
|
||||
{/* ── How it works ──
|
||||
Ahead of the feature blocks, because the block below it is step three of the loop and the page
|
||||
never described steps one, two and four at all. */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.how.label', 'How it works')}</SectionLabel>
|
||||
<Reveal>
|
||||
<HowItWorks />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── Features ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.features.label', 'What makes us different')}</SectionLabel>
|
||||
@@ -659,6 +1133,49 @@ export default function About() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Who's here ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.who.label', 'Who’s here')}</SectionLabel>
|
||||
<Reveal>
|
||||
<WhosHere />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── What's public ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.public.label', 'What’s public')}</SectionLabel>
|
||||
<Reveal>
|
||||
<ProseBlock
|
||||
icon={EyeIcon}
|
||||
label={t('about.public.eyebrow', 'Your profile')}
|
||||
title={t(
|
||||
'about.public.title',
|
||||
'Your profile is a web page. That’s the point — and it’s a switch.',
|
||||
)}
|
||||
visual={<VisibilityToggleMock />}
|
||||
>
|
||||
<p>
|
||||
{t(
|
||||
'about.public.p1',
|
||||
'Compass is a directory, so by default your profile is a public page that anyone can read and search engines can index. That is the deal that makes searching work at all: everyone can be found, so you can find anyone.',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'about.public.p2',
|
||||
'If that isn’t what you want, one tap makes your profile members-only — logged-out visitors see nothing, and we tell search engines not to index it. You can switch back whenever you like.',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'about.public.p3',
|
||||
'The rest is yours too: hide profiles you’d rather not see, block someone outright, report them to moderators, download everything we hold about you, or delete your account and its data for good. Messages are encrypted at rest. Nothing you write is sold — there is nobody to sell it to.',
|
||||
)}
|
||||
</p>
|
||||
</ProseBlock>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── Mission ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.mission.label', 'Why we exist')}</SectionLabel>
|
||||
@@ -672,31 +1189,171 @@ 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>*/}
|
||||
Mission" has been promoted, and a one-item grid is just an indirection.
|
||||
Restored from the home page, where it was the closing quote. It is a claim a first-time
|
||||
visitor cannot verify, sitting where they most needed a concrete reason; next to a mission
|
||||
statement on the reference page it reads as positioning instead of as self-congratulation.
|
||||
The `about.block.vision.*` keys already carry this exact sentence in fr and de — which is why
|
||||
the "Digital Public Good" card above had to be given keys of its own, since it was borrowing
|
||||
these and therefore rendering the Linux line to every French and German reader. */}
|
||||
<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>
|
||||
|
||||
{/* The section used to be a ballot with no explanation of the machinery behind it. */}
|
||||
{/*<p className="mb-5 max-w-3xl text-base leading-relaxed text-ink-600">*/}
|
||||
{/* {t(*/}
|
||||
{/* 'about.vote.intro',*/}
|
||||
{/* 'Compass runs on a written constitution. Members who contribute — five hours of work, or $20 a year — can become voting members; new administrators need a unanimous vote of the current ones. Proposals are public, ballots are public, and the outcome binds the project. The vote below is a real one.',*/}
|
||||
{/* )}*/}
|
||||
{/*</p>*/}
|
||||
|
||||
<Reveal>
|
||||
<VoteEvidence />
|
||||
</Reveal>
|
||||
|
||||
{/* The turnout tension, named rather than managed. Twelve voters is a small number and hiding it
|
||||
would cost more than it saves on the one page that has just promised the reader every claim
|
||||
is checkable. */}
|
||||
{/*<p className="mt-4 max-w-3xl text-base leading-relaxed text-ink-600">*/}
|
||||
{/* {t(*/}
|
||||
{/* 'about.vote.kicker',*/}
|
||||
{/* 'Twelve voters is a small turnout. We would rather show you the real one than a bigger number that isn’t real.',*/}
|
||||
{/* )}*/}
|
||||
{/* <span className="mt-2 flex flex-wrap gap-x-5 gap-y-2">*/}
|
||||
{/* {[*/}
|
||||
{/* {href: '/constitution', label: t('about.vote.constitution', 'The constitution →')},*/}
|
||||
{/* {href: '/vote', label: t('about.vote.votes', 'Open votes →')},*/}
|
||||
{/* {href: '/members', label: t('about.vote.members', 'Voting members →')},*/}
|
||||
{/* ].map((l) => (*/}
|
||||
{/* <Link*/}
|
||||
{/* key={l.href}*/}
|
||||
{/* href={l.href}*/}
|
||||
{/* className="text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"*/}
|
||||
{/* >*/}
|
||||
{/* {l.label}*/}
|
||||
{/* </Link>*/}
|
||||
{/* ))}*/}
|
||||
{/* </span>*/}
|
||||
{/*</p>*/}
|
||||
|
||||
<Reveal>
|
||||
<RepoActivity className="mt-4 sm:mt-5" />
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ── Who's behind it, what it costs, where it runs ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.who_runs.label', 'Who runs it')}</SectionLabel>
|
||||
|
||||
<div className="grid gap-4 sm:gap-5">
|
||||
<Reveal>
|
||||
<ProseBlock
|
||||
icon={UserGroupIcon}
|
||||
label={t('about.founder.eyebrow', 'Who’s behind it')}
|
||||
title={t('about.founder.title', 'Somebody has to start these things.')}
|
||||
links={[{href: '/Martin', label: t('about.founder.link', 'Martin’s profile →')}]}
|
||||
visual={<FounderPhoto />}
|
||||
>
|
||||
<p>
|
||||
{t(
|
||||
'about.founder.p1',
|
||||
'Compass was started by Martin Braquet, an engineer from Belgium, after years of living across Europe, the U.S., India and Indonesia and finding that the connections that mattered most were the hardest to make on purpose. He still stewards and actively improves the project, but the constitution puts direction in the members’ hands.',
|
||||
)}
|
||||
</p>
|
||||
<p>{t('about.founder.p2', 'Everyone who has built Compass is a volunteer.')}</p>
|
||||
</ProseBlock>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={70}>
|
||||
<ProseBlock
|
||||
icon={BanknotesIcon}
|
||||
label={t('about.costs.eyebrow', 'What it costs')}
|
||||
title={t('about.costs.title', 'The entire budget, in three numbers.')}
|
||||
links={[
|
||||
{href: '/financials', label: t('about.costs.books', 'See the books →')},
|
||||
{href: '/support', label: t('about.costs.donate', 'Donate →')},
|
||||
]}
|
||||
visual={<CostsChart />}
|
||||
>
|
||||
{/* Figures come from `FINANCIALS` so this block, the home strip and /financials cannot
|
||||
quote three different numbers for the one claim the page is staking itself on. */}
|
||||
<p>
|
||||
{t(
|
||||
'about.costs.p1',
|
||||
'Since launch, Compass has cost ${spent} to run — hosting, infrastructure, domains. Members have donated ${donated}. The remaining ${deficit} came out of the founder’s pocket. There are no salaries, no marketing budget and no private costs, and every line of it is published.',
|
||||
{
|
||||
spent: FINANCIALS.spent,
|
||||
donated: FINANCIALS.donated,
|
||||
deficit: FINANCIALS.deficit,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</ProseBlock>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<ProseBlock
|
||||
icon={DevicePhoneMobileIcon}
|
||||
label={t('about.platforms.eyebrow', 'Where it runs')}
|
||||
title={t('about.platforms.title', 'Browser, Android, and iPhone.')}
|
||||
links={[
|
||||
{
|
||||
href: ANDROID_APP_URL,
|
||||
label: t('about.platforms.android', 'Android app →'),
|
||||
external: true,
|
||||
},
|
||||
]}
|
||||
visual={<PlatformGlyphs />}
|
||||
>
|
||||
<p>
|
||||
{t(
|
||||
'about.platforms.p1',
|
||||
'The web app works in any browser. There’s an Android app on Google Play. On iPhone, add Compass to your home screen from Safari and it behaves like an app. The interface is in English, French and German, and there is a public API.',
|
||||
)}
|
||||
</p>
|
||||
</ProseBlock>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Press ──
|
||||
Its own section rather than folded into "Where it runs": that block is about platforms, this
|
||||
one is proof a stranger can check outside the site entirely. Unlike home's one-line mention,
|
||||
this is the "full" version the outlets themselves get named with — real logos, fetched from
|
||||
each outlet's own official site (dhnet.be, lavenir.net, matele.be, rcf.be), not a third-party
|
||||
logo aggregator or a Wikipedia fair-use scan. */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.press.label', 'Press')}</SectionLabel>
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<p className="mb-6 max-w-2xl text-base leading-relaxed text-ink-600">
|
||||
{t(
|
||||
'about.press.text',
|
||||
'Compass has been covered by RCF, La DH, L’Avenir and Matélé.',
|
||||
)}
|
||||
</p>
|
||||
<PressLogos />
|
||||
<Link
|
||||
href="/press"
|
||||
className="mt-6 inline-flex w-fit items-center text-sm font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
{t('about.press.link', 'Read the coverage →')}
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ── Help ── */}
|
||||
<Section>
|
||||
<SectionLabel>{t('about.help.label', 'Help Compass grow')}</SectionLabel>
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ClipboardDocumentIcon,
|
||||
EnvelopeIcon,
|
||||
MicrophoneIcon,
|
||||
NewspaperIcon,
|
||||
PlayCircleIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import clsx from 'clsx'
|
||||
import {githubRepo, supportEmail} from 'common/constants'
|
||||
import Link from 'next/link'
|
||||
import {ComponentType, ReactNode, SVGProps, useMemo, useState} from 'react'
|
||||
import {SectionLabel} from 'web/components/about/section'
|
||||
import {PageBase} from 'web/components/page-base'
|
||||
import {OutletId, OutletLogo, OUTLETS, PressLogos} from 'web/components/press/press-logos'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {Reveal} from 'web/components/widgets/reveal'
|
||||
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
|
||||
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||
import {useLocale, useT} from 'web/lib/locale'
|
||||
import {copyToClipboard} from 'web/lib/util/copy'
|
||||
|
||||
type IconType = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
/**
|
||||
* What kind of coverage an item is.
|
||||
*
|
||||
* Not decoration: a reader scanning this page wants to know whether a link costs them thirty seconds or
|
||||
* half an hour, and "RCF" alone does not say "this is a radio interview". It also picks the verb on the
|
||||
* call to action — "Listen", "Watch", "Read" — which was previously a bare underlined title for all five.
|
||||
*/
|
||||
type Medium = 'article' | 'radio' | 'video'
|
||||
|
||||
type PressItem = {
|
||||
id: number
|
||||
title: string
|
||||
source: string
|
||||
outlet: OutletId
|
||||
medium: Medium
|
||||
date: string
|
||||
url: string
|
||||
summary?: string
|
||||
@@ -17,7 +48,8 @@ const pressItems: PressItem[] = [
|
||||
{
|
||||
id: 5,
|
||||
title: 'Compass, une application de rencontre belge',
|
||||
source: 'RCF',
|
||||
outlet: 'rcf',
|
||||
medium: 'radio',
|
||||
date: '2026-02-09',
|
||||
url: 'https://www.rcf.fr/culture/le-temps-dun-cafe?episode=657722',
|
||||
summary:
|
||||
@@ -28,7 +60,8 @@ const pressItems: PressItem[] = [
|
||||
id: 4,
|
||||
title:
|
||||
'Un Havelangeois lance Compass, une appli de rencontre qui mise avant tout sur la personnalité : "Les recherches se font via des mots-clés spécifiques"',
|
||||
source: 'La DH',
|
||||
outlet: 'dh',
|
||||
medium: 'article',
|
||||
date: '2026-01-21',
|
||||
url: 'https://www.dhnet.be/regions/namur/2026/01/21/un-havelangeois-lance-compass-une-appli-de-rencontre-qui-mise-avant-tout-sur-la-personnalite-les-recherches-se-font-via-des-mots-cles-specifiques-6ZBEE4GNVZHHZBWH5PFXNLD4WI/',
|
||||
summary:
|
||||
@@ -39,7 +72,8 @@ const pressItems: PressItem[] = [
|
||||
id: 3,
|
||||
title:
|
||||
'Un Havelangeois lance Compass, une appli de rencontre qui mise avant tout sur la personnalité : "Les recherches se font via des mots-clés spécifiques"',
|
||||
source: "L'Avenir",
|
||||
outlet: 'lavenir',
|
||||
medium: 'article',
|
||||
date: '2026-01-21',
|
||||
url: 'https://www.lavenir.net/regions/namur/2026/01/21/un-havelangeois-lance-compass-une-appli-de-rencontre-qui-mise-avant-tout-sur-la-personnalite-les-recherches-se-font-via-des-mots-cles-specifiques-LPAHVUX5VFAOFGZ4X3UJDXZD2Q/',
|
||||
language: 'fr',
|
||||
@@ -47,7 +81,8 @@ const pressItems: PressItem[] = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Martin Braquet, un jeune ingénieur havelangeois, sort son appli de rencontre éthique.',
|
||||
source: 'Matélé',
|
||||
outlet: 'matele',
|
||||
medium: 'video',
|
||||
date: '2026-01-17',
|
||||
url: 'https://www.facebook.com/reel/757129776892904',
|
||||
summary:
|
||||
@@ -57,7 +92,8 @@ const pressItems: PressItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Une application qui réinvente les rencontres en ligne développée par un Havelangeois',
|
||||
source: 'Matélé',
|
||||
outlet: 'matele',
|
||||
medium: 'video',
|
||||
date: '2026-01-15',
|
||||
url: 'https://www.matele.be/une-application-qui-reinvente-les-rencontres-en-ligne-developpee-par-un-havelangeois',
|
||||
summary:
|
||||
@@ -66,47 +102,461 @@ const pressItems: PressItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const PressItem = ({item}: {item: PressItem; locales: Intl.LocalesArgument}) => {
|
||||
// ─── Item chrome ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MEDIUM_ICON: Record<Medium, IconType> = {
|
||||
article: NewspaperIcon,
|
||||
radio: MicrophoneIcon,
|
||||
video: PlayCircleIcon,
|
||||
}
|
||||
|
||||
function useMediumLabels() {
|
||||
const t = useT()
|
||||
|
||||
const label: Record<Medium, string> = {
|
||||
article: t('press.medium.article', 'Article'),
|
||||
radio: t('press.medium.radio', 'Radio'),
|
||||
video: t('press.medium.video', 'Video'),
|
||||
}
|
||||
// The verb has to match the medium or the page promises the wrong thing: "Read the coverage" on a
|
||||
// fifty-minute radio episode is a small lie about what the click costs.
|
||||
const action: Record<Medium, string> = {
|
||||
article: t('press.action.article', 'Read the article'),
|
||||
radio: t('press.action.radio', 'Listen to the interview'),
|
||||
video: t('press.action.video', 'Watch the report'),
|
||||
}
|
||||
|
||||
return {label, action}
|
||||
}
|
||||
|
||||
/**
|
||||
* The date, formatted in the reader's own locale.
|
||||
*
|
||||
* The previous version printed the raw ISO string, with the locale-formatted line commented out beside
|
||||
* it. It is pinned to UTC here, which is the thing that makes it safe: these are plain calendar dates
|
||||
* with no time, so without a fixed zone a reader west of Greenwich gets yesterday, and the server and
|
||||
* the client can disagree about which day it is at all.
|
||||
*/
|
||||
function PressDate({date, locale}: {date: string; locale: Intl.LocalesArgument}) {
|
||||
const formatted = useMemo(() => {
|
||||
try {
|
||||
return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
} catch {
|
||||
return date
|
||||
}
|
||||
}, [date, locale])
|
||||
|
||||
return (
|
||||
<div className="mb-8 px-6 pb-4 border border-canvas-200 rounded-lg shadow-md hover:shadow-lg transition-shadow bg-canvas-50">
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
<Link href={item.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{item.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between text-sm mb-2">
|
||||
<span>{item.source}</span>
|
||||
{/*<span>{new Date(item.date).toLocaleDateString(locales, { year: 'numeric', month: 'long', day: 'numeric' })}</span>*/}
|
||||
<span>{item.date}</span>
|
||||
</div>
|
||||
{item.summary && (
|
||||
<p>
|
||||
<b>{t('press.summary', 'Summary (Compass editorial)')}</b>:{' '}
|
||||
{t(`press.summary.${item.id}`, item.summary)}
|
||||
</p>
|
||||
)}
|
||||
{/*<div className="mt-2">*/}
|
||||
{/* <span className="inline-block px-2 py-1 text-xs rounded-full">*/}
|
||||
{/* {item.language.toUpperCase()}*/}
|
||||
{/* </span>*/}
|
||||
{/*</div>*/}
|
||||
<time dateTime={date} className="text-sm text-ink-500 tabular-nums">
|
||||
{formatted}
|
||||
</time>
|
||||
)
|
||||
}
|
||||
|
||||
/** Medium + language, as one quiet run of metadata rather than two competing pills. */
|
||||
function ItemMeta({item, className}: {item: PressItem; className?: string}) {
|
||||
const t = useT()
|
||||
const {label} = useMediumLabels()
|
||||
const {locale} = useLocale()
|
||||
const Icon = MEDIUM_ICON[item.medium]
|
||||
|
||||
return (
|
||||
<div className={clsx('flex flex-wrap items-center gap-x-3 gap-y-2', className)}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-100 px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.8px] text-primary-700 ring-1 ring-primary-200">
|
||||
<Icon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{label[item.medium]}
|
||||
</span>
|
||||
<span className="text-sm text-ink-400" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<PressDate date={item.date} locale={locale} />
|
||||
<span className="text-sm text-ink-400" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="text-sm text-ink-500">
|
||||
{t(`languages.${item.language}`, item.language.toUpperCase())}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editorial summary.
|
||||
*
|
||||
* Still labelled as ours — these are not the outlets' own standfirsts and the page should not let anyone
|
||||
* think they are — but the label is now an eyebrow above the text rather than a bolded run-in, so the
|
||||
* summary reads as prose instead of as a field with a caption glued to its front.
|
||||
*/
|
||||
function Summary({item, clamp}: {item: PressItem; clamp?: boolean}) {
|
||||
const t = useT()
|
||||
if (!item.summary) return null
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p className={clsx(eyebrow, 'mb-2 text-ink-700')}>
|
||||
{t('press.summary', 'Summary (Compass editorial)')}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
'text-[15px] leading-relaxed text-ink-600',
|
||||
// The rows below the lead item are a scan, not a read: three lines is enough to tell whether
|
||||
// the piece is worth opening, and un-clamped these summaries run to a full paragraph each and
|
||||
// the list stops being scannable at all.
|
||||
clamp && 'line-clamp-3',
|
||||
)}
|
||||
>
|
||||
{t(`press.summary.${item.id}`, item.summary)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The call to action at the base of a card. Not a real link — the whole card is the link. */
|
||||
function ItemCTA({medium, className}: {medium: Medium; className?: string}) {
|
||||
const {action} = useMediumLabels()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-sm font-semibold text-primary-700 transition-colors group-hover:text-primary-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{action[medium]}
|
||||
<ArrowUpRightIcon
|
||||
className="h-4 w-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead item, given the room a lead deserves.
|
||||
*
|
||||
* The five items used to be five identical bordered boxes under an "FR" heading, so the most recent piece
|
||||
* — a full radio interview — arrived with exactly the weight of a syndicated duplicate three rows below
|
||||
* it. Here the newest one gets the display-size headline, the untruncated summary and the logo at full
|
||||
* size; everything after it is a row.
|
||||
*/
|
||||
function FeaturedItem({item}: {item: PressItem}) {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(surface, surfaceHover, 'group relative block overflow-hidden p-6 sm:p-10')}
|
||||
>
|
||||
{/* The one gradient on this page, and the reason the lead reads as a lead at a glance rather than
|
||||
after reading its headline. Same treatment as the about page's spotlight block. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-32 -top-32 h-[420px] w-[420px] rounded-full bg-primary-500/[0.08] blur-3xl"
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-4">
|
||||
<OutletLogo outlet={item.outlet} />
|
||||
{/* `min-w` so the metadata drops onto its own line rather than being squeezed into the ~150px
|
||||
left beside a 128px logo tile on a phone. */}
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<p className={clsx(eyebrow, 'mb-2 text-primary-700')}>
|
||||
{t('press.latest', 'Latest coverage')}
|
||||
</p>
|
||||
<ItemMeta item={item} />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 mb-0 font-heading text-[clamp(22px,2.6vw,32px)] font-bold leading-[1.2] tracking-tight text-ink-900 text-balance transition-colors group-hover:text-primary-800">
|
||||
{item.title}
|
||||
</h2>
|
||||
<div className="max-w-3xl">
|
||||
<Summary item={item} />
|
||||
</div>
|
||||
<ItemCTA medium={item.medium} className="mt-6" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything after the lead: logo rail, then the piece.
|
||||
*
|
||||
* The logo is the left rail rather than a byline in the text because it is what the eye actually uses to
|
||||
* navigate this list — four outlets, five items, and two of those items are the same syndicated article
|
||||
* carried by two different papers. The mark distinguishes them instantly; the identical headlines do not.
|
||||
*/
|
||||
function PressRow({item}: {item: PressItem}) {
|
||||
const {name} = OUTLETS[item.outlet]
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={clsx(
|
||||
surface,
|
||||
surfaceHover,
|
||||
'group flex flex-col gap-5 p-5 sm:flex-row sm:gap-7 sm:p-6',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 sm:block">
|
||||
<OutletLogo outlet={item.outlet} size="sm" />
|
||||
{/* The outlet's name in text as well as in the mark: the logos are images, and a name that only
|
||||
exists inside a PNG is a name a screen reader user gets once, as alt text, with no emphasis. */}
|
||||
<span className="text-sm font-semibold text-ink-700 sm:mt-2 sm:block sm:text-center">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ItemMeta item={item} className="mb-3" />
|
||||
<h3 className="mt-0 mb-0 font-heading text-lg font-bold leading-snug tracking-tight text-ink-900 sm:text-xl transition-colors group-hover:text-primary-800">
|
||||
{item.title}
|
||||
</h3>
|
||||
<Summary item={item} clamp />
|
||||
<ItemCTA medium={item.medium} className="mt-4" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Header band ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The logo strip under the page header, with the coverage counted underneath it.
|
||||
*
|
||||
* A rule-bounded band rather than a card, for the same reason the about page's `StatBand` is one: this
|
||||
* page is otherwise a column of cards, and opening it with a sixth one gives the eye nothing to catch on.
|
||||
*
|
||||
* The three numbers are derived from `pressItems`, never typed in, so adding a sixth item can't leave the
|
||||
* page claiming five.
|
||||
*/
|
||||
function CoverageBand({items}: {items: PressItem[]}) {
|
||||
const t = useT()
|
||||
const {locale} = useLocale()
|
||||
|
||||
const outlets = new Set(items.map((i) => i.outlet)).size
|
||||
const since = useMemo(() => {
|
||||
const earliest = items.map((i) => i.date).sort()[0]
|
||||
if (!earliest) return null
|
||||
try {
|
||||
return new Date(`${earliest}T00:00:00Z`).toLocaleDateString(locale, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
} catch {
|
||||
return earliest
|
||||
}
|
||||
}, [items, locale])
|
||||
|
||||
return (
|
||||
<div className="border-y border-canvas-200 py-8">
|
||||
<PressLogos />
|
||||
<p className="mt-6 text-sm text-ink-500">
|
||||
{t('press.band.caption', '{stories} stories · {outlets} outlets · since {since}', {
|
||||
stories: items.length,
|
||||
outlets,
|
||||
since,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Media kit ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The paragraph a journalist can paste without having to write one themselves — with the button that
|
||||
* puts it on their clipboard.
|
||||
*
|
||||
* Every claim in it is one this site already makes and can back up (free, open source, donation-funded,
|
||||
* member-governed, started in Belgium by Martin), which is the only reason it can be handed out
|
||||
* pre-written. Deliberately no member count: a boilerplate is copied once and quoted for months, and a
|
||||
* headcount is the one figure here that would be wrong by the time it ran.
|
||||
*/
|
||||
function Boilerplate() {
|
||||
const t = useT()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const text = t(
|
||||
'press.boilerplate.text',
|
||||
'Compass (compassmeet.com) is a free, open-source directory for finding people who share your values, interests and intentions — friends, partners or collaborators. Instead of swiping through an algorithmic feed, members read and search every profile: by values, interests, politics, or a single keyword. There are no ads, no subscriptions and no venture capital. Compass is built by volunteers, funded by donations, and governed by its members under a public constitution. It was started in Belgium by engineer Martin Braquet.',
|
||||
)
|
||||
|
||||
const onCopy = () => {
|
||||
copyToClipboard(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(surface, 'p-6 sm:p-8')}>
|
||||
<div className="lg:flex lg:items-start lg:justify-between lg:gap-10">
|
||||
<div className="min-w-0">
|
||||
<p className={clsx(eyebrow, 'mb-3 text-primary-700')}>
|
||||
{t('press.boilerplate.eyebrow', 'Boilerplate')}
|
||||
</p>
|
||||
<h3 className="mt-0 mb-4 max-w-2xl font-heading text-[clamp(20px,2.4vw,28px)] font-bold leading-[1.2] tracking-tight text-ink-900 text-balance">
|
||||
{t('press.boilerplate.title', 'Use this, word for word.')}
|
||||
</h3>
|
||||
{/* `max-w-xl`, not `2xl`: a slightly narrower measure both reads better and makes the copy
|
||||
column roughly as tall as the facts panel beside it, which otherwise leaves a block of dead
|
||||
space under the button on desktop. */}
|
||||
<p className="max-w-xl text-base leading-relaxed text-ink-600">{text}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl border-2 border-canvas-200 bg-transparent px-5 py-2.5 text-sm font-semibold text-ink-900 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-primary-500 hover:text-primary-500"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-[1.05rem] w-[1.05rem]" strokeWidth={2.5} aria-hidden />
|
||||
) : (
|
||||
<ClipboardDocumentIcon
|
||||
className="h-[1.05rem] w-[1.05rem]"
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{copied
|
||||
? t('press.boilerplate.copied', 'Copied!')
|
||||
: t('press.boilerplate.copy', 'Copy boilerplate')}
|
||||
</button>
|
||||
</div>
|
||||
<FastFacts />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The facts a journalist checks before filing, in the order they get asked for.
|
||||
*
|
||||
* The member count is queried rather than written down — same rule as `StatBand` and `MemberGrowth` — and
|
||||
* its row simply doesn't render if the call comes back empty, because a press page quoting a stale or
|
||||
* zeroed figure is worse than one that doesn't quote it at all. Everything else here is policy or fact,
|
||||
* not measurement, so it is static.
|
||||
*/
|
||||
function FastFacts() {
|
||||
const t = useT()
|
||||
const {data} = useAPIGetter('stats', {})
|
||||
|
||||
// No "what is it" row: the boilerplate immediately to the left is that answer at full length, and
|
||||
// repeating it in four words beside itself is the sort of duplication that makes a panel look padded.
|
||||
const facts: {label: string; value: ReactNode}[] = [
|
||||
{label: t('press.facts.founder', 'Founder'), value: 'Martin Braquet'},
|
||||
{label: t('press.facts.based', 'Based'), value: t('press.facts.based_value', 'Belgium')},
|
||||
{
|
||||
label: t('press.facts.price', 'Price'),
|
||||
value: t('press.facts.price_value', 'Free — no ads, no subscriptions'),
|
||||
},
|
||||
{
|
||||
label: t('press.facts.platforms', 'Platforms'),
|
||||
value: t('press.facts.platforms_value', 'Web, Android, iPhone'),
|
||||
},
|
||||
{
|
||||
label: t('press.facts.languages', 'Interface'),
|
||||
value: t('press.facts.languages_value', 'English, French, German'),
|
||||
},
|
||||
{
|
||||
label: t('press.facts.source', 'Source'),
|
||||
value: (
|
||||
<Link
|
||||
href={githubRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-primary-700 transition-colors hover:text-primary-800"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Leads the panel when it's there: it's the first thing anyone writing about a young platform asks.
|
||||
if (data?.profiles) {
|
||||
facts.unshift({
|
||||
label: t('press.facts.members', 'Members'),
|
||||
value: (
|
||||
<span className="tabular-nums">
|
||||
{data.profiles.toLocaleString('en-US')}{' '}
|
||||
<span className="text-ink-500">{t('press.facts.members_live', '(live)')}</span>
|
||||
</span>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 rounded-xl bg-canvas-100 p-5 ring-1 ring-canvas-200 sm:p-6 lg:mt-0 lg:w-80 lg:flex-shrink-0">
|
||||
<p className={clsx(eyebrow, 'mb-1 text-ink-700')}>{t('press.facts.title', 'Fast facts')}</p>
|
||||
<dl className="m-0 divide-y divide-canvas-200">
|
||||
{facts.map((f) => (
|
||||
<div key={f.label} className="flex items-baseline gap-4 py-2.5">
|
||||
<dt className="w-24 flex-shrink-0 text-[13px] font-semibold text-ink-500">{f.label}</dt>
|
||||
<dd className="m-0 min-w-0 flex-1 text-[13px] leading-snug text-ink-900">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One of the two action cards under the boilerplate: assets to download, a person to email. */
|
||||
function KitCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
text,
|
||||
buttonLabel,
|
||||
buttonUrl,
|
||||
primary,
|
||||
}: {
|
||||
icon: IconType
|
||||
title: string
|
||||
text: string
|
||||
buttonLabel: string
|
||||
buttonUrl: string
|
||||
primary?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(surface, surfaceHover, 'flex h-full flex-col p-6 sm:p-7')}>
|
||||
<div className="mb-5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 ring-1 ring-primary-200">
|
||||
<Icon className="h-5 w-5 text-primary-600" strokeWidth={1.8} />
|
||||
</div>
|
||||
<h3 className="mt-0 mb-2.5 font-bold text-ink-900">{title}</h3>
|
||||
<p className="mb-6 text-sm leading-relaxed text-ink-600">{text}</p>
|
||||
{/* Same hierarchy as the about page: one filled CTA per group, everything else outlined, so the
|
||||
two cards don't shout at each other. */}
|
||||
<Link
|
||||
href={buttonUrl}
|
||||
target={buttonUrl.startsWith('http') ? '_blank' : undefined}
|
||||
rel={buttonUrl.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className={clsx(
|
||||
'mt-auto inline-flex w-fit items-center rounded-xl px-5 py-2.5 text-sm font-semibold transition-all duration-200 ease-out hover:-translate-y-0.5',
|
||||
primary
|
||||
? 'bg-cta text-white shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)] hover:bg-cta-hover'
|
||||
: 'border-2 border-canvas-200 text-ink-900 hover:border-primary-500 hover:text-primary-500',
|
||||
)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PressPage() {
|
||||
const t = useT()
|
||||
|
||||
const pressByLanguage = pressItems.reduce<Record<string, PressItem[]>>((acc, item) => {
|
||||
if (!acc[item.language]) {
|
||||
acc[item.language] = []
|
||||
}
|
||||
acc[item.language].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const {locale} = useLocale()
|
||||
// Newest first, and sorted rather than assumed: the array is maintained by hand and the ordering is
|
||||
// load-bearing now that the first item is styled as the lead.
|
||||
const items = useMemo(() => [...pressItems].sort((a, b) => b.date.localeCompare(a.date)), [])
|
||||
const [featured, ...rest] = items
|
||||
|
||||
return (
|
||||
<PageBase trackPageView={'press'}>
|
||||
@@ -119,71 +569,98 @@ export default function PressPage() {
|
||||
url={'/press'}
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-4">{t('press.title', 'Press')}</h1>
|
||||
<p className="text-xl max-w-3xl mx-auto">
|
||||
{/* `max-w-6xl` and left-aligned, matching `/about`: a centred `max-w-4xl` column left roughly half
|
||||
the screen empty at desktop widths, and this page now has a logo strip and a two-column media
|
||||
kit that both want the room. */}
|
||||
<div className="mx-auto max-w-6xl px-5 pb-24 pt-12 sm:px-8">
|
||||
{/* ── Page header ── */}
|
||||
<div className="mb-10">
|
||||
<p className={clsx(eyebrow, 'mb-4 text-primary-700')}>
|
||||
{t('press.eyebrow', 'Press & media')}
|
||||
</p>
|
||||
<h1 className="mb-5 max-w-3xl text-[clamp(34px,5vw,56px)] leading-[1.08] tracking-tight text-ink-900 text-balance">
|
||||
{t('press.headline', 'Compass in the press.')}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-relaxed text-ink-700 sm:text-xl">
|
||||
{t('press.subtitle', 'Latest news and media coverage about Compass')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-6">{t('press.media_kit', 'Media Kit')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="px-6 pb-4 border border-canvas-200 rounded-lg shadow bg-canvas-50">
|
||||
<h3 className="text-lg font-medium mb-3">
|
||||
{t('press.brand_assets', 'Brand Assets')}
|
||||
</h3>
|
||||
<p className=" mb-4">
|
||||
{t('press.brand_assets_description', 'Download our logo and brand guidelines.')}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/CompassConnections/assets/archive/refs/heads/main.zip"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none transition-all"
|
||||
>
|
||||
{t('press.download_assets', 'Download Assets')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-6 pb-4 border border-canvas-200 rounded-lg shadow bg-canvas-50">
|
||||
<h3 className="text-lg font-medium mb-3">{t('press.contact', 'Press Contact')}</h3>
|
||||
<p className="-300 mb-4">
|
||||
{t('press.contact_description', 'For press inquiries, please contact our team.')}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:hello@compassmeet.com"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none transition-all"
|
||||
>
|
||||
{t('press.contact_us', 'Contact Us')}
|
||||
</a>
|
||||
{items.length > 0 && <CoverageBand items={items} />}
|
||||
|
||||
{/* ── Coverage ──
|
||||
The language grouping is gone. It put every item under an "FR" heading — one group, every
|
||||
time — which cost a heading's worth of vertical space to tell the reader nothing. Each item
|
||||
now carries its own language in its metadata line, which is where it does work: next to the
|
||||
medium, where a reader is deciding whether to click. */}
|
||||
{items.length > 0 && (
|
||||
<Section>
|
||||
<SectionLabel>{t('press.coverage.label', 'Coverage')}</SectionLabel>
|
||||
|
||||
<Reveal>
|
||||
<FeaturedItem item={featured} />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:mt-5 sm:gap-5">
|
||||
{rest.map((item, i) => (
|
||||
<Reveal key={item.id} delay={i * 70}>
|
||||
<PressRow item={item} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className={clsx(surface, 'mt-10 p-8 text-center')}>
|
||||
<p className="m-0 text-ink-600">
|
||||
{t(
|
||||
'press.no_articles',
|
||||
'No press articles available at the moment. Please check back later.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
{Object.entries(pressByLanguage).map(([language, items]) => (
|
||||
<div key={language}>
|
||||
<h2 className="text-2xl font-semibold mb-6">
|
||||
{t(`languages.${language}`, language.toUpperCase())}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{items.map((item, index) => (
|
||||
<PressItem key={index} item={item} locales={locale} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* ── Media kit ──
|
||||
Moved below the coverage. It used to open the page, which meant a reader who arrived from an
|
||||
article — the overwhelming majority here — met a logo download before a single story. The
|
||||
people who want a media kit are a small, motivated minority and will scroll for it. */}
|
||||
<Section>
|
||||
<SectionLabel>{t('press.media_kit', 'Media Kit')}</SectionLabel>
|
||||
|
||||
{pressItems.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="">
|
||||
{t(
|
||||
'press.no_articles',
|
||||
'No press articles available at the moment. Please check back later.',
|
||||
<Reveal>
|
||||
<Boilerplate />
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:mt-5 sm:grid-cols-2 sm:gap-5">
|
||||
<Reveal>
|
||||
<KitCard
|
||||
icon={ArrowDownTrayIcon}
|
||||
title={t('press.brand_assets', 'Brand Assets')}
|
||||
text={t(
|
||||
'press.brand_assets_description',
|
||||
'Download our logo and brand guidelines.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
buttonLabel={t('press.download_assets', 'Download Assets')}
|
||||
buttonUrl="https://github.com/CompassConnections/assets/archive/refs/heads/main.zip"
|
||||
primary
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal delay={70}>
|
||||
<KitCard
|
||||
icon={EnvelopeIcon}
|
||||
title={t('press.contact', 'Press Contact')}
|
||||
text={t(
|
||||
'press.contact_description',
|
||||
'For press inquiries, please contact our team.',
|
||||
)}
|
||||
buttonLabel={t('press.contact_us', 'Contact Us')}
|
||||
buttonUrl={`mailto:${supportEmail}`}
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</PageBase>
|
||||
)
|
||||
|
||||
9
web/public/images/press/la-dh-logo.svg
Normal file
9
web/public/images/press/la-dh-logo.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<!-- Hand-vectorized recreation of DH's square icon mark (navy + red tiles with a "D"/"H" monogram),
|
||||
matching the app-icon variant of the logo rather than the "DH Les Sports+" wordmark used before.
|
||||
Built as plain shapes/text rather than traced from an official vector file, since none was found. -->
|
||||
<svg viewBox="0 0 200 155" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="La DH">
|
||||
<rect x="18" y="18" width="108" height="108" rx="3" fill="#1B2740" transform="rotate(-8 72 72)"/>
|
||||
<text x="72" y="76" transform="rotate(-8 72 72)" font-family="Arial, Helvetica, sans-serif" font-weight="800" font-size="58" fill="#FFFFFF" text-anchor="middle" dominant-baseline="central">D</text>
|
||||
<rect x="98" y="54" width="90" height="90" rx="2" fill="#D6362E"/>
|
||||
<text x="143" y="100" font-family="Arial, Helvetica, sans-serif" font-weight="800" font-size="52" fill="#FFFFFF" text-anchor="middle" dominant-baseline="central">H</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 952 B |
4
web/public/images/press/lavenir-logo.svg
Normal file
4
web/public/images/press/lavenir-logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="120" height="30" viewBox="0 0 120 29" fill="none" xmlns="http://www.w3.org/2000/svg"><title lang="fr">${title}</title>
|
||||
<title>Logo avenir</title>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.366 2.51128C105.366 3.89822 104.242 5.02256 102.855 5.02256C101.468 5.02256 100.343 3.89822 100.343 2.51128C100.343 1.12434 101.468 0 102.855 0C104.242 0 105.366 1.12434 105.366 2.51128ZM14.3155 9.37768C15.3076 6.48729 15.9816 3.49785 16.3255 0.461649L16.3525 0.226147H11.6006C11.731 3.41807 11.4966 6.61494 10.9029 9.75349L10.8881 9.83328H14.1592L14.3155 9.37768ZM4.60383 0.247056L0 27.9333H4.86794L9.47177 0.247056H4.60383ZM41.5715 21.8218L49.1411 7.00561H54.5879L43.5991 27.9542H38.1529L33.5441 7.00561H38.4957L41.5715 21.8218ZM111.845 10.7599L112.473 7.00561H107.636L104.132 27.9542H108.969C110.395 18.3674 112.066 12.5234 119.229 12.0992L120 7.00616C117.056 7.00616 113.722 8.44008 111.845 10.7604V10.7599ZM81.4642 11.5385C83.5452 8.00924 86.3162 6.30406 89.3018 6.30406C94.3881 6.30406 96.5528 10.5073 95.7654 15.2658L93.5716 27.9548H89.0057L91.1995 15.2658C91.643 12.6488 90.241 11.4592 88.3493 11.4592C85.3989 11.4592 80.8276 15.5035 78.943 26.6061L78.7185 27.9548H73.8869L77.3908 7.00616H82.2279L81.4642 11.5385ZM73.5996 16.5808C73.6002 16.9495 73.5826 17.36 73.5468 17.8112L73.5479 17.8117C73.5121 18.2635 73.4516 18.7625 73.3674 19.3089H57.4161C57.7126 20.8386 58.3658 21.999 59.2472 22.7892C60.1287 23.5798 61.2721 23.9744 62.678 23.9744C63.68 23.9744 64.5873 23.7631 65.3994 23.341C66.1626 22.9449 66.8521 22.3528 67.4689 21.5715L71.8477 23.2965C71.0311 24.6952 70.0022 25.8347 68.7515 26.7024C66.9814 27.9306 64.8921 28.5446 62.4843 28.5446C59.6484 28.5446 57.3181 27.596 55.4935 25.6977C53.6684 23.7999 52.7539 21.3778 52.7484 18.4324C52.7451 16.6678 53.084 14.995 53.7658 13.4142C54.447 11.8334 55.4165 10.4528 56.6732 9.27204C57.6373 8.37075 58.73 7.6846 59.9516 7.21525C61.1725 6.7459 62.4804 6.51095 63.8736 6.51095C66.7459 6.51095 69.0943 7.44855 70.9194 9.32376C72.7435 11.1995 73.5941 13.6184 73.5996 16.5808ZM57.9032 15.2135L57.903 15.214V15.2135H57.9032ZM57.9032 15.2135C58.3776 13.9124 59.1704 12.8958 60.1529 12.1624C61.1351 11.429 62.2565 11.062 63.5154 11.062C64.8965 11.062 66.0581 11.4257 67.0012 12.1525C67.9437 12.8799 68.6519 13.9001 68.9963 15.2135H57.9032ZM96.3541 27.9542L99.858 7.00561H104.695L101.191 27.9542H96.3541ZM22.5619 0.226147L7.73413 27.9542H13.6948L16.9346 21.7465C20.4044 20.4958 24.9521 19.8586 27.6389 19.7981L29.5774 27.9542H35.1194L28.3702 0.226147H22.5619ZM19.9296 16.008L24.6082 7.04358L26.5242 15.1051C24.5615 15.2157 22.229 15.5222 19.9296 16.008Z" fill="#54AC3B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
web/public/images/press/matele-logo.png
Normal file
BIN
web/public/images/press/matele-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
web/public/images/press/rcf-logo.png
Normal file
BIN
web/public/images/press/rcf-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -430,6 +430,33 @@
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* The opposite of .scrollbar-hide: a slim scrollbar that is always drawn, never an overlay that fades
|
||||
out. Used by ScrollPanel, where the whole point is that the region reads as scrollable at a glance. */
|
||||
.scrollbar-visible {
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
scrollbar-color: rgb(var(--color-primary-400) / 0.55) rgb(var(--color-canvas-200));
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.scrollbar-visible::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: rgb(var(--color-canvas-200));
|
||||
border-radius: 100px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--color-primary-400) / 0.55);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--color-primary-500) / 0.8);
|
||||
}
|
||||
|
||||
text {
|
||||
font-family: icomoon, var(--font-main), emoji, sans-serif;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user