diff --git a/android/app/build.gradle b/android/app/build.gradle index ab3bf74d..99ea1407 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 { diff --git a/backend/api/package.json b/backend/api/package.json index 625f0135..4e1c2eda 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/common/messages/de.json b/common/messages/de.json index 2cb9500d..819f84dc 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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" } diff --git a/common/messages/fr.json b/common/messages/fr.json index 7cf59762..a3a731a3 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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" } diff --git a/common/src/constants.ts b/common/src/constants.ts index 71a2ba27..0d85fcc2 100644 --- a/common/src/constants.ts +++ b/common/src/constants.ts @@ -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 diff --git a/docs/marketing-copy.md b/docs/marketing-copy.md new file mode 100644 index 00000000..693f94e2 --- /dev/null +++ b/docs/marketing-copy.md @@ -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` | diff --git a/tests/e2e/utils/seed-showcase.ts b/tests/e2e/utils/seed-showcase.ts index 328eae2d..d63dd350 100644 --- a/tests/e2e/utils/seed-showcase.ts +++ b/tests/e2e/utils/seed-showcase.ts @@ -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) { diff --git a/tests/e2e/web/pages/profilePage.ts b/tests/e2e/web/pages/profilePage.ts index 3367ed25..1d5b5d84 100644 --- a/tests/e2e/web/pages/profilePage.ts +++ b/tests/e2e/web/pages/profilePage.ts @@ -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') diff --git a/web/components/about/platform-stats.tsx b/web/components/about/platform-stats.tsx index 785bf8ba..76eed7db 100644 --- a/web/components/about/platform-stats.tsx +++ b/web/components/about/platform-stats.tsx @@ -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 && ( - - )} {showConversations && ( )} + {showMessages && ( + + )} {/* 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. */} } /> + {/* 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. */} +

+ {t('about.stat.caption', 'Read live from the database, not from a press kit.')} +

) } diff --git a/web/components/answers/answer-compatibility-question-button.tsx b/web/components/answers/answer-compatibility-question-button.tsx index cd0c7d3f..38e89f7d 100644 --- a/web/components/answers/answer-compatibility-question-button.tsx +++ b/web/components/answers/answer-compatibility-question-button.tsx @@ -53,7 +53,10 @@ export function AnswerCompatibilityQuestionButton(props: { ) : ( diff --git a/web/components/answers/compatibility-questions-display.tsx b/web/components/answers/compatibility-questions-display.tsx index 2743459f..0f2fec5c 100644 --- a/web/components/answers/compatibility-questions-display.tsx +++ b/web/components/answers/compatibility-questions-display.tsx @@ -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 && ( - {shownPinnedAnswers.map((answer) => ( - - ))} + + {shownPinnedAnswers.map((answer) => ( + + ))} + {NUM_PINNED_QUESTIONS_TO_SHOW < pinnedAnswers.length && ( )} - + {/* 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 && } + {answeredQuestions.length > 0 && ( <> -
+
{/*) => { setSearchTerm(e.target.value) }} @@ -225,7 +233,7 @@ export function CompatibilityQuestionsDisplay(props: { />
)} - {compatibilityScore && ( - - )} {answeredQuestions.length == 0 ? ( @@ -260,21 +265,23 @@ export function CompatibilityQuestionsDisplay(props: { ) : ( <> - {shownAnswers.map((answer) => { - return ( - - ) - })} + + {shownAnswers.map((answer) => { + return ( + + ) + })} + {shownAnswers.length === 0 && (
@@ -415,16 +422,30 @@ export function CompatibilityAnswerBlock(props: { - - {question.question} - + + {/* 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. */} +

+ {question.question} +

+ {comparedProfile && isAnswered && (
)} - {!!currentUser && } + {/* 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 && ( + + )} {isCurrentUser && isAnswered && ( <>
+ {/* 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 && ( - {answerText} - +
+ )} + {/* 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 && ( +
+ +
)} - - {answer?.explanation && } - {distinctPreferredAnswersText.length > 0 && ( - -
+
+ {preferredDoesNotIncludeAnswerText ? t('answers.display.acceptable', 'Acceptable') : t('answers.display.also_acceptable', 'Also acceptable')} -
- - {distinctPreferredAnswersText.map((text) => ( - - {text} - - ))} - - + + {distinctPreferredAnswersText.map((text, i) => ( + + {i > 0 && ( + + {' · '} + + )} + {text} + + ))} +
)} {!isAnswered && ( @@ -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} @@ -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. */} + ) +} + 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(null) const imageRef = useRef(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 diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index e4e39bcc..394c18f5 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -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. */} ) => { e.preventDefault() onPointerDown?.() diff --git a/web/components/photos-modal.tsx b/web/components/photos-modal.tsx index 432d48b8..92280a15 100644 --- a/web/components/photos-modal.tsx +++ b/web/components/photos-modal.tsx @@ -91,7 +91,7 @@ export const ViewProfileCardButton = (props: { - {t('share_profile.view_profile_card', 'Profile Card')} + {t('share_profile.view_profile_card', 'Card')} diff --git a/web/components/press/press-logos.tsx b/web/components/press/press-logos.tsx new file mode 100644 index 00000000..9afbeafe --- /dev/null +++ b/web/components/press/press-logos.tsx @@ -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 = { + 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 + // `` 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 ( +
+ {name} +
+ ) +} + +/** 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 ( +
+ {OUTLET_ORDER.map((id) => ( + + ))} +
+ ) +} diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index 140d420e..596dcb89 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -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 ( - - - - - - + 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 && } + + - - + - + + + + {!isCurrentUser && ( <> @@ -81,67 +90,23 @@ export default function ProfileAbout(props: { ) } -function Divider() { - return ( -
- ) -} - -function SectionLabel(props: {compact?: boolean; children: ReactNode}) { - const {compact, children} = props - return ( -
- {children} -
- ) -} - -function Chip(props: {variant?: 'default' | 'primary' | 'muted'; children: ReactNode}) { - const {variant = 'default', children} = props - if (variant === 'muted') { - return ( - - {children} - - ) - } - 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 ( @@ -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 ( - <> - -
- {icon} -
- - {title} - {text != null && text !== '' && ( -
{text}
- )} - {details && ( -
- {details} -
- )} - {children} - -
- {divider && } - +
+
+ {title} +
+
+ {text != null && text !== '' && ( +
+ {text} +
+ )} + {details && ( +
+ {details} +
+ )} + {children} +
+
) } -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 ( +
+ {seekingText} + {status && {status}} +
+ ) +} + +function HeroFact(props: {label: string; children: ReactNode}) { + const {label, children} = props + return ( +
+
+ {label} +
+
+ {children} +
+
+ ) +} + +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 ( } title={t('profile.connection_goals', 'Connection Goals')} text={seekingText} details={relationshipText} @@ -226,9 +261,7 @@ function SeekingAndRelationship(props: {profile: Profile}) { {romanticStyles && (
{romanticStyles!.map((r) => ( - - {r} - + {r} ))}
)} @@ -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 ( - } - title={t('profile.education', 'Education')} - text={text} - /> - ) + return } function OccupationAndWork(props: {profile: Profile}) { @@ -327,14 +407,7 @@ function OccupationAndWork(props: {profile: Profile}) { const workText = workAreas?.join(' · ') - return ( - } - title={t('profile.work', 'Work')} - text={occupationText} - details={workText} - /> - ) + return } function Politics(props: {profile: Profile}) { @@ -347,7 +420,6 @@ function Politics(props: {profile: Profile}) { return ( } 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 ( } 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 ( } title={t('profile.gender_identity', 'Gender Identity')} text={text} details={`"${genderDetails}"`} @@ -409,7 +479,6 @@ function Orientation(props: {profile: Profile}) { return ( } title={t('profile.orientation', 'Orientation')} text={orientationText} details={orientationDetails ? `"${orientationDetails}"` : undefined} @@ -432,7 +501,6 @@ function Neurotype(props: {profile: Profile}) { return ( } 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 ( - } - title={t('profile.ethnicity', 'Ethnicity')} - text={text} - /> - ) + return } 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 ? ( - - ) : ( - - ) - - return + return } 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 ? : - - return + return } function Diet(props: {profile: Profile}) { @@ -502,7 +555,6 @@ function Diet(props: {profile: Profile}) { return ( } title={t('profile.diet', 'Diet')} text={formatChoiceList(diet, 'diet', INVERTED_DIET_CHOICES, t)} /> @@ -518,7 +570,6 @@ function LanguagesSection(props: {profile: Profile}) { return ( } 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 ( - } - title={t('profile.cannabis', 'Cannabis')} - text={parts} - > + {showIntentions && (
{profile.cannabis_intention!.map((i) => ( - + {t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])} ))} @@ -577,15 +624,11 @@ function Psychedelics(props: {profile: Profile}) { const prefText = formatPartnerPreferences(profile.psychedelics_pref, t) return ( - } - title={t('profile.psychedelics', 'Psychedelics')} - text={parts} - > + {showIntentions && (
{profile.psychedelics_intention!.map((i) => ( - + {t(`profile.substance_intention.${i}`, INVERTED_SUBSTANCE_INTENTION_CHOICES[i])} ))} @@ -633,12 +676,10 @@ function LastOnline(props: {lastOnlineTime?: string}) { return ( } 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 ( } title={t('profile.children', 'Children')} text={hasKidsText} details={wantsKidsText} @@ -722,7 +762,6 @@ function RaisedIn(props: {profile: Profile}) { if (!locationText) return null return ( } title={t('profile.raised_in', 'Raised In')} text={ @@ -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 +} - 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 +} +/** + * 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 ( - - {interests && interests.length > 0 && ( - <> - {/*{t('profile.interests', 'Interests')}*/} -
- {interests.map((interest, i) => ( - {interest} - ))} -
- - )} - {causes && causes.length > 0 && ( - <> - {interests && interests.length > 0 && ( -
+
+ {items.map((item, i) => ( + + {i > 0 && ( + + {' · '} + )} - {t('profile.causes', 'Causes')} -
- {causes.map((cause, i) => ( - - {cause} - - ))} -
- - )} - + {item} +
+ ))} +
) } @@ -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 ( - -
- -
-
- {notes} -
-
+
+ {notes} +
) } @@ -845,15 +884,19 @@ export function ProfilePersonality(props: {profile: Profile}) { return ( - {/* 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. */} - ) } +/** 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 +} + 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 ( -
- {t('profile.mbti', 'MBTI')} +
- {t('profile.big5', 'Big Five')}
{traits.map((trait) => { if (trait.value === null || trait.value === undefined) return null diff --git a/web/components/profile-carousel.tsx b/web/components/profile-carousel.tsx deleted file mode 100644 index 2d780bff..00000000 --- a/web/components/profile-carousel.tsx +++ /dev/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 ( - - {profile.pinned_url && ( -
- -
- )} - {photoNums > 0 && ( - - -
+{photoNums}
- - - - )} -
- ) - } - - return ( - <> - - {buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => ( - -
- {isVideo(url) ? ( -
- {(profile.image_descriptions as Record)?.[url] && ( -

- {(profile.image_descriptions as Record)?.[url]} -

- )} - - ))} -
- - - - ) -} diff --git a/web/components/profile-comment-section.tsx b/web/components/profile-comment-section.tsx index b22ad427..7806b4b7 100644 --- a/web/components/profile-comment-section.tsx +++ b/web/components/profile-comment-section.tsx @@ -59,48 +59,38 @@ export const ProfileCommentSection = (props: { <> {currentUser && profile.comments_enabled && ( <> -
- {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.', - )} - - )} -
- {!isCurrentUser && ( - <> - {!showCommentInput ? ( + {showCommentInput ? ( + + ) : ( + /* 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. */ + +
+ {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.', + )} +
+ {!isCurrentUser && ( - ) : ( - )} - +
)} )} diff --git a/web/components/profile/connect-actions.tsx b/web/components/profile/connect-actions.tsx index 9f51e0cb..b845a6e2 100644 --- a/web/components/profile/connect-actions.tsx +++ b/web/components/profile/connect-actions.tsx @@ -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(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 ( - -
- {/* Primary Action */} -
- {profile.allow_direct_messaging || matches.length > 0 ? ( - - ) : ( -

- {t( - 'profile.connect.direct_messaging_disabled', - '{user} turned off direct messaging', - {user: user.name}, - )} -

- )} -
+ + {profile.allow_direct_messaging || matches.length > 0 ? ( + + ) : ( +

+ {t('profile.connect.direct_messaging_disabled', '{user} turned off direct messaging', { + user: user.name, + })} +

+ )} - {/* Interest Section */} -
-

+ {/* 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. */} +
+ +

{t('profile.connect.private_connection_signal', 'Private connection signal')} -

+

+ {/* Explaining the mechanism is only worth the row when the mechanism is available. */} + {hasSignalControls && ( + + )} + - {profile.allow_interest_indicating ? ( - <> - -
- {t( - 'profile.wont_be_notified_unless_mutual', - 'They won’t be notified unless the interest is mutual.', - )} -
- -
- {showHelp && {tips}} -
- {Object.entries(RELATIONSHIP_CHOICES).map(([label, type]) => { + {profile.allow_interest_indicating ? ( + <> + {hasSignalControls && ( +

+ {t( + 'profile.wont_be_notified_unless_mutual', + 'They won’t be notified unless the interest is mutual.', + )} +

+ )} + + {showHelp && ( +
+ {tips} +
+ )} + + {/* 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 ? ( +

+ {t( + 'profile.connect.no_shared_types', + "You and {name} aren't open to the same kind of connection.", + {name: shortenName(user.name)}, + )} +

+ ) : ( + + {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 ( 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 && } {t(`profile.relationship.${type}`, label)} - {isMutual && ` • ${t('profile.mutual', 'Mutual')}`} + {isMutual && ( + + {t('profile.mutual', 'Mutual')} + + )} ) })} -
- {matches.length > 0 && ( -
+ + )} + + {matches.length > 0 && ( + + + {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(', '), }, )} -
- )} - - ) : ( -

- {t( - 'profile.turned_off_interest_indicators', - '{user} turned off interest indicators', - {user: user.name}, - )} -

- )} -
+ + + )} + + ) : ( +

+ {t('profile.turned_off_interest_indicators', '{user} turned off interest indicators', { + user: user.name, + })} +

+ )}
) diff --git a/web/components/profile/profile-gallery.tsx b/web/components/profile/profile-gallery.tsx new file mode 100644 index 00000000..8d9dd3f3 --- /dev/null +++ b/web/components/profile/profile-gallery.tsx @@ -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 ( + +
+ !isLocked && setLightboxOpen(true)} + className={isLocked ? 'cursor-default' : 'cursor-pointer'} + /> +
+ + {(visibleUrls.length > 1 || lockedCount > 0) && ( +
+ {!isLocked && + visibleUrls.map((url, i) => ( + + ))} + {lockedCount > 0 && ( +
+ +
+ )} +
+ )} + + {/* 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. */} + } + open={lightboxOpen} + setOpen={setLightboxOpen} + /> + + ) +} + +function MediaTile(props: { + url: string + priority?: boolean + onClick?: () => void + className?: string +}) { + const {url, priority, onClick, className} = props + + if (isVideo(url)) { + return ( +