Add onboarding

This commit is contained in:
MartinBraquet
2026-01-30 18:34:02 +01:00
parent e80d8d701a
commit c36ceb7ed9
11 changed files with 633 additions and 133 deletions

View File

@@ -51,9 +51,10 @@ export function AnswerCompatibilityQuestionButton(props: {
setOpen={setOpen}
user={user}
otherQuestions={questionsToAnswer}
fromSignup={fromSignup}
refreshCompatibilityAll={refreshCompatibilityAll}
onClose={() => {
if (fromSignup) router.push('/')
if (fromSignup) router.push('/onboarding/soft-gate')
}}
/>
</>
@@ -74,8 +75,9 @@ export function AnswerSkippedCompatibilityQuestionsButton(props: {
user: User | null | undefined
skippedQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
fromSignup?: boolean
}) {
const {user, skippedQuestions, refreshCompatibilityAll} = props
const {user, skippedQuestions, refreshCompatibilityAll, fromSignup} = props
const [open, setOpen] = useState(false)
const t = useT()
if (!user) return null
@@ -92,12 +94,56 @@ export function AnswerSkippedCompatibilityQuestionsButton(props: {
setOpen={setOpen}
user={user}
otherQuestions={skippedQuestions}
fromSignup={fromSignup}
refreshCompatibilityAll={refreshCompatibilityAll}
/>
</>
)
}
function CompatibilityOnboardingScreen({onNext, onSkip}: { onNext: () => void; onSkip: () => void }) {
const t = useT()
return (
<Col className="max-w-2xl mx-auto text-center px-6 py-12">
<h1 className="text-4xl font-bold text-ink-900 mb-6">
{t('compatibility.onboarding.title', 'See who you\'ll actually align with')}
</h1>
<div className="text-lg text-ink-700 leading-relaxed mb-8 space-y-4">
<p>
{t('compatibility.onboarding.body1', 'Answer a few short questions to calculate compatibility based on values and preferences — not photos or swipes.')}
</p>
<p>
{t('compatibility.onboarding.body2', 'Your answers directly affect who matches with you and how strongly.')}
</p>
</div>
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4 mb-8">
<p className="text-primary-800 font-medium">
{t('compatibility.onboarding.impact', 'Most people who answer at least 5 questions see far more relevant matches.')}
</p>
</div>
<Col className="gap-4">
<Button
onClick={onNext}
size="lg"
className="w-full max-w-xs mx-auto"
>
{t('compatibility.onboarding.start', 'Start answering')}
</Button>
<button
onClick={onSkip}
className="text-sm text-ink-500 hover:text-ink-700 underline"
>
{t('compatibility.onboarding.later', 'Do this later')}
</button>
</Col>
</Col>
)
}
function AnswerCompatibilityQuestionModal(props: {
open: boolean
setOpen: (open: boolean) => void
@@ -105,9 +151,21 @@ function AnswerCompatibilityQuestionModal(props: {
otherQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
onClose?: () => void
fromSignup?: boolean
}) {
const {open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose} = props
const {open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose, fromSignup} = props
const [questionIndex, setQuestionIndex] = useState(0)
const [showOnboarding, setShowOnboarding] = useState(fromSignup ?? false)
const handleStartQuestions = () => {
setShowOnboarding(false)
}
const handleSkipOnboarding = () => {
setShowOnboarding(false)
setOpen(false)
}
return (
<Modal
open={open}
@@ -115,28 +173,36 @@ function AnswerCompatibilityQuestionModal(props: {
onClose={() => {
refreshCompatibilityAll()
setQuestionIndex(0)
setShowOnboarding(fromSignup ?? false)
onClose?.()
}}
>
<Col className={MODAL_CLASS}>
<AnswerCompatibilityQuestionContent
key={otherQuestions[questionIndex].id}
index={questionIndex}
total={otherQuestions.length}
compatibilityQuestion={otherQuestions[questionIndex]}
user={user}
onSubmit={() => {
setOpen(false)
}}
isLastQuestion={questionIndex === otherQuestions.length - 1}
onNext={() => {
if (questionIndex === otherQuestions.length - 1) {
{showOnboarding ? (
<CompatibilityOnboardingScreen
onNext={handleStartQuestions}
onSkip={handleSkipOnboarding}
/>
) : (
<AnswerCompatibilityQuestionContent
key={otherQuestions[questionIndex].id}
index={questionIndex}
total={otherQuestions.length}
compatibilityQuestion={otherQuestions[questionIndex]}
user={user}
onSubmit={() => {
setOpen(false)
} else {
setQuestionIndex(questionIndex + 1)
}
}}
/>
}}
isLastQuestion={questionIndex === otherQuestions.length - 1}
onNext={() => {
if (questionIndex === otherQuestions.length - 1) {
setOpen(false)
} else {
setQuestionIndex(questionIndex + 1)
}
}}
/>
)}
</Col>
</Modal>
)

View File

@@ -1,16 +1,17 @@
import { PencilIcon, XIcon } from '@heroicons/react/outline'
import { JSONContent } from '@tiptap/core'
import {PencilIcon, XIcon} from '@heroicons/react/outline'
import {JSONContent} from '@tiptap/core'
import clsx from 'clsx'
import { Profile } from 'common/profiles/profile'
import {Profile} from 'common/profiles/profile'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Content } from 'web/components/widgets/editor'
import { updateProfile } from 'web/lib/api'
import { EditableBio } from './editable-bio'
import { tryCatch } from 'common/util/try-catch'
import { useT } from 'web/lib/locale'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Content} from 'web/components/widgets/editor'
import {updateProfile} from 'web/lib/api'
import {EditableBio} from './editable-bio'
import {tryCatch} from 'common/util/try-catch'
import {useT} from 'web/lib/locale'
import {Tooltip} from "web/components/widgets/tooltip";
export function BioBlock(props: {
isCurrentUser: boolean
@@ -46,6 +47,7 @@ export function BioBlock(props: {
/>
)}
{isCurrentUser && !edit && (
<Tooltip text={t('more_options_user.edit_bio', 'Bio options')} noTap>
<DropdownMenu
items={[
{
@@ -65,6 +67,7 @@ export function BioBlock(props: {
]}
closeOnClick
/>
</Tooltip>
)}
</Row>
</Col>

View File

@@ -488,26 +488,90 @@ export const OptionalProfileUserForm = (props: {
</Col>
</>}
<Category title={t('profile.optional.languages', 'Languages')}/>
<Category title={t('profile.optional.interests', 'Interests')}/>
<AddOptionEntry
// title={t('profile.optional.interests', 'Interests')}
choices={interestChoices}
setChoices={setInterestChoices}
profile={profile}
setProfile={setProfile}
label={'interests'}
/>
<Category title={t('profile.optional.category.morality', 'Morality')}/>
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<Category title={t('profile.optional.category.education', 'Education')}/>
<Col className={clsx(colClassName)}>
{/*<label className={clsx(labelClassName)}>*/}
{/* {t('profile.optional.languages', 'Languages')}*/}
{/*</label>*/}
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-full max-h-60 overflow-y-auto w-full">
<MultiCheckbox
choices={LANGUAGE_CHOICES}
selected={profile.languages || []}
translationPrefix={'profile.language'}
onChange={(selected) => setProfile('languages', selected)}
/>
</div>
</div>
</div>
<label className={clsx(labelClassName)}>
{t('profile.optional.education_level', 'Highest completed education level')}
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level'] ?? ''}
choicesMap={Object.fromEntries(Object.entries(EDUCATION_CHOICES).map(([k, v]) => [t(`profile.education.${v}`, k), v]))}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.university', 'University')}
</label>
<Input
type="text"
onChange={(e) => setProfile('university', e.target.value)}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Category title={t('profile.optional.category.work', 'Work')}/>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{profile['company']
? t('profile.optional.job_title_at_company', 'Job title at {company}', {company: profile['company']})
: t('profile.optional.job_title', 'Job title')}
</label>
<Input
type="text"
onChange={(e) => setProfile('occupation_title', e.target.value)}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.company', 'Company')}
</label>
<Input
type="text"
onChange={(e) => setProfile('company', e.target.value)}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<AddOptionEntry
title={t('profile.optional.work', 'Work Area')}
choices={workChoices}
setChoices={setWorkChoices}
profile={profile}
setProfile={setProfile}
label={'work'}
/>
<Category title={t('profile.optional.political_beliefs', 'Political beliefs')}/>
<Col className={clsx(colClassName)}>
@@ -550,26 +614,6 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.interests', 'Interests')}/>
<AddOptionEntry
// title={t('profile.optional.interests', 'Interests')}
choices={interestChoices}
setChoices={setInterestChoices}
profile={profile}
setProfile={setProfile}
label={'interests'}
/>
<Category title={t('profile.optional.category.morality', 'Morality')}/>
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<Category title={t('profile.optional.category.psychology', 'Psychology')}/>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>
@@ -597,70 +641,6 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.category.work', 'Work')}/>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{profile['company']
? t('profile.optional.job_title_at_company', 'Job title at {company}', {company: profile['company']})
: t('profile.optional.job_title', 'Job title')}
</label>
<Input
type="text"
onChange={(e) => setProfile('occupation_title', e.target.value)}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.company', 'Company')}
</label>
<Input
type="text"
onChange={(e) => setProfile('company', e.target.value)}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<AddOptionEntry
title={t('profile.optional.work', 'Work Area')}
choices={workChoices}
setChoices={setWorkChoices}
profile={profile}
setProfile={setProfile}
label={'work'}
/>
<Category title={t('profile.optional.category.education', 'Education')}/>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.education_level', 'Highest completed education level')}
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level'] ?? ''}
choicesMap={Object.fromEntries(Object.entries(EDUCATION_CHOICES).map(([k, v]) => [t(`profile.education.${v}`, k), v]))}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.university', 'University')}
</label>
<Input
type="text"
onChange={(e) => setProfile('university', e.target.value)}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Category title={t('profile.optional.category.substances', 'Substances')}/>
<Col className={clsx(colClassName)}>
@@ -694,6 +674,26 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Category title={t('profile.optional.languages', 'Languages')}/>
<Col className={clsx(colClassName)}>
{/*<label className={clsx(labelClassName)}>*/}
{/* {t('profile.optional.languages', 'Languages')}*/}
{/*</label>*/}
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-full max-h-60 overflow-y-auto w-full">
<MultiCheckbox
choices={LANGUAGE_CHOICES}
selected={profile.languages || []}
translationPrefix={'profile.language'}
onChange={(selected) => setProfile('languages', selected)}
/>
</div>
</div>
</div>
</Col>
{/* <Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Birthplace</label>
<Input

View File

@@ -22,6 +22,7 @@ import toast from "react-hot-toast";
import {StarButton} from "web/components/widgets/star-button";
import {disableProfile} from "web/lib/util/disable";
import {useT} from 'web/lib/locale'
import {Tooltip} from "web/components/widgets/tooltip";
export default function ProfileHeader(props: {
user: User
@@ -80,6 +81,7 @@ export default function ProfileHeader(props: {
className="hidden sm:flex"
username={user.username}
/>
<Tooltip text={t('more_options_user.edit_profile', 'Edit profile')} noTap>
<Button
color={'gray-outline'}
onClick={() => {
@@ -90,7 +92,9 @@ export default function ProfileHeader(props: {
>
<PencilIcon className=" h-4 w-4"/>
</Button>
</Tooltip>
<Tooltip text={t('more_options_user.profile_options', 'Profile options')} noTap>
<DropdownMenu
menuWidth={'w-52'}
icon={
@@ -145,6 +149,7 @@ export default function ProfileHeader(props: {
},
]}
/>
</Tooltip>
</Row>
) : (
<Row className="items-center gap-1 sm:gap-2">

View File

@@ -544,6 +544,16 @@
"profile.language.serbo-croatian": "Serbokroatisch",
"profile.language.shona": "Shona",
"profile.language.sindhi": "Sindhi",
"onboarding.soft-gate.seo.title": "Sie sind bereit zu erkunden - Compass",
"onboarding.soft-gate.seo.description": "Starten Sie mit Compass - transparente Verbindungen ohne Algorithmen",
"onboarding.soft-gate.title": "Sie sind bereit zu erkunden",
"onboarding.soft-gate.intro": "Sie haben Ihre ersten Kompatibilitätsfragen beantwortet und Ihre Hauptinteressen geteilt.",
"onboarding.soft-gate.what_it_means": "Das bedeutet das für Sie:",
"onboarding.soft-gate.bullet1": "Kompatibilitätswerte spiegeln jetzt Ihre Werte und Präferenzen wider",
"onboarding.soft-gate.bullet2": "Sie sehen Übereinstimmungswerte, die eng mit dem übereinstimmen, was Ihnen wichtig ist",
"onboarding.soft-gate.bullet3": "Sie können Ihr Profil jederzeit aktualisieren, um die Chancen zu erhöhen, dass die richtigen Personen sich an Sie wenden.",
"onboarding.soft-gate.explore_button": "Profile jetzt erkunden",
"onboarding.soft-gate.refine_button": "Profil verfeinern",
"profile.language.sinhala": "Singhalesisch",
"profile.language.somali": "Somali",
"profile.language.southern-min": "Süd-Min",
@@ -927,5 +937,40 @@
"vote.toast.created": "Vorschlag erstellt",
"vote.urgent": "Dringend",
"vote.voted": "Stimme gespeichert",
"onboarding.skip": "Onboarding überspringen",
"onboarding.welcome": "Willkommen bei Compass",
"onboarding.step1.title": "Keine Black Box. Keine Manipulation.",
"onboarding.step1.body1": "Compass entscheidet nicht, wen Sie sehen sollten.",
"onboarding.step1.body2": "Es gibt keinen Engagement-Algorithmus, kein Swipe-Ranking, keine boosteten Profile und keine Aufmerksamkeitsoptimierung. Sie können die vollständige Datenbank durchsuchen, Ihre eigenen Filter anwenden und genau sehen, warum jemand mit Ihnen übereinstimmt.",
"onboarding.step1.body3": "Sie behalten die Kontrolle über die Entdeckung. Immer.",
"onboarding.step1.footer": "Transparenz ist ein Grundprinzip, keine Funktion.",
"onboarding.step2.title": "Suchen, nicht scrollen.",
"onboarding.step2.body1": "Anstatt endlosem Swiping erlaubt Ihnen Compass, gezielt zu suchen.",
"onboarding.step2.body2": "Suchen Sie Personen nach:",
"onboarding.step2.item1": "Interessen und Schlüsselwörtern",
"onboarding.step2.item2": "Werten und Ideen",
"onboarding.step2.item3": "Kompatibilitätsantworten",
"onboarding.step2.item4": "Standort und Absicht",
"onboarding.step2.body3": "Sie können Suchen speichern und benachrichtigt werden, wenn neue Personen übereinstimmen. Keine Notwendigkeit, die App jeden Tag zu überprüfen.",
"onboarding.step2.footer": "Weniger Lärm. Mehr Signal.",
"onboarding.step3.title": "Kompatibilität, die Sie verstehen können.",
"onboarding.step3.body1": "Matches sind weder magisch noch mysteriös.",
"onboarding.step3.body2": "Ihr Kompatibilitäts-Score kommt von expliziten Fragen:",
"onboarding.step3.item1": "Ihre Antwort",
"onboarding.step3.item2": "Welche Antworten Sie von anderen akzeptieren",
"onboarding.step3.item3": "Wie wichtig jede Frage für Sie ist",
"onboarding.step3.body3": "Sie können das System inspizieren, in Frage stellen und verbessern. Die vollständige Mathematik ist Open Source.",
"onboarding.step3.footer": "Wenn Sie nicht mit der Funktionsweise einverstanden sind, können Sie helfen, sie zu ändern.",
"onboarding.step3.continue": "Loslegen",
"common.continue": "Weiter",
"compatibility.onboarding.title": "Sehen Sie, mit wem Sie tatsächlich übereinstimmen",
"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.",
"compatibility.onboarding.start": "Fragen beantworten beginnen",
"compatibility.onboarding.later": "Später erledigen",
"more_options_user.edit_profile": "Profil bearbeiten",
"more_options_user.profile_options": "Profiloptionen",
"more_options_user.edit_bio": "Bio bearbeiten",
"vote.with_priority": "mit Priorität"
}

View File

@@ -544,6 +544,16 @@
"profile.language.serbo-croatian": "Serbo-croate",
"profile.language.shona": "Shona",
"profile.language.sindhi": "Sindhi",
"onboarding.soft-gate.seo.title": "Vous êtes prêt à explorer - Compass",
"onboarding.soft-gate.seo.description": "Commencez avec Compass - des connexions transparentes sans algorithmes",
"onboarding.soft-gate.title": "Vous êtes prêt à explorer",
"onboarding.soft-gate.intro": "Vous avez répondu à vos premières questions de compatibilité et partagé vos principaux centres d'intérêt.",
"onboarding.soft-gate.what_it_means": "Voici ce que cela signifie pour vous :",
"onboarding.soft-gate.bullet1": "Les scores de compatibilité reflètent désormais vos valeurs et préférences",
"onboarding.soft-gate.bullet2": "Vous verrez des pourcentages de compatibilité qui correspondent étroitement à ce qui vous importe",
"onboarding.soft-gate.bullet3": "Vous pouvez mettre à jour votre profil à tout moment pour augmenter les chances que les bonnes personnes vous contactent.",
"onboarding.soft-gate.explore_button": "Explorer les profils maintenant",
"onboarding.soft-gate.refine_button": "Affiner votre profil",
"profile.language.sinhala": "Cingalais",
"profile.language.somali": "Somali",
"profile.language.southern-min": "Min du Sud",
@@ -927,5 +937,40 @@
"vote.toast.created": "Proposition créée",
"vote.urgent": "Urgente",
"vote.voted": "Vote enregistré",
"onboarding.skip": "Passer le tutoriel",
"onboarding.welcome": "Bienvenue sur Compass!",
"onboarding.step1.title": "Pas de boîte noire. Pas de manipulation.",
"onboarding.step1.body1": "Compass ne décide pas qui vous devriez voir.",
"onboarding.step1.body2": "Il n'y a pas d'algorithme d'engagement, pas de classement par swipe, pas de profils boostés et pas d'optimisation de l'attention. Vous pouvez parcourir la base de données complète, appliquer vos propres filtres et voir exactement pourquoi quelqu'un vous correspond.",
"onboarding.step1.body3": "Vous gardez le contrôle de la découverte. Toujours.",
"onboarding.step1.footer": "La transparence est un principe fondamental, pas une fonctionnalité.",
"onboarding.step2.title": "Cherchez, sans swipe.",
"onboarding.step2.body1": "Au lieu d'un défilement sans fin, Compass vous permet de chercher intentionnellement.",
"onboarding.step2.body2": "Cherchez des personnes par :",
"onboarding.step2.item1": "Intérêts et mots-clés",
"onboarding.step2.item2": "Valeurs et idées",
"onboarding.step2.item3": "Réponses de compatibilité",
"onboarding.step2.item4": "Lieu et intention",
"onboarding.step2.body3": "Vous pouvez sauvegarder des recherches et être notifié lorsque de nouvelles personnes vous correspondent. Pas besoin de vérifier l'application chaque jour.",
"onboarding.step2.footer": "Moins de bruit. Plus de signal.",
"onboarding.step3.title": "Compatibilité que vous pouvez comprendre.",
"onboarding.step3.body1": "Les correspondances ne sont ni magiques ni mystérieuses.",
"onboarding.step3.body2": "Votre score de compatibilité provient de questions explicites :",
"onboarding.step3.item1": "Votre réponse",
"onboarding.step3.item2": "Quelles réponses vous acceptez des autres",
"onboarding.step3.item3": "L'importance de chaque question pour vous",
"onboarding.step3.body3": "Vous pouvez inspecter, questionner et améliorer le système. Les maths derrière sont open source.",
"onboarding.step3.footer": "Si vous n'êtes pas d'accord avec son fonctionnement, vous pouvez aider à le changer.",
"onboarding.step3.continue": "Commencer",
"common.continue": "Continuer",
"compatibility.onboarding.title": "Questions de 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.",
"compatibility.onboarding.start": "Commencer à répondre",
"compatibility.onboarding.later": "Faire ça plus tard",
"more_options_user.edit_profile": "Modifier le profil",
"more_options_user.profile_options": "Options du profil",
"more_options_user.edit_bio": "Options de biographie",
"vote.with_priority": "avec priorité"
}

View File

@@ -0,0 +1,227 @@
import {useState} from 'react'
import {Col} from 'web/components/layout/col'
import Router from 'next/router'
import {Button} from 'web/components/buttons/button'
import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator";
import {useT} from "web/lib/locale";
interface OnboardingStepProps {
onNext: () => void
onSkip: () => void
}
interface OnboardingScreenProps {
title: string
content: React.ReactNode
footerText?: string
onNext: () => void
onSkip: () => void
onBack?: () => void
continueText?: string
welcomeTitle?: string
}
function OnboardingScreen({
title,
content,
footerText,
onNext,
onSkip,
onBack,
continueText = undefined,
welcomeTitle
}: OnboardingScreenProps) {
const t = useT()
return (
<Col className="max-w-2xl mx-auto text-center px-6 py-12">
{onBack && (
<div className="self-start mb-4">
<button
onClick={onBack}
className="text-ink-500 hover:text-ink-700 text-sm"
>
{t("common.back", "Back")}
</button>
</div>
)}
{welcomeTitle && (
<h2 className="text-5xl text-gray-500 mb-4 animate-fade-in animate-fade-out-slow">
{welcomeTitle}
</h2>
)}
<h1 className="text-4xl font-bold text-ink-900 mb-6">
{title}
</h1>
<div className="text-lg text-ink-700 leading-relaxed mb-8">
{content}
</div>
{footerText && (
<p className="text-sm text-ink-500 italic mb-8">
{footerText}
</p>
)}
<Col className="gap-4">
<Button
onClick={onNext}
size="lg"
className="w-full max-w-xs mx-auto"
>
{continueText ?? t('common.continue', 'Continue')}
</Button>
<button
onClick={onSkip}
className="text-sm text-ink-500 hover:text-ink-700 underline"
>
{t('onboarding.skip', 'Skip onboarding')}
</button>
</Col>
</Col>
)
}
function Step1NoHiddenAlgorithms({onNext, onSkip}: OnboardingStepProps) {
const t = useT()
const content = (
<div className="space-y-4">
<p>
{t('onboarding.step1.body1', 'Compass does not decide who you should see.')}
</p>
<p>
{t('onboarding.step1.body2', 'There is no engagement algorithm, no swipe-ranking, no boosted profiles, and no attention optimization. You can browse the full database, apply your own filters, and see exactly why someone matches with you.')}
</p>
<p className="font-semibold">
{t('onboarding.step1.body3', 'You stay in control of discovery. Always.')}
</p>
</div>
)
return (
<OnboardingScreen
title={t('onboarding.step1.title', 'No black box. No manipulation.')}
content={content}
footerText={t('onboarding.step1.footer', 'Transparency is a core principle, not a feature.')}
onNext={onNext}
onSkip={onSkip}
welcomeTitle={t('onboarding.welcome', 'Welcome to Compass!')}
/>
)
}
function Step2SearchBeatsSwiping({onNext, onSkip, onBack}: OnboardingStepProps & { onBack: () => void }) {
const t = useT()
const content = (
<div className="space-y-4">
<p>
{t('onboarding.step2.body1', 'Instead of endless swiping, Compass lets you search intentionally.')}
</p>
<p className="text-left max-w-md mx-auto">
{t('onboarding.step2.body2', 'Look for people by:')}
</p>
<ul className="text-left max-w-md mx-auto space-y-2">
<li>{t('onboarding.step2.item1', 'Interests and keywords')}</li>
<li>{t('onboarding.step2.item2', 'Values and ideas')}</li>
<li>{t('onboarding.step2.item3', 'Compatibility answers')}</li>
<li>{t('onboarding.step2.item4', 'Location and intent')}</li>
</ul>
<p>
{t('onboarding.step2.body3', 'You can save searches and get notified when new people match them. No need to check the app every day.')}
</p>
</div>
)
return (
<OnboardingScreen
title={t('onboarding.step2.title', 'Search, don\'t scroll.')}
content={content}
footerText={t('onboarding.step2.footer', 'Less noise. More signal.')}
onNext={onNext}
onSkip={onSkip}
onBack={onBack}
/>
)
}
function Step3CompatibilityInspect({onNext, onSkip, onBack}: OnboardingStepProps & { onBack: () => void }) {
const t = useT()
const content = (
<div className="space-y-4">
<p>
{t('onboarding.step3.body1', 'Matches aren\'t magic or mysterious.')}
</p>
<p>
{t('onboarding.step3.body2', 'Your compatibility score comes from explicit questions:')}
</p>
<ul className="text-left max-w-md mx-auto space-y-2">
<li>{t('onboarding.step3.item1', 'Your answer')}</li>
<li>{t('onboarding.step3.item2', 'What answers you accept from others')}</li>
<li>{t('onboarding.step3.item3', 'How important each question is to you')}</li>
</ul>
<p>
{t('onboarding.step3.body3', 'You can inspect, question, and improve the system. The full math is open source.')}
</p>
</div>
)
return (
<OnboardingScreen
title={t('onboarding.step3.title', 'Compatibility you can understand.')}
content={content}
footerText={t('onboarding.step3.footer', 'If you disagree with how it works, you can help change it.')}
onNext={onNext}
onSkip={onSkip}
onBack={onBack}
continueText={t('onboarding.step3.continue', 'Get started')}
/>
)
}
export default function OnboardingPage() {
const [currentStep, setCurrentStep] = useState(0)
const handleNext = () => {
setCurrentStep(currentStep + 1)
}
const handleBack = () => {
setCurrentStep(currentStep - 1)
}
const handleSkip = () => {
Router.push('/signup')
}
const handleComplete = () => {
Router.push('/signup')
return <CompassLoadingIndicator/>
}
const renderStep = () => {
switch (currentStep) {
case 0:
return <Step1NoHiddenAlgorithms onNext={handleNext} onSkip={handleSkip}/>
case 1:
return <Step2SearchBeatsSwiping onNext={handleNext} onSkip={handleSkip} onBack={handleBack}/>
case 2:
return <Step3CompatibilityInspect onNext={handleComplete} onSkip={handleSkip} onBack={handleBack}/>
default:
return handleComplete()
}
}
return (
// <PageBase>
// <SEO
// title="Welcome to Compass - Onboarding"
// description="Get started with Compass - transparent dating without algorithms"
// />
<Col className="min-h-screen items-center justify-center">
{renderStep()}
</Col>
// </PageBase>
)
}

View File

@@ -0,0 +1,74 @@
import {Col} from 'web/components/layout/col'
import Router from 'next/router'
import {Button} from 'web/components/buttons/button'
import {useUser} from "web/hooks/use-user";
import {PageBase} from "web/components/page-base";
import {SEO} from "web/components/SEO";
import {useT} from "web/lib/locale";
export default function SoftGatePage() {
const user = useUser()
const t = useT()
const handleExplore = () => {
Router.push('/')
}
const handleRefine = () => {
Router.push(`/${user?.username ?? ''}`)
}
return (
<PageBase trackPageView={'soft gate'}>
<SEO
title={t("onboarding.soft-gate.seo.title", "You're ready to explore - Compass")}
description={t("onboarding.soft-gate.seo.description", "Get started with Compass - transparent connections without algorithms")}
/>
<Col className="min-h-screen items-center justify-center p-6">
<Col className="max-w-xl w-full gap-6 text-center">
<h1 className="text-4xl font-semibold text-ink-900">
{t("onboarding.soft-gate.title", "You're ready to explore")}
</h1>
<Col className="gap-4 text-ink-700">
<p>
{t("onboarding.soft-gate.intro", "You've answered your first compatibility questions and shared your top interests.")}
</p>
<p className="text-left space-y-2 text-ink-600">
{t("onboarding.soft-gate.what_it_means", "Here's what that means for you:")}
</p>
<ul className="text-left mx-auto space-y-2">
<li>
{t("onboarding.soft-gate.bullet1", "Compatibility scores now reflect your values and preferences")}
</li>
<li>
<span>{t("onboarding.soft-gate.bullet2", "You'll see match percentages that align closely with what you care about")}</span>
</li>
<li>
<span>{t("onboarding.soft-gate.bullet3", "You can update your profile anytime to increase the chances of the right people reaching out.")}</span>
</li>
</ul>
</Col>
<Col className="gap-3 items-center">
<Button
onClick={handleExplore}
size="lg"
>
{t("onboarding.soft-gate.explore_button", "Explore Profiles Now")}
</Button>
<button
onClick={handleRefine}
className="text-sm text-ink-500 hover:text-ink-700 transition-colors"
>
{t("onboarding.soft-gate.refine_button", "Refine Profile")}
</button>
</Col>
</Col>
</Col>
</PageBase>
)
}

View File

@@ -51,8 +51,8 @@ function RegisterComponent() {
console.log("Router.push('/')")
await Router.push('/')
} else {
console.log("Router.push('/signup')")
await Router.push('/signup')
console.log("Router.push('/onboarding')")
await Router.push('/onboarding')
}
setIsLoading(false)
}

View File

@@ -52,7 +52,7 @@ function RegisterComponent() {
if (profile) {
await Router.push('/')
} else {
await Router.push('/signup')
await Router.push('/onboarding')
}
} catch (error) {
console.error("Error fetching profile profile:", error)

View File

@@ -494,3 +494,38 @@ ol > li::marker {
color: #374151; /* pick a visible color */
}
/* Onboarding animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-out-slow {
0% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-5px);
}
}
.animate-fade-in {
animation: fade-in 0.8s ease-out;
}
.animate-fade-out-slow {
animation: fade-out-slow 3s ease-out forwards;
}