Introduce new components and utilities: ProfileAvatar, ShareCTAButton, custom video extensions, and responsive hooks.

- Added `ProfileAvatar` for consistent avatar rendering across profiles, with fallback initials and gradient backgrounds.
- Created `ShareCTAButton` for sharing links with native sharing support and clipboard fallback.
- Integrated custom `tiptap` video extension (`tiptap-video`) for video support in rich text editing.
- Introduced `useColumnCount` for responsive column count management in dynamic masonry layouts.
- Enhanced onboarding flow with refined "Share My Profile" feature using the new share button.
- Updated styles and layout in filtered profiles and onboarding screens for better consistency and responsiveness.
This commit is contained in:
MartinBraquet
2026-07-27 00:51:20 +02:00
parent b6536a9b58
commit a5c4969d48
36 changed files with 1539 additions and 605 deletions

View File

@@ -11,7 +11,7 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 133
versionCode 134
versionName "1.32.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -4,7 +4,7 @@ service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read;
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
allow write: if request.auth != null && request.resource.size <= 20 * 1024 * 1024;
}
}
}

View File

@@ -684,6 +684,7 @@
"onboarding.soft-gate.refine_button": "Profil verfeinern",
"onboarding.soft-gate.seo.description": "Starten Sie mit Compass - transparente Verbindungen ohne Algorithmen",
"onboarding.soft-gate.seo.title": "Sie sind bereit zu erkunden - Compass",
"onboarding.soft-gate.share_button": "Mein Profil teilen",
"onboarding.soft-gate.title": "Sie sind bereit zu erkunden",
"onboarding.soft-gate.what_it_means": "Das bedeutet das für Sie:",
"onboarding.step1.body1": "Compass entscheidet nicht, wen Sie sehen sollten.",
@@ -1556,7 +1557,7 @@
"stats.with_bio": "Mit Bio",
"sticky_format_menu.add_embed": "Embed hinzufügen",
"sticky_format_menu.add_emoji": "Emoji hinzufügen",
"sticky_format_menu.upload_image": "Bild hochladen",
"sticky_format_menu.upload_image": "Foto oder Video hochladen",
"terms.changes.text": "Wir können diese Bedingungen gelegentlich aktualisieren. Die weitere Nutzung von Compass nach Änderungen gilt als Zustimmung zu den neuen Bedingungen.",
"terms.changes.title": "6. Änderungen",
"terms.contact": "Bei Fragen zu diesen Bedingungen kontaktieren Sie uns bitte unter ",

View File

@@ -683,6 +683,7 @@
"onboarding.soft-gate.refine_button": "Affiner votre profil",
"onboarding.soft-gate.seo.description": "Commencez avec Compass - des connexions transparentes sans algorithmes",
"onboarding.soft-gate.seo.title": "Vous êtes prêt à explorer - Compass",
"onboarding.soft-gate.share_button": "Partager mon profil",
"onboarding.soft-gate.title": "Vous êtes prêt à explorer",
"onboarding.soft-gate.what_it_means": "Voici ce que cela signifie pour vous :",
"onboarding.step1.body1": "Compass ne décide pas qui vous devriez voir.",
@@ -1555,7 +1556,7 @@
"stats.with_bio": "Complétés",
"sticky_format_menu.add_embed": "Ajouter un embed",
"sticky_format_menu.add_emoji": "Ajouter un emoji",
"sticky_format_menu.upload_image": "Ajouter une image",
"sticky_format_menu.upload_image": "Ajouter une photo ou vidéo",
"terms.changes.text": "Nous pouvons mettre à jour ces Conditions périodiquement. La poursuite de l'utilisation de Compass après les mises à jour vaut acceptation des nouvelles Conditions.",
"terms.changes.title": "6. Modifications",
"terms.contact": "Pour toute question concernant ces Conditions, veuillez nous contacter à ",

View File

@@ -20,22 +20,28 @@ export type DisplayOptions = {
showBio: boolean | null | undefined
}
/**
* What a profile card shows out of the box: city, age, headline, keywords, bio and the photo.
* Gender stays a toggle people can switch on, it's just off by default. The rest live on the
* profile page — their card renders are commented out in `web/components/profile-grid.tsx`, so
* those flags are inert until they're restored.
*/
export const initialDisplayOptions: DisplayOptions = {
showPhotos: true,
showAge: undefined,
showGender: undefined,
showLanguages: true,
cardSize: 'medium',
showAge: true,
showCity: true,
showHeadline: true,
showKeywords: true,
showCity: true,
showOccupation: true,
showSeeking: true,
showInterests: true,
showCauses: false,
showDiet: true,
showSmoking: true,
showDrinks: true,
showMBTI: true,
showBio: true,
cardSize: 'medium',
showGender: false,
showLanguages: false,
showOccupation: false,
showSeeking: false,
showInterests: false,
showCauses: false,
showDiet: false,
showSmoking: false,
showDrinks: false,
showMBTI: false,
}

View File

@@ -14,6 +14,7 @@ import {uniq} from 'lodash'
import {compareTwoStrings} from 'string-similarity'
import Iframe from './tiptap-iframe'
import Video from './tiptap-video'
/** get first url in text. like "notion.so " -> "http://notion.so" "notion" -> null */
export function getUrl(text: string) {
@@ -49,7 +50,8 @@ export function parseMentions(data: JSONContent): string[] {
export const extensions = [
StarterKit,
Link,
Image.extend({renderText: () => '[image]'}),
Image.extend({renderText: () => '📷 Photo'}),
Video.extend({renderText: () => '🎥 Video'}),
Mention, // user @mention
Iframe.extend({
renderText: ({node}) => ('[embed]' + node.attrs.src ? `(${node.attrs.src})` : ''),

View File

@@ -0,0 +1,46 @@
// Modeled on @tiptap/extension-image (there is no official @tiptap/extension-video package).
import {mergeAttributes, Node} from '@tiptap/core'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: {src: string}) => ReturnType
}
}
}
const Video = Node.create({
name: 'video',
group: 'block',
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
}
},
parseHTML() {
return [{tag: 'video[src]'}]
},
renderHTML({HTMLAttributes}) {
return ['video', mergeAttributes(HTMLAttributes, {controls: true})]
},
addCommands() {
return {
setVideo:
(options: {src: string}) =>
({commands}) =>
commands.insertContent({type: this.name, attrs: options}),
}
},
})
export default Video

View File

@@ -110,6 +110,31 @@ canvas. Formats are defined in `src/theme.ts` (`FORMATS`) and registered in `src
> Don't post the 9:16 story as a feed post — Instagram crops it to ~4:5. Use `IntroPost` for the feed.
### Profile scroll b-roll (`ProfileScrollStory`)
A 6-second silent scroll of one profile, made to sit **under the closing sentences of a talking-head
clip** — not to stand on its own. No audio (the voice on the track is the audio), no captions, no logo
animation. The profile URL is on screen from the first frame to the last, because most people meet a
clip like this as a screenshot or a repost, with no link sticker attached.
The motion is one steady scroll with a single hold on the "good news / bad news" paragraph, which is
the passage such a voiceover is usually about. A little velocity-proportional blur covers the fast
middle stretch; at 30fps a hard-edged scroll strobes.
```bash
node scripts/capture-profile.mjs https://www.compassmeet.com/mhg1 --out profile-mhg1
npm run render:scroll # -> out/compass-profile-scroll-story.mp4 9:16 (1080×1920)
```
`--out <dir>` is what keeps this from overwriting `public/profile/`, which the profile-tour video is
built from. The scene reads three cards — header, details, bio — and stacks them in page order; the
`SHOTS` table at the top of `src/scenes/ProfileScroll.tsx` carries their dimensions and the two
offsets inside the bio card that the hold is aimed at, so a bio of a different length needs those
numbers re-measured.
> Long renders here can trip the 30s `delayRender` on `BrandFonts` (registered for the OG card, but
> bundled into every composition). Hence `--timeout=120000` in the script.
## Stills
### Social preview card (`OgCard`)

View File

@@ -17,6 +17,7 @@
"render:alert:dark": "remotion render SearchAlertDark out/compass-search-alert-dark.mp4 --crf 30",
"render:carousel": "node scripts/render-carousel.mjs",
"render:post": "remotion render IntroPost out/compass-intro-post.mp4",
"render:scroll": "remotion render ProfileScrollStory out/compass-profile-scroll-story.mp4 --timeout=120000",
"render:search": "remotion render SearchDemoLight out/compass-search-demo-light.mp4 --crf 30",
"render:search:dark": "remotion render SearchDemoDark out/compass-search-demo-dark.mp4 --crf 30",
"render:story": "remotion render IntroStory out/compass-intro-story.mp4",

View File

@@ -31,8 +31,13 @@ const {chromium} = pw
const HERE = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const LOGIN = args.includes('--login')
const URL = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000/Martin'
const OUT_DIR = join(HERE, '..', 'public', 'profile')
// --out <dir> writes elsewhere under public/, so capturing a second profile doesn't
// overwrite the artwork the profile-tour video is built from.
const outAt = args.indexOf('--out')
const OUT_NAME = outAt === -1 ? 'profile' : args[outAt + 1]
const URL =
args.find((a, i) => !a.startsWith('--') && i !== outAt + 1) ?? 'http://localhost:3000/Martin'
const OUT_DIR = join(HERE, '..', 'public', OUT_NAME)
const PROFILE_DIR = join(HERE, '..', '.auth-profile')
// Card title -> output file. Titles come from the ProfileCard headings in

View File

@@ -2,6 +2,7 @@ import {Composition, Still} from 'remotion'
import {FORMATS} from './theme'
import {Intro, INTRO_DURATION} from './scenes/Intro'
import {OgCard} from './scenes/OgCard'
import {ProfileScroll, PROFILE_SCROLL_DURATION} from './scenes/ProfileScroll'
import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour'
import {SearchDemo, SearchDemoProps, calculateSearchDemoMetadata} from './scenes/SearchDemo'
import {SearchAlert, SearchAlertProps, calculateSearchAlertMetadata} from './scenes/SearchAlert'
@@ -46,6 +47,17 @@ export const RemotionRoot: React.FC = () => {
width={FORMATS.post.width}
height={FORMATS.post.height}
/>
{/* Six-second silent b-roll of one profile, to lay under the closing sentences of a
talking-head story. Story format only — it is never a standalone post.
node scripts/capture-profile.mjs <profileUrl> --out profile-mhg1 && npm run render:scroll */}
<Composition
id="ProfileScrollStory"
component={ProfileScroll}
durationInFrames={PROFILE_SCROLL_DURATION}
fps={FORMATS.story.fps}
width={FORMATS.story.width}
height={FORMATS.story.height}
/>
{/* Home-page hero clip. Not a social format: its canvas is the capture viewport, and both
size and duration come from public/search/manifest.json via calculateMetadata, so a
re-capture with a different --query needs no change here.

View File

@@ -0,0 +1,170 @@
import React from 'react'
import {AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig} from 'remotion'
import {FORMATS, colors, fonts} from '../theme'
// A six-second silent b-roll scroll of a single profile, made to sit *under* the
// closing sentences of a talking-head clip rather than to stand on its own:
//
// - no audio, no captions, no logo animation — the voice on the track is the audio
// - the profile URL is on screen from frame 0 to the last frame, because most
// people meet this clip as a screenshot or a repost, without a link sticker
// - a beat on the header, a readable pass down the details, then a slightly
// slower pass down the whole of the bio, ending on its last line
//
// Artwork is real screenshots of the live page, captured by scripts/capture-profile.mjs:
// node scripts/capture-profile.mjs https://www.compassmeet.com/mhg1 --out profile-mhg1
// Re-run that after any profile-UI change; update SHOTS below if a card's height moves.
const DIR = 'profile-mhg1'
const PROFILE_URL = 'compassmeet.com/mhg1'
// CSS-px dimensions reported by the capture script. The PNGs are 2× these numbers;
// we scale by width, so only the ratio matters. Order is the order they are stacked.
const SHOTS = [
{src: `${DIR}/01-header.png`, w: 844, h: 758},
{src: `${DIR}/02-details.png`, w: 844, h: 3102},
{src: `${DIR}/06-bio.png`, w: 844, h: 3424},
] as const
const GAP = 36 // page gutter between cards, in source px
const BIO_INDEX = 2 // the card the second half of the clip reads through
// A hair of the card's top edge left showing, so the bio reads as part of a page
// rather than as a full-bleed slab of text.
const BIO_HEADROOM = 40
// ─── Motion ─────────────────────────────────────────────────────────────────
// Timing is expressed as scroll *speed*, not as a frame budget, so the clip stays
// legible whatever the profile's length: a longer bio makes a longer video rather
// than a faster, unreadable one. Duration falls out of the geometry below.
//
// The bio is the point of the clip, so it moves slowest. The details run a little
// quicker — skimmable rather than readable, which is what that card is.
const HOLD_FRAMES = 30 // 1.0s beat on his photo and name before anything moves
const DETAILS_SPEED = 28 // px per frame
const BIO_SPEED = 18.5 // px per frame
// ─── Geometry ───────────────────────────────────────────────────────────────
// Derived at module scope because the composition's duration depends on it, and
// that has to be known before the component renders. Canvas width is the story
// format's, which is the only format this scene is registered for.
const SCALE = FORMATS.story.width / SHOTS[0].w
const TOPS: number[] = []
let cursor = 0
for (const shot of SHOTS) {
TOPS.push(cursor)
cursor += shot.h + GAP
}
const PAGE_HEIGHT = (cursor - GAP) * SCALE
// Where the details pass stops: the top of the bio card, just below the frame edge.
const BIO_Y = -(TOPS[BIO_INDEX] * SCALE - BIO_HEADROOM)
// Where it finishes: the bottom of the bio card resting on the bottom of the frame,
// which on this page is also the end of the scroll.
const END_Y = -(PAGE_HEIGHT - FORMATS.story.height)
const DETAILS_END = HOLD_FRAMES + Math.round(Math.abs(BIO_Y) / DETAILS_SPEED)
export const PROFILE_SCROLL_DURATION =
DETAILS_END + Math.round(Math.abs(END_Y - BIO_Y) / BIO_SPEED)
export const ProfileScroll: React.FC = () => {
const frame = useCurrentFrame()
const {width} = useVideoConfig()
const y = interpolate(
frame,
[0, HOLD_FRAMES, DETAILS_END, PROFILE_SCROLL_DURATION],
[0, 0, BIO_Y, END_Y],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: (t) => t, // per-segment linear; the segments themselves are the rhythm
},
)
return (
<AbsoluteFill style={{backgroundColor: colors.canvas100, overflow: 'hidden'}}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width,
height: PAGE_HEIGHT,
transform: `translateY(${y}px)`,
willChange: 'transform',
}}
>
{SHOTS.map((shot, i) => (
<Img
key={shot.src}
src={staticFile(shot.src)}
style={{
position: 'absolute',
top: TOPS[i] * SCALE,
left: 0,
width,
height: shot.h * SCALE,
}}
/>
))}
</div>
<UrlBar url={PROFILE_URL} />
</AbsoluteFill>
)
}
// Always on screen. The scrim is what guarantees it stays legible whatever the page
// is showing underneath — the profile scrolls past both cream cards and warm gutters.
const UrlBar: React.FC<{url: string}> = ({url}) => {
const {height} = useVideoConfig()
return (
<>
<AbsoluteFill
style={{
top: height - 560,
background: `linear-gradient(to bottom, rgba(237,232,224,0) 0%, rgba(237,232,224,0.55) 32%, rgba(237,232,224,0.94) 58%, rgba(237,232,224,1) 78%)`,
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: height - 310,
display: 'flex',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 22,
padding: '26px 52px',
borderRadius: 999,
backgroundColor: colors.cream,
border: `2px solid ${colors.amber}`,
boxShadow: '0 10px 40px rgba(44,36,22,0.16)',
}}
>
<Img src={staticFile('logo.svg')} style={{width: 52, height: 52}} />
<span
style={{
fontFamily: fonts.body,
fontSize: 50,
fontWeight: 600,
letterSpacing: '-0.01em',
color: colors.ink,
}}
>
{url}
</span>
</div>
</div>
</>
)
}

View File

@@ -44,6 +44,7 @@ test.describe('when given valid input', () => {
Number(filteredProfiles?.split(' ')[0]),
)
// The seeking line only renders on the Large card, which this test selects above.
const profile = await app.people.getProfileInfo()
if (!profile) throw new Error('No profile found')
const seeking = await getCardSeekingInfo(profile)

View File

@@ -22,7 +22,7 @@ export function FileUploadButton(props: {
<input
ref={ref}
type="file"
accept=".gif,.jpg,.jpeg,.png,.webp, image/*"
accept=".gif,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov, image/*, video/*"
multiple
className="hidden"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -1,6 +1,7 @@
import {Image} from '@tiptap/extension-image'
import clsx from 'clsx'
import {useState} from 'react'
import {MediaModal} from 'web/components/media-modal'
export const BasicImage = Image.extend({
renderReact: (attrs: any) => <img loading="lazy" {...attrs} alt={attrs.alt ?? ''} />,
@@ -31,14 +32,7 @@ function ExpandingImage(props: {src: string; alt?: string; title?: string; size?
)}
height={size === 'md' ? 400 : 128}
/>
{expanded && (
<div
className="bg-opacity fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60"
onClick={() => setExpanded(false)}
>
<img alt={alt ?? ''} {...rest} className="max-h-full cursor-pointer object-contain" />
</div>
)}
<MediaModal url={rest.src} open={expanded} setOpen={setExpanded} />
</>
)
}

View File

@@ -60,7 +60,7 @@ function UploadButton(props: {upload: UploadMutation}) {
return (
<Tooltip
text={t('sticky_format_menu.upload_image', 'Upload image')}
text={t('sticky_format_menu.upload_image', 'Upload photo or video')}
className="flex items-stretch"
placement="bottom"
>

View File

@@ -13,13 +13,18 @@ export const useUploadMutation = (editor: Editor | null) =>
useMutation(
(files: File[]) =>
// TODO: Images should be uploaded under a particular username
Promise.all(files.map((file) => uploadImage('default', file))),
Promise.all(
files.map(async (file) => ({
src: await uploadImage('default', file),
isVideo: file.type.startsWith('video'),
})),
),
{
onSuccess(urls) {
if (!editor || !urls.length) return
onSuccess(uploads) {
if (!editor || !uploads.length) return
let trans = editor.chain().focus()
urls.forEach((src) => {
trans = trans.setImage({src})
uploads.forEach(({src, isVideo}) => {
trans = isVideo ? trans.setVideo({src}) : trans.setImage({src})
trans = trans.createParagraphNear()
})
trans.run()

View File

@@ -0,0 +1,38 @@
import clsx from 'clsx'
import Video from 'common/util/tiptap-video'
import {useState} from 'react'
import {MediaModal} from 'web/components/media-modal'
export const BasicVideo = Video.extend({
renderReact: (attrs: any) => <video src={attrs.src} controls preload="metadata" />,
})
export const DisplayVideo = Video.extend({
renderReact: (attrs: any) => <ExpandingVideo {...attrs} />,
})
export const MediumDisplayVideo = Video.extend({
renderReact: (attrs: any) => <ExpandingVideo size={'md'} {...attrs} />,
})
function ExpandingVideo(props: {src: string; size?: 'md'}) {
const [expanded, setExpanded] = useState(false)
const {size, ...rest} = props
return (
<>
<video
{...rest}
muted
playsInline
preload="metadata"
onClick={() => setExpanded(true)}
className={clsx(
'cursor-pointer object-contain',
size === 'md' ? 'max-h-[400px]' : 'h-[128px]',
)}
/>
<MediaModal url={rest.src} open={expanded} setOpen={setExpanded} />
</>
)
}

View File

@@ -4,21 +4,23 @@ import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
// Commented-out entries are fields the profile card no longer renders (they live on the profile
// page). Kept here so they're one line away from coming back.
const TOGGLE_FIELDS: Array<{key: keyof DisplayOptions; labelKey: string; fallbackLabel: string}> = [
{key: 'showGender', labelKey: 'filter.show_gender', fallbackLabel: 'Gender'},
{key: 'showCity', labelKey: 'filter.show_city', fallbackLabel: 'City'},
{key: 'showAge', labelKey: 'filter.show_age', fallbackLabel: 'Age'},
{key: 'showHeadline', labelKey: 'filter.show_headline', fallbackLabel: 'Headline'},
{key: 'showKeywords', labelKey: 'filter.show_keywords', fallbackLabel: 'Keywords'},
{key: 'showSeeking', labelKey: 'filter.show_seeking', fallbackLabel: 'What they seek'},
{key: 'showOccupation', labelKey: 'filter.show_occupation', fallbackLabel: 'Work'},
{key: 'showInterests', labelKey: 'filter.show_interests', fallbackLabel: 'Interests'},
{key: 'showCauses', labelKey: 'filter.show_causes', fallbackLabel: 'Causes'},
{key: 'showDiet', labelKey: 'filter.show_diet', fallbackLabel: 'Diet'},
{key: 'showSmoking', labelKey: 'filter.show_smoking', fallbackLabel: 'Smoking'},
{key: 'showDrinks', labelKey: 'filter.show_drinks', fallbackLabel: 'Drinks'},
{key: 'showMBTI', labelKey: 'filter.show_mbti', fallbackLabel: 'MBTI'},
{key: 'showLanguages', labelKey: 'filter.show_languages', fallbackLabel: 'Languages'},
// {key: 'showSeeking', labelKey: 'filter.show_seeking', fallbackLabel: 'What they seek'},
// {key: 'showOccupation', labelKey: 'filter.show_occupation', fallbackLabel: 'Work'},
// {key: 'showInterests', labelKey: 'filter.show_interests', fallbackLabel: 'Interests'},
// {key: 'showCauses', labelKey: 'filter.show_causes', fallbackLabel: 'Causes'},
// {key: 'showDiet', labelKey: 'filter.show_diet', fallbackLabel: 'Diet'},
// {key: 'showSmoking', labelKey: 'filter.show_smoking', fallbackLabel: 'Smoking'},
// {key: 'showDrinks', labelKey: 'filter.show_drinks', fallbackLabel: 'Drinks'},
// {key: 'showMBTI', labelKey: 'filter.show_mbti', fallbackLabel: 'MBTI'},
// {key: 'showLanguages', labelKey: 'filter.show_languages', fallbackLabel: 'Languages'},
{key: 'showBio', labelKey: 'filter.show_bio', fallbackLabel: 'Bio'},
{key: 'showPhotos', labelKey: 'filter.show_photos', fallbackLabel: 'Profile photo'},
]

View File

@@ -180,7 +180,7 @@ export const Search = forwardRef<
value={filters.orderBy || 'created_time'}
className={clsx(
'!h-10 w-auto !rounded-full !border-canvas-200 !bg-transparent !shadow-none text-xs text-ink-500',
highlightSort && 'border-blue-500 ring-2 ring-blue-300',
highlightSort && 'border-primary-500 ring-2 ring-primary-300',
)}
>
<option value="created_time">{t('common.new', 'New')}</option>
@@ -190,11 +190,12 @@ export const Search = forwardRef<
<option value="last_online_time">{t('common.active', 'Active')}</option>
</Select>
<Button
color={highlightFilters ? 'blue' : 'gray-white'}
color="gray-white"
size="sm"
className={clsx(
'!h-10 !rounded-full border border-canvas-200',
highlightFilters && 'border-blue-500',
highlightFilters &&
'border-primary-500 ring-2 ring-primary-300 bg-primary-50 text-primary-700',
)}
onClick={handleOpenFilters}
>
@@ -263,7 +264,7 @@ export const Search = forwardRef<
<Tooltip
text={t(
'search.include_short_bios_tooltip',
'To list all the profiles, tick "Include incomplete profiles"',
'To list incomplete profiles, go to Filters, then Advanced, and tick "Include incomplete profiles"',
)}
>
<QuestionMarkCircleIcon className="w-5 h-5" />

View File

@@ -1,9 +1,26 @@
import {Dialog, Transition} from '@headlessui/react'
import {XMarkIcon} from '@heroicons/react/24/outline'
import Image from 'next/image'
import {Fragment} from 'react'
import {
Fragment,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react'
import {isVideo} from 'web/lib/firebase/storage'
const MIN_SCALE = 1
const MAX_SCALE = 4
const DOUBLE_TAP_SCALE = 2.5
const DOUBLE_TAP_MAX_DELAY_MS = 300
const DOUBLE_TAP_MAX_DIST_PX = 30
const ZOOM_ANIMATION_MS = 200
type Point = {x: number; y: number}
// Enlarges an image or video to the largest size that fits within 90% of the
// viewport in both dimensions, without cropping (aspect ratio preserved).
export function MediaModal(props: {url: string; open: boolean; setOpen: (open: boolean) => void}) {
@@ -34,7 +51,7 @@ export function MediaModal(props: {url: string; open: boolean; setOpen: (open: b
<div className="sr-only">Close</div>
</button>
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="fixed inset-0 flex items-center justify-center p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-150"
@@ -44,23 +61,17 @@ export function MediaModal(props: {url: string; open: boolean; setOpen: (open: b
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative flex items-center justify-center">
<Dialog.Panel className="relative flex h-full w-full items-center justify-center">
{isVideo(url) ? (
<video
src={url}
controls
autoPlay
playsInline
className="h-auto max-h-[90vh] w-auto max-w-[90vw] rounded object-contain"
className="h-auto max-h-full w-auto max-w-full rounded object-contain"
/>
) : (
<Image
src={url}
width={2000}
height={2000}
alt=""
className="h-auto max-h-[90vh] w-auto max-w-[90vw] rounded object-contain"
/>
<ZoomableImage url={url} active={open} />
)}
</Dialog.Panel>
</Transition.Child>
@@ -69,3 +80,186 @@ export function MediaModal(props: {url: string; open: boolean; setOpen: (open: b
</Transition.Root>
)
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n))
}
function distance(a: Point, b: Point) {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function midpoint(a: Point, b: Point): Point {
return {x: (a.x + b.x) / 2, y: (a.y + b.y) / 2}
}
// Keeps `point` (a page coordinate, e.g. the cursor or pinch midpoint) visually
// fixed while the image scales from `fromScale` to `toScale` around `center`.
function anchoredTranslate(
point: Point,
center: Point,
translate: Point,
fromScale: number,
toScale: number,
): Point {
const vx = (point.x - center.x - translate.x) / fromScale
const vy = (point.y - center.y - translate.y) / fromScale
return {x: point.x - center.x - toScale * vx, y: point.y - center.y - toScale * vy}
}
// 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
const containerRef = useRef<HTMLDivElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
const [scale, setScale] = useState(1)
const [translate, setTranslate] = useState<Point>({x: 0, y: 0})
const [isAnimating, setIsAnimating] = useState(false)
const pointers = useRef(new Map<number, Point>())
const pinchDistance = useRef<number | null>(null)
const dragStart = useRef<{pointer: Point; translate: Point} | null>(null)
const lastTap = useRef<{time: number; point: Point} | null>(null)
// Reset zoom whenever a new image is shown or the modal opens/closes.
useEffect(() => {
setScale(1)
setTranslate({x: 0, y: 0})
pointers.current.clear()
pinchDistance.current = null
dragStart.current = null
lastTap.current = null
}, [url, active])
const clampTranslate = (s: number, t: Point): Point => {
const img = imageRef.current
if (!img || s <= 1) return {x: 0, y: 0}
const maxX = (img.offsetWidth * (s - 1)) / 2
const maxY = (img.offsetHeight * (s - 1)) / 2
return {x: clamp(t.x, -maxX, maxX), y: clamp(t.y, -maxY, maxY)}
}
const zoomAt = (point: Point, toScale: number, animate = false) => {
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
const center = {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2}
const nextScale = clamp(toScale, MIN_SCALE, MAX_SCALE)
const nextTranslate = clampTranslate(
nextScale,
anchoredTranslate(point, center, translate, scale, nextScale),
)
setScale(nextScale)
setTranslate(nextTranslate)
if (animate) {
setIsAnimating(true)
window.setTimeout(() => setIsAnimating(false), ZOOM_ANIMATION_MS)
}
}
const toggleZoom = (point: Point) => {
zoomAt(point, scale > 1 ? 1 : DOUBLE_TAP_SCALE, true)
}
const onWheel = (e: ReactWheelEvent) => {
e.preventDefault()
zoomAt({x: e.clientX, y: e.clientY}, scale * Math.exp(-e.deltaY * 0.002))
}
const onDoubleClick = (e: ReactMouseEvent) => {
toggleZoom({x: e.clientX, y: e.clientY})
}
const onPointerDown = (e: ReactPointerEvent) => {
;(e.target as Element).setPointerCapture?.(e.pointerId)
pointers.current.set(e.pointerId, {x: e.clientX, y: e.clientY})
if (pointers.current.size === 2) {
const [a, b] = Array.from(pointers.current.values())
pinchDistance.current = distance(a, b)
dragStart.current = null
} else if (pointers.current.size === 1) {
dragStart.current = {pointer: {x: e.clientX, y: e.clientY}, translate}
if (e.pointerType === 'touch') {
const now = Date.now()
const point = {x: e.clientX, y: e.clientY}
if (
lastTap.current &&
now - lastTap.current.time < DOUBLE_TAP_MAX_DELAY_MS &&
distance(lastTap.current.point, point) < DOUBLE_TAP_MAX_DIST_PX
) {
toggleZoom(point)
lastTap.current = null
} else {
lastTap.current = {time: now, point}
}
}
}
}
const onPointerMove = (e: ReactPointerEvent) => {
if (!pointers.current.has(e.pointerId)) return
pointers.current.set(e.pointerId, {x: e.clientX, y: e.clientY})
if (pointers.current.size === 2 && pinchDistance.current) {
const [a, b] = Array.from(pointers.current.values())
const newDistance = distance(a, b)
zoomAt(midpoint(a, b), scale * (newDistance / pinchDistance.current))
pinchDistance.current = newDistance
} else if (pointers.current.size === 1 && dragStart.current && scale > 1) {
const {pointer, translate: startTranslate} = dragStart.current
setTranslate(
clampTranslate(scale, {
x: startTranslate.x + (e.clientX - pointer.x),
y: startTranslate.y + (e.clientY - pointer.y),
}),
)
}
}
const endPointer = (e: ReactPointerEvent) => {
pointers.current.delete(e.pointerId)
if (pointers.current.size < 2) pinchDistance.current = null
if (pointers.current.size === 1) {
const [remaining] = Array.from(pointers.current.values())
dragStart.current = {pointer: remaining, translate}
} else if (pointers.current.size === 0) {
dragStart.current = null
}
}
return (
<div
ref={containerRef}
className="relative flex h-full w-full items-center justify-center overflow-hidden"
style={{touchAction: 'none'}}
onWheel={onWheel}
onDoubleClick={onDoubleClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onPointerLeave={endPointer}
>
<Image
ref={imageRef}
src={url}
width={2000}
height={2000}
alt=""
draggable={false}
className={`block h-auto max-h-full w-auto max-w-full select-none rounded object-contain ${
scale > 1 ? 'cursor-grab active:cursor-grabbing' : ''
}`}
style={{
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
transition: isAnimating ? `transform ${ZOOM_ANIMATION_MS}ms ease-out` : 'none',
}}
/>
</div>
)
}

View File

@@ -31,7 +31,7 @@ export const ProfileCardViewer = (props: {
width={width}
height={height}
alt={t('profile_card.alt', "{username}'s profile card", {username: user.username})}
className={`rounded-2xl transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
className={`rounded-2xl ring-1 ring-canvas-300/60 shadow-[0_8px_24px_-12px_rgb(44_36_22/0.35)] dark:ring-canvas-200/40 transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
priority
loading="eager"

View File

@@ -5,11 +5,12 @@ import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {Profile} from 'common/profiles/profile'
import {DisplayOptions} from 'common/profiles-rendering'
import {CardSize, DisplayOptions} from 'common/profiles-rendering'
import {parseJsonContentToText} from 'common/util/parse'
import {clamp} from 'lodash'
import {
Brain,
Briefcase,
Calendar,
Cigarette,
HandHeart,
Languages,
@@ -18,67 +19,208 @@ import {
Sparkles,
Wine,
} from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import React, {useEffect, useRef, useState} from 'react'
import React, {useMemo, useRef, useState} from 'react'
import {PiMagnifyingGlassBold} from 'react-icons/pi'
import {LocationFilterProps} from 'web/components/filters/location-filter'
import GenderIcon from 'web/components/gender-icon'
import {IconWithInfo} from 'web/components/icons'
import {Row} from 'web/components/layout/row'
import {SendMessageButton} from 'web/components/messaging/send-message-button'
import {ProfileAvatar} from 'web/components/profile/profile-avatar'
import {ProfileLocation} from 'web/components/profile/profile-location'
import {getSeekingText} from 'web/components/profile-about'
import {GetNotifiedButton} from 'web/components/searches/get-notified-button'
import {CompatibleBadge} from 'web/components/widgets/compatible-badge'
import {Content} from 'web/components/widgets/editor'
import {CompatibilityRing} from 'web/components/widgets/compatible-badge'
import HideProfileButton from 'web/components/widgets/hide-profile-button'
import {StarButton} from 'web/components/widgets/star-button'
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useChoicesContext} from 'web/hooks/use-choices'
import {ColumnCountOptions, useColumnCount} from 'web/hooks/use-column-count'
import {isDark, useTheme} from 'web/hooks/use-theme'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {getSeekingConnectionText} from 'web/lib/profile/seeking'
import {capitalizePure} from 'web/lib/util/time'
import {Col} from './layout/col'
export function ProfileGridSkeleton(props: {count?: number; className?: string}) {
const {count = 6, className} = props
/**
* Per-card-size tuning. Cards are text-first: the photo is a round avatar rather than a panel, so
* the headline and bio get the width and the visual weight.
*
* `headlineCharsPerLine` is a rough average for the rendered column width at that size — it only feeds
* the line budget below, so being a few characters off just shifts a clamp by one line.
*/
type CardSizeConfig = {
/** Feeds the masonry column count — see `useColumnCount`. */
columns: ColumnCountOptions
avatarPx: number
ringPx: number
padding: string
gap: string
nameClass: string
metaClass: string
headlineClass: string
bioClass: string
keywordClass: string
/** Caps the reading measure on wide cards — full-width lines get unreadable past ~90 chars. */
textWidthClass: string
/**
* Wide cards would otherwise be medium's content with a void beside it. Instead they get a rail
* of the detail fields the compact sizes leave to the profile page — that's the reason to pick
* this size: depth per person rather than people per screen.
*/
detailRail: boolean
/** Rough characters per rendered line for the headline, used to size the line budget. */
headlineCharsPerLine: number
/** Headline + bio lines a card aims for, so cards with a long headline show less bio. */
totalTextLines: number
maxHeadlineLines: number
maxBioLines: number
maxKeywords: number
}
const MIN_BIO_LINES = 2
const CARD_SIZE_CONFIG: Record<CardSize, CardSizeConfig> = {
small: {
columns: {minCardWidth: 300, maxColumns: 4},
avatarPx: 44,
ringPx: 30,
padding: 'px-3.5 py-3',
gap: 'gap-2',
nameClass: 'text-base',
metaClass: 'text-xs',
headlineClass: 'text-sm',
bioClass: 'text-[13px]',
keywordClass: 'text-[11px] px-2 py-0.5',
textWidthClass: '',
detailRail: false,
headlineCharsPerLine: 52,
totalTextLines: 6,
maxHeadlineLines: 3,
maxBioLines: 5,
maxKeywords: 4,
},
medium: {
columns: {minCardWidth: 380, maxColumns: 3},
avatarPx: 52,
ringPx: 34,
padding: 'px-4 py-3.5',
gap: 'gap-2.5',
nameClass: 'text-lg',
metaClass: 'text-sm',
headlineClass: 'text-base',
bioClass: 'text-sm',
keywordClass: 'text-xs px-2.5 py-0.5',
textWidthClass: '',
detailRail: false,
headlineCharsPerLine: 66,
totalTextLines: 6,
maxHeadlineLines: 3,
maxBioLines: 5,
maxKeywords: 5,
},
large: {
columns: {maxColumns: 1, minCardWidth: 0},
avatarPx: 80,
ringPx: 42,
padding: 'px-5 py-4',
gap: 'gap-3',
nameClass: 'text-xl',
metaClass: 'text-sm',
headlineClass: 'text-lg',
bioClass: 'text-base',
keywordClass: 'text-sm px-3 py-1',
textWidthClass: 'max-w-3xl',
detailRail: true,
headlineCharsPerLine: 78,
totalTextLines: 6,
maxHeadlineLines: 3,
maxBioLines: 5,
maxKeywords: 8,
},
}
// Tailwind needs the full class name in the source, so the clamp can't be interpolated.
const LINE_CLAMP_CLASS: Record<number, string> = {
1: 'line-clamp-1',
2: 'line-clamp-2',
3: 'line-clamp-3',
4: 'line-clamp-4',
5: 'line-clamp-5',
6: 'line-clamp-6',
}
export function ProfileGridSkeleton(props: {
count?: number
className?: string
cardSize?: CardSize
}) {
const {count = 20, className, cardSize} = props
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
// Same measured-width masonry as the real grid, so the layout doesn't reflow when profiles land.
const gridRef = useRef<HTMLDivElement>(null)
const columnCount = useColumnCount(gridRef, config.columns)
// Varied heights so the skeleton previews the masonry rather than a uniform grid.
const bioLines = [3, 2, 4, 2, 3, 3]
return (
<div className={clsx('grid gap-6 py-4 grid-cols-1', className)}>
{Array.from({length: count}).map((_, i) => (
<div
key={i}
className="rounded-xl border border-canvas-300 bg-canvas-50 overflow-hidden animate-pulse"
>
<div className="flex flex-row h-full justify-between">
<div className="flex-1 px-4 py-3 space-y-2 min-w-0">
{/* name */}
<div className="h-5 bg-canvas-200 rounded w-2/5" />
{/* age / location */}
<div className="h-3.5 bg-canvas-200 rounded w-1/3" />
{/* headline */}
<div className="h-3.5 bg-canvas-200 rounded w-3/5 mt-1" />
{/* keyword pills */}
<div className="flex gap-2 pt-1">
<div className="h-6 bg-canvas-200 rounded-full w-16" />
<div className="h-6 bg-canvas-200 rounded-full w-20" />
<div className="h-6 bg-canvas-200 rounded-full w-14" />
<div ref={gridRef} className={clsx('flex items-start gap-4 py-4', className)}>
{Array.from({length: columnCount}).map((_, columnIndex) => (
<Col key={columnIndex} className="min-w-0 flex-1 gap-4">
{Array.from({length: count})
.map((_, i) => i)
.filter((i) => i % columnCount === columnIndex)
.map((i) => (
<div
key={i}
className={clsx(
'rounded-xl border border-canvas-300 bg-canvas-50 animate-pulse space-y-3',
config.padding,
)}
>
<div className="flex flex-row items-center gap-3">
<div
className="shrink-0 rounded-full bg-canvas-200"
style={{height: config.avatarPx, width: config.avatarPx}}
/>
<div className="flex-1 space-y-2">
{/* name */}
<div className="h-4 bg-canvas-200 rounded w-2/5" />
{/* location */}
<div className="h-3 bg-canvas-200 rounded w-1/3" />
</div>
{/* compatibility ring */}
<div
className="shrink-0 rounded-full bg-canvas-200"
style={{height: config.ringPx, width: config.ringPx}}
/>
</div>
{/* headline */}
<div className="h-4 bg-canvas-200 rounded w-4/5" />
{/* bio lines */}
<div className="space-y-1.5">
{Array.from({length: bioLines[i % bioLines.length]}).map((_, j) => (
<div
key={j}
className="h-3 bg-canvas-200 rounded"
style={{width: `${95 - j * 12}%`}}
/>
))}
</div>
{/* keyword pills */}
<div className="flex gap-2 pt-1">
<div className="h-6 bg-canvas-200 rounded-full w-16" />
<div className="h-6 bg-canvas-200 rounded-full w-20" />
<div className="h-6 bg-canvas-200 rounded-full w-14" />
</div>
</div>
{/* bio lines */}
<div className="space-y-1.5 pt-1">
<div className="h-3 bg-canvas-200 rounded w-full" />
<div className="h-3 bg-canvas-200 rounded w-4/5" />
<div className="h-3 bg-canvas-200 rounded w-3/4" />
</div>
</div>
{/* photo placeholder */}
<div className="shrink-0 w-40 min-h-24 bg-canvas-200 rounded-r-xl" />
</div>
</div>
))}
</Col>
))}
</div>
)
@@ -120,45 +262,52 @@ export const ProfileGrid = (props: {
} = props
const {cardSize} = displayOptions ?? {}
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
const user = useUser()
const t = useT()
const other_profiles = profiles.filter((profile) => profile.user_id !== user?.id)
const gridCols = {
small: 'lg:grid-cols-2',
medium: '',
large: '',
}[cardSize ?? 'medium']
// Masonry: cards are only as tall as their content, so a sparse profile stays short instead of
// padding itself out with empty space. Round-robin (rather than CSS `columns`, which fills the
// first column top to bottom) keeps the sort order reading left to right across each row.
const gridRef = useRef<HTMLDivElement>(null)
const columnCount = useColumnCount(gridRef, config.columns)
const columns = useMemo(() => {
const cols: Profile[][] = Array.from({length: columnCount}, () => [])
other_profiles.forEach((profile, i) => cols[i % columnCount].push(profile))
return cols
}, [other_profiles, columnCount])
return (
<div className="relative" data-testid="people-profile-grid">
<div
className={clsx(
`grid gap-6 py-4 grid-cols-1`,
isReloading && 'animate-pulse opacity-80',
gridCols,
)}
ref={gridRef}
className={clsx('flex items-start gap-4 py-4', isReloading && 'animate-pulse opacity-80')}
>
{other_profiles.map((profile) => (
<ProfilePreview
key={profile.id}
profile={profile}
compatibilityScore={compatibilityScores?.[profile.user_id]}
hasStar={starredUserIds?.includes(profile.user_id) ?? false}
refreshStars={refreshStars}
onHide={onHide}
isHidden={hiddenUserIds?.includes(profile.user_id) ?? false}
onUndoHidden={onUndoHidden}
displayOptions={displayOptions}
/>
{columns.map((column, columnIndex) => (
<Col key={columnIndex} className="min-w-0 flex-1 gap-4">
{column.map((profile) => (
<ProfilePreview
key={profile.id}
profile={profile}
compatibilityScore={compatibilityScores?.[profile.user_id]}
hasStar={starredUserIds?.includes(profile.user_id) ?? false}
refreshStars={refreshStars}
onHide={onHide}
isHidden={hiddenUserIds?.includes(profile.user_id) ?? false}
onUndoHidden={onUndoHidden}
displayOptions={displayOptions}
/>
))}
</Col>
))}
</div>
<LoadMoreUntilNotVisible loadMore={loadMore} />
{isLoadingMore && <ProfileGridSkeleton count={2} />}
{isLoadingMore && <ProfileGridSkeleton count={columnCount} cardSize={cardSize} />}
{user?.isBannedFromPosting ? (
<div className="py-8 text-center">
@@ -197,6 +346,91 @@ export const ProfileGrid = (props: {
)
}
/**
* The detail fields the compact card sizes leave to the profile page. Deliberately not gated on
* the `show*` display toggles: those toggles only cover the compact card, and picking the wide
* size *is* the request to see this.
*/
function ProfileDetailRail(props: {profile: Profile; className?: string}) {
const {profile, className} = props
const choicesIdsToLabels = useChoicesContext()
const t = useT()
const seekingText = profile.pref_relation_styles?.length ? getSeekingText(profile, t, true) : null
const rows = [
seekingText && {
key: 'seeking',
testid: 'people-profile-seeking',
icon: <PiMagnifyingGlassBold className="h-4 w-4" />,
text: seekingText,
},
profile.occupation_title && {
key: 'occupation',
icon: <Briefcase className="h-4 w-4" />,
text: profile.occupation_title,
},
!!profile.interests?.length && {
key: 'interests',
icon: <Sparkles className="h-4 w-4" />,
text: profile.interests
.slice(0, 5)
.map((id) => choicesIdsToLabels['interests'][id])
.join(' • '),
},
!!profile.causes?.length && {
key: 'causes',
icon: <HandHeart className="h-4 w-4" />,
text: profile.causes
.slice(0, 5)
.map((id) => choicesIdsToLabels['causes'][id])
.join(' • '),
},
!!profile.diet?.length && {
key: 'diet',
icon: <Salad className="h-4 w-4" />,
text: profile.diet.map((e) => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e])).join(' • '),
},
profile.is_smoker && {
key: 'smoking',
icon: <Cigarette className="h-4 w-4" />,
text: t('profile.optional.smoking', 'Smokes'),
},
profile.drinks_per_month !== null &&
profile.drinks_per_month !== undefined && {
key: 'drinks',
icon: <Wine className="h-4 w-4" />,
text: `${profile.drinks_per_month} ${t('filter.drinks.per_month', 'per month')}`,
},
profile.mbti && {
key: 'mbti',
icon: <Brain className="h-4 w-4" />,
text: profile.mbti.toUpperCase(),
},
!!profile.languages?.length && {
key: 'languages',
icon: <Languages className="h-4 w-4" />,
text: profile.languages
.map((v) => t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]))
.join(' • '),
},
].filter(Boolean) as Array<{key: string; testid?: string; icon: React.ReactNode; text: string}>
// A profile with none of these would otherwise leave a bordered empty column.
if (!rows.length) return null
return (
<Col
className={clsx('gap-1.5 border-l border-canvas-300 pl-5 text-ink-500', className)}
data-testid="profile-detail-rail"
>
{rows.map((row) => (
<IconWithInfo key={row.key} testid={row.testid} icon={row.icon} text={row.text} />
))}
</Col>
)
}
function ProfilePreview(props: {
profile: Profile
compatibilityScore: CompatibilityScore | undefined
@@ -222,35 +456,70 @@ function ProfilePreview(props: {
showPhotos,
showAge,
showGender,
showLanguages,
showHeadline,
showKeywords,
showCity,
showOccupation,
showSeeking,
showInterests,
showCauses,
showDiet,
showSmoking,
showDrinks,
showMBTI,
showBio,
cardSize,
// Fields no longer shown on the card — they live on the profile page. Toggles are still in
// `DisplayOptions` (and commented out in `field-toggles.tsx`) in case we bring them back.
// showLanguages,
// showOccupation,
// showSeeking,
// showInterests,
// showCauses,
// showDiet,
// showSmoking,
// showDrinks,
// showMBTI,
} = displayOptions ?? {}
const {user} = profile
const choicesIdsToLabels = useChoicesContext()
// const choicesIdsToLabels = useChoicesContext()
const t = useT()
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
const [isLoading, setIsLoading] = useState(false)
const [showRing, setShowRing] = useState(false)
const ringTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pointerStartRef = useRef<{x: number; y: number} | null>(null)
const hideButtonClickedRef = useRef(false)
const {theme} = useTheme()
const isDarkTheme = isDark(theme)
const headline = showHeadline !== false ? profile.headline?.trim() : undefined
const bioText = useMemo(() => {
if (showBio === false) return ''
return parseJsonContentToText(profile.bio as JSONContent)
.replace(/\s+/g, ' ')
.trim()
}, [profile.bio, showBio])
// Keep the total amount of text roughly constant per card: the longer the headline, the fewer
// bio lines we show. A card with no headline gives the whole budget to the bio, and vice versa.
const estimatedHeadlineLines = headline
? Math.ceil(headline.length / config.headlineCharsPerLine)
: 0
const headlineLines = clamp(
estimatedHeadlineLines,
headline ? 1 : 0,
bioText ? config.maxHeadlineLines : config.maxHeadlineLines + 1,
)
const bioLines = bioText
? clamp(config.totalTextLines - headlineLines, MIN_BIO_LINES, config.maxBioLines)
: 0
const handlePointerDown = (e: React.PointerEvent) => {
// Only a press on the card itself arms the press feedback. Two things to keep out:
// the card's own controls (message, star, hide), and anything in a modal one of them opens —
// Headless UI renders those in a portal, so React still bubbles their events up to this card
// even though the DOM node lives outside it.
const card = e.currentTarget as HTMLElement
const target = e.target as HTMLElement | null
if (!target || !card.contains(target) || target.closest('button, [role="button"]')) {
pointerStartRef.current = null
return
}
pointerStartRef.current = {x: e.clientX, y: e.clientY}
}
@@ -262,13 +531,9 @@ function ProfilePreview(props: {
// Check if opening in new tab
const isNewTab = e.button === 1 || e.ctrlKey || e.metaKey || e.shiftKey
// Reset hide button click flag after checking
const wasHideButtonClicked = hideButtonClickedRef.current
hideButtonClickedRef.current = false
// If moved more than 10px, treat as drag/scroll - cancel loading
// Also cancel if clicking hide button or opening in new tab
if (dx > 10 || dy > 10 || wasHideButtonClicked || isNewTab) {
// Also cancel if opening in new tab
if (dx > 10 || dy > 10 || isNewTab) {
setIsLoading(false)
setShowRing(false)
if (ringTimeoutRef.current) {
@@ -287,21 +552,6 @@ function ProfilePreview(props: {
const handleClick = () => {}
// Show the bottom transparent gradient only if the text can't fit the card
const textRef = useRef<HTMLDivElement>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
const el = textRef.current
if (!el) return
const check = () => setIsOverflowing(el.scrollHeight > el.clientHeight)
check()
const ro = new ResizeObserver(check)
ro.observe(el)
return () => ro.disconnect()
}, [])
const bio = profile.bio as JSONContent
// If this profile was just hidden, render a compact placeholder with Undo action.
if (isHidden) {
return (
@@ -329,58 +579,74 @@ function ProfilePreview(props: {
)
}
if (bio && bio.content) {
const newBio = []
let i = 0
for (const c of bio.content) {
if ((c?.content?.length || 0) == 0) continue
if (c.type === 'paragraph') {
newBio.push(c)
} else if (['heading'].includes(c.type ?? '')) {
newBio.push({
type: 'paragraph',
content: c.content,
})
} else if (c.type === 'image') {
continue
} else {
newBio.push(c)
}
i += 1
if (i >= 5) break
}
bio.content = newBio
}
const seekingText = profile.pref_relation_styles?.length
? cardSize === 'large'
? getSeekingText(profile, t, true)
: getSeekingConnectionText(profile, t, true)
: null
// const seekingText = profile.pref_relation_styles?.length
// ? cardSize === 'large'
// ? getSeekingText(profile, t, true)
// : getSeekingConnectionText(profile, t, true)
// : null
// if (!profile.work?.length && !profile.occupation_title && !profile.interests?.length && (profile.bio_length || 0) < 100) {
// return null
// }
const isPhotoRendered = showPhotos !== false && profile.pinned_url
const keywords = showKeywords !== false ? (profile.keywords ?? []) : []
const visibleKeywords = keywords.slice(0, config.maxKeywords)
const hiddenKeywordCount = keywords.length - visibleKeywords.length
const textHeightClass = {
small: 'max-h-40',
medium: 'max-h-60 lg:max-h-40',
large: 'max-h-80',
}[cardSize ?? 'medium']
const textBlock = (
<>
{headline && (
<p
className={clsx(
'main-font my-0 italic text-ink-900',
config.headlineClass,
config.textWidthClass,
LINE_CLAMP_CLASS[headlineLines],
)}
data-testid="people-profile-headline"
>
“{headline}”
</p>
)}
const photoSizeClass = {
small: 'w-40 h-auto min-h-24',
medium: 'w-40 lg:w-40 h-auto lg:h-auto min-h-20',
large: 'w-60 lg:w-60 h-60 lg:h-auto min-h-48',
}[cardSize ?? 'medium']
{!!bioLines && (
<p
className={clsx(
'my-0',
// Without a headline the bio *is* the card's voice, so give it more contrast.
headline ? 'text-ink-500' : 'text-ink-600',
config.bioClass,
config.textWidthClass,
LINE_CLAMP_CLASS[bioLines],
)}
data-testid="people-profile-bio"
>
{bioText}
</p>
)}
const cardClass = {
small: 'flex-row',
medium: 'flex-row',
large: 'flex-col',
}[cardSize ?? 'medium']
{!!visibleKeywords.length && (
<Row className="flex-wrap gap-1.5 pt-0.5" data-testid="profile-keywords">
{visibleKeywords.map(capitalizePure).map((tag, i) => (
<span
key={i}
className={clsx(
'rounded-full border border-canvas-300 bg-canvas-100 text-ink-700 transition-colors group-hover:border-primary-200',
config.keywordClass,
)}
>
{tag.trim()}
</span>
))}
{hiddenKeywordCount > 0 && (
<span className={clsx('rounded-full text-ink-400', config.keywordClass)}>
+{hiddenKeywordCount}
</span>
)}
</Row>
)}
</>
)
return (
<div
@@ -404,239 +670,117 @@ function ProfilePreview(props: {
'before:bg-[#C17F3E] before:rounded-l-lg',
'before:opacity-0 before:transition-opacity before:duration-[120ms]',
'hover:before:opacity-100',
// 'hover:bg-canvas-100',
'hover:border-primary-300 hover:shadow-lg',
'relative z-10 cursor-pointer group block rounded-xl overflow-hidden bg-transparent h-full',
'text-ink-600',
// hover,
)}
>
{/* Phase 1: Dim overlay */}
{isLoading && (
<div className="absolute inset-0 bg-canvas-100/[0.32] rounded-xl z-20 pointer-events-none" />
)}
<Col className={clsx('relative w-full rounded-xl transition-all text-sm')}>
<Row
className={clsx(
'absolute top-2 right-2 items-start justify-end px-2 pb-3 z-10 gap-1',
isPhotoRendered && (cardSize === 'large' ? 'mr-0 sm:mr-60' : 'mr-40'),
)}
>
{compatibilityScore && (
<CompatibleBadge
compatibility={compatibilityScore}
className={clsx('pt-1 text-xs', cardSize !== 'large' && 'hidden sm:flex')}
/>
)}
<Row
className={clsx(
'items-start gap-1 transition-opacity',
'lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100',
)}
>
{onHide && (
<HideProfileButton
hiddenUserId={profile.user_id}
onHidden={onHide}
className="ml-1"
stopPropagation
eyeOff
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
)}
{hasStar !== undefined && (
<StarButton
targetProfile={profile}
isStarred={hasStar}
refresh={refreshStars}
size={'h-4 w-4'}
className="h-7 w-7 !rounded-lg !p-1 hover:border-primary-400 hover:bg-primary-50"
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
)}
{user && (
<div
className={clsx(cardSize !== 'large' && 'hidden sm:flex')}
data-testid="message-profile-button"
<Col
className={clsx(
'relative w-full rounded-xl transition-opacity duration-75',
config.padding,
config.gap,
isLoading && 'opacity-50',
)}
>
<Row className="items-start gap-3">
<ProfileAvatar
profile={profile}
sizePx={config.avatarPx}
hidePhoto={showPhotos === false}
/>
{/* Stretch to the avatar's height and center: without a location, the name would
otherwise sit at the top with a void beneath it. */}
<Col className="min-w-0 flex-1 justify-center gap-0.5 self-stretch">
<Row className="min-w-0 items-baseline gap-2">
<h3
className={clsx(
'main-font my-0 truncate font-medium text-gray-900 dark:text-white',
config.nameClass,
)}
data-testid="people-profile-name"
>
<SendMessageButton
toUser={user}
profile={profile}
size={'h-4 w-4'}
className={clsx('!p-1 w-7 h-7')}
accentIfMessaged
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
{user.name}
</h3>
<Row
className={clsx('shrink-0 items-center gap-1 text-ink-500', config.metaClass)}
data-testid="people-profile-age-gender"
>
{showAge !== false && !!profile.age && <span>{profile.age}</span>}
{showGender !== false && profile.gender && (
<GenderIcon gender={profile.gender as Gender} className="h-3.5 w-3.5" />
)}
</Row>
</Row>
{showCity !== false && (
<ProfileLocation
profile={profile}
hideIcon
className={clsx('text-ink-500', config.metaClass)}
/>
)}
</Col>
{/* The score owns the top-right corner; the actions slot in to its left (with a wider
gap than sits between the actions themselves). With no score the actions take the
corner themselves. */}
<Row className="shrink-0 items-center gap-2.5">
<Row
className={clsx(
'items-center gap-1 transition-opacity',
'lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100',
)}
>
{onHide && (
<HideProfileButton
hiddenUserId={profile.user_id}
onHidden={onHide}
stopPropagation
eyeOff
/>
</div>
)}
{hasStar !== undefined && (
<StarButton
targetProfile={profile}
isStarred={hasStar}
refresh={refreshStars}
size={'h-4 w-4'}
className="h-7 w-7 !rounded-lg !p-1 hover:border-primary-400 hover:bg-primary-50"
/>
)}
{user && (
<div data-testid="message-profile-button">
<SendMessageButton
toUser={user}
profile={profile}
size={'h-4 w-4'}
className={clsx('!p-1 w-7 h-7')}
accentIfMessaged
/>
</div>
)}
</Row>
{compatibilityScore && (
<CompatibilityRing compatibility={compatibilityScore} sizePx={config.ringPx} />
)}
</Row>
</Row>
<div className={clsx('flex lg:flex-row h-full lg:justify-between', cardClass)}>
<div
ref={textRef}
className={clsx(
'relative min-w-0 px-4 py-2 overflow-hidden lg:flex-1',
textHeightClass,
)}
>
<h3
className={clsx(
'main-font font-medium text-lg text-gray-900 dark:text-white truncate my-0 transition-opacity duration-75',
isLoading && 'opacity-50',
)}
data-testid="people-profile-name"
>
{user.name}
</h3>
<Row
className={clsx(
'flex-wrap gap-x-2 transition-opacity duration-75',
isLoading && 'opacity-50',
)}
data-testid="people-profile-age-gender"
>
{showCity !== false && <ProfileLocation profile={profile} />}
{showAge !== false && profile.age && (
<IconWithInfo
text={t('profile.header.age', '{age} years old', {age: profile.age})}
icon={<Calendar className="h-4 w-4 " />}
/>
)}
{showGender !== false && profile.gender && (
<IconWithInfo
text={''}
icon={<GenderIcon gender={profile.gender as Gender} className="h-4 w-4 " />}
/>
)}
</Row>
{showHeadline !== false && profile.headline && (
<p className="italic my-0">"{profile.headline}"</p>
)}
{showKeywords !== false && !!profile.keywords?.length && (
<Row className={'gap-2 flex-wrap py-2'} data-testid="profile-keywords">
{profile.keywords
?.slice(0, 10)
?.map(capitalizePure)
?.map((tag, i) => (
<span
key={i}
className={
'bg-canvas-200 text-primary-700 text-sm px-3 p-1 rounded-full border border-canvas-300'
}
>
{tag.trim()}
</span>
))}
</Row>
)}
{showSeeking !== false && seekingText && (
<IconWithInfo
testid="people-profile-seeking"
text={seekingText}
icon={<PiMagnifyingGlassBold className="h-4 w-4 " />}
/>
)}
{showOccupation !== false && profile.occupation_title && (
<IconWithInfo
text={profile.occupation_title}
icon={<Briefcase className="h-4 w-4 " />}
/>
)}
{showInterests !== false && !!profile.interests?.length && (
<IconWithInfo
text={profile.interests
?.slice(0, 5)
.map((id) => choicesIdsToLabels['interests'][id])
.join(' ')}
icon={<Sparkles className="h-4 w-4 " />}
/>
)}
{showCauses !== false && !!profile.causes?.length && (
<IconWithInfo
text={profile.causes
?.slice(0, 5)
.map((id) => choicesIdsToLabels['causes'][id])
.join(' ')}
icon={<HandHeart className="h-4 w-4 " />}
/>
)}
<Row className={'gap-2 flex-wrap'}>
{showDiet !== false && !!profile.diet?.length && (
<IconWithInfo
text={profile.diet
?.map((e) => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e]))
.join(' ')}
icon={<Salad className="h-4 w-4 " />}
/>
)}
{showSmoking !== false && profile.is_smoker && (
<IconWithInfo
text={t('profile.optional.smoking', 'Smokes')}
icon={<Cigarette className="h-4 w-4 " />}
/>
)}
{showDrinks !== false &&
profile.drinks_per_month !== null &&
profile.drinks_per_month !== undefined && (
<IconWithInfo
text={`${profile.drinks_per_month} ${t('filter.drinks.per_month', 'per month')}`}
icon={<Wine className="h-4 w-4 " />}
/>
)}
{showMBTI !== false && profile.mbti && (
<IconWithInfo
text={profile.mbti.toUpperCase()}
icon={<Brain className="h-4 w-4 " />}
/>
)}
{showLanguages !== false && !!profile.languages?.length && (
<IconWithInfo
text={profile.languages
?.map((v) => t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]))
.join(' ')}
icon={<Languages className="h-4 w-4 " />}
/>
)}
</Row>
{showBio !== false && bio && (
<div className="border-l-2 border-gray-200 dark:border-gray-600 pl-3 mt-1">
<Content className="w-full italic text-sm" content={bio} />
</div>
)}
{isOverflowing && (
<div
className={clsx(
'absolute bottom-0 inset-x-0 h-16 bg-gradient-to-t from-canvas-50 to-transparent pointer-events-none',
// 'group-hover:from-canvas-100 dark:group-hover:from-canvas-100',
)}
/>
)}
</div>
{isPhotoRendered && (
<div
className={clsx(
'relative shrink-0 rounded-r-xl lg:self-stretch overflow-hidden z-1 mx-auto mr-0',
photoSizeClass,
)}
>
<Image
src={profile.pinned_url!}
fill
alt=""
className="object-cover object-top"
loading="lazy"
priority={false}
/>
</div>
)}
</div>
{/* 2:1 split rather than a fixed-width rail: the text column is capped at its reading
measure, so a rail pinned to the card's right edge would leave all the slack as a
gutter down the middle. */}
{config.detailRail ? (
<Row className="gap-6">
<Col className={clsx('min-w-0 flex-[2]', config.gap)}>{textBlock}</Col>
{/* Below lg the card is too narrow to split — the rail would squeeze both columns. */}
<ProfileDetailRail profile={profile} className="hidden min-w-0 flex-1 lg:flex" />
</Row>
) : (
textBlock
)}
</Col>
</Link>

View File

@@ -0,0 +1,93 @@
import clsx from 'clsx'
import {Profile} from 'common/profiles/profile'
import Image from 'next/image'
/**
* Warm duotones picked to sit on both the light and dark canvas. Seeded off the user id so a
* photoless profile keeps the same mark across sessions instead of looking like a missing image.
*/
const AVATAR_GRADIENTS: Array<[string, string]> = [
['#C79A5B', '#7A4A1F'],
['#A88C6A', '#4A3524'],
['#8FA07C', '#3A4A33'],
['#7C93A0', '#2E4049'],
['#A07C8F', '#492E3D'],
['#BFA05A', '#5C4318'],
['#8C8FA8', '#33364F'],
]
function hashString(s: string) {
let hash = 0
for (let i = 0; i < s.length; i++) {
hash = (hash * 31 + s.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
/** First letter of the first and last word — `Array.from` so emoji/accents don't get sliced. */
function getInitials(name: string | undefined) {
const words = (name ?? '').trim().split(/\s+/).filter(Boolean)
if (!words.length) return '?'
const first = Array.from(words[0])[0] ?? ''
const last = words.length > 1 ? (Array.from(words[words.length - 1])[0] ?? '') : ''
return (first + last).toUpperCase()
}
/**
* The profile photo when there is one, otherwise a generated monogram. Both render in the same
* round frame so cards keep an identical rhythm whether or not someone uploaded a picture.
*/
export function ProfileAvatar(props: {
profile: Profile
sizePx: number
/** Ignore the uploaded photo and always draw the monogram (used by the "photos off" toggle). */
hidePhoto?: boolean
className?: string
}) {
const {profile, sizePx, hidePhoto, className} = props
const {user} = profile
// Deliberately not falling back to `user.avatarUrl`: signup generates one, so it would mask the
// fact that a profile has no real photo.
const photoUrl = hidePhoto ? null : profile.pinned_url
const [from, to] = AVATAR_GRADIENTS[hashString(profile.user_id) % AVATAR_GRADIENTS.length]
return (
<div
className={clsx(
'relative shrink-0 overflow-hidden rounded-full',
'ring-1 ring-inset ring-ink-900/10 dark:ring-white/10',
className,
)}
style={{width: sizePx, height: sizePx}}
>
{photoUrl ? (
<Image
src={photoUrl}
fill
sizes={`${sizePx}px`}
alt=""
className="object-cover object-center"
loading="lazy"
priority={false}
/>
) : (
<>
<div
className="absolute inset-0"
style={{backgroundImage: `linear-gradient(140deg, ${from}, ${to})`}}
/>
{/* Top-left sheen: keeps the flat fill from reading as an unloaded placeholder. */}
<div className="absolute inset-0 bg-gradient-to-br from-white/25 via-transparent to-black/20" />
<span
className="main-font absolute inset-0 grid select-none place-items-center font-medium text-white/95"
style={{fontSize: sizePx * 0.36, letterSpacing: '0.03em'}}
aria-hidden
>
{getInitials(user?.name)}
</span>
</>
)}
</div>
)
}

View File

@@ -1,3 +1,4 @@
import clsx from 'clsx'
import {getGoogleMapsUrl, getLocationText} from 'common/geodb'
import {Profile} from 'common/profiles/profile'
import React from 'react'
@@ -5,8 +6,14 @@ import {IoLocationOutline} from 'react-icons/io5'
import {IconWithInfo} from 'web/components/icons'
import {CustomLink} from 'web/components/links'
export function ProfileLocation(props: {profile: Profile; prefix?: string}) {
const {profile, prefix} = props
export function ProfileLocation(props: {
profile: Profile
prefix?: string
/** Drop the pin icon and let the caller size the text — used on profile cards. */
hideIcon?: boolean
className?: string
}) {
const {profile, prefix, hideIcon, className} = props
const text = getLocationText(profile, prefix)
@@ -14,13 +21,21 @@ export function ProfileLocation(props: {profile: Profile; prefix?: string}) {
return null
}
const link = (
<CustomLink href={getGoogleMapsUrl(text)} className={'hover:text-primary-500'}>
{text}
</CustomLink>
)
if (hideIcon) {
return <span className={clsx('truncate', className)}>{link}</span>
}
return (
<IconWithInfo
icon={<IoLocationOutline className="text-ink-300" style={{width: '14px', height: '14px'}} />}
>
<CustomLink href={getGoogleMapsUrl(text)} className={'hover:text-primary-500'}>
{text}
</CustomLink>
{link}
</IconWithInfo>
)
}

View File

@@ -5,8 +5,9 @@ import {Profile} from 'common/profiles/profile'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {DAY_MS} from 'common/util/time'
import {isEqual} from 'lodash'
import {Compass as CompassIcon, TrendingUp} from 'lucide-react'
import {useRouter} from 'next/router'
import {useCallback, useEffect, useRef, useState} from 'react'
import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {FiltersElement} from 'web/components/filters/filters'
@@ -21,7 +22,6 @@ import {useDisplayOptions} from 'web/hooks/use-display-options'
import {useGetter} from 'web/hooks/use-getter'
import {useHiddenProfiles} from 'web/hooks/use-hidden-profiles'
import {useIsClearedFilters} from 'web/hooks/use-is-cleared-filters'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {useProfile} from 'web/hooks/use-profile'
@@ -31,6 +31,33 @@ import {api} from 'web/lib/api'
import {useLocale, useT} from 'web/lib/locale'
import {getStars} from 'web/lib/supabase/stars'
function ProfileBanner(props: {
icon: ReactNode
onDismiss: () => void
dismissLabel: string
children: ReactNode
}) {
const {icon, onDismiss, dismissLabel, children} = props
return (
<div className="relative w-full overflow-hidden rounded-2xl border border-primary-200/60 bg-gradient-to-br from-primary-50 via-canvas-50 to-canvas-50 px-4 py-4 shadow-sm dark:border-primary-800/30 dark:from-primary-950/30 dark:via-canvas-50 dark:to-canvas-50 sm:px-5">
<div className="pointer-events-none absolute -right-10 -top-12 h-36 w-36 rounded-full bg-primary-200/40 blur-3xl dark:bg-primary-700/20" />
<button
onClick={onDismiss}
aria-label={dismissLabel}
className="absolute right-2 top-2 rounded-full p-1.5 text-ink-400 transition-colors hover:bg-canvas-100 hover:text-ink-700"
>
<XMarkIcon className="h-4 w-4" />
</button>
<Row className="items-start gap-3 pr-7">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-600 dark:bg-primary-900/50 dark:text-primary-300">
{icon}
</div>
<Col className="min-w-0 items-start gap-2 text-left">{children}</Col>
</Row>
</div>
)
}
export function ProfilesHome() {
const user = useUser()
const you = useProfile()
@@ -48,7 +75,7 @@ export function ProfilesHome() {
raisedInLocationFilterProps,
} = useFilters(you ?? undefined, fromSignup)
const {displayOptions, updateDisplayOptions} = useDisplayOptions(you)
const {displayOptions, updateDisplayOptions} = useDisplayOptions()
const [profiles, setProfiles] = usePersistentInMemoryState<Profile[] | undefined>(
undefined,
@@ -78,8 +105,7 @@ export function ProfilesHome() {
const t = useT()
const {locale} = useLocale()
const isClearedFilters = useIsClearedFilters(filters)
const isMobile = useIsMobile()
// Tracked separately from `isMobile` (640px) since the docked filters column only replaces
// Tracked separately from mobile (640px) since the docked filters column only replaces
// the slide-over once there's room beside the grid, at the `lg` (1024px) breakpoint.
const [isDesktop, setIsDesktop] = useState(false)
useEffect(() => {
@@ -242,94 +268,77 @@ export function ProfilesHome() {
<div className={clsx(showDockedFilters && 'lg:grid lg:grid-cols-12 lg:gap-4')}>
<Col className={clsx(showDockedFilters && 'lg:col-span-9')}>
{showSignupBanner && user && (
<div className="w-full bg-canvas-50 rounded-xl text-center py-3 px-3 relative">
<Col className="items-center justify-center gap-2">
<span className={'mb-2'}>
{t(
'profiles.search_intention',
'Compass works best when you search with intention. Try using keywords or filters instead of scrolling.',
)}
</span>
<Row className="gap-2 mb-2">
<Button
size="sm"
color="gray-white"
className={'border'}
onClick={() => {
searchInputRef.current?.focus()
}}
>
{t('profiles.try_keyword_search', 'Try a keyword search')}
</Button>
{isMobile && (
<Button
size="sm"
color={'gray-white'}
className={'border'}
onClick={() => {
if (!isMobile) return
setHighlightFilters(true)
setTimeout(() => {
setHighlightFilters(false)
setOpenFiltersModal(true)
}, 500)
}}
>
{t('profiles.show_filters', 'Show me the filters')}
</Button>
)}
<Button
size="sm"
color={'gray-white'}
className={'border'}
onClick={() => {
setHighlightSort(true)
setTimeout(() => {
setHighlightSort(false)
}, 500)
}}
>
{t('profiles.sort_differently', 'Sort differently')}
</Button>
</Row>
<Row className="gap-2 mb-6 sm:mb-2">
<p>
{t(
'profiles.interactive_profiles',
'Profiles are interactive — click any card to learn more and reach out.',
)}
</p>
</Row>
</Col>
<Button
size="2xs"
color="gray-white"
onClick={() => setShowSignupBanner(false)}
className="absolute bottom-1 right-1"
>
{t('profiles.dismiss', 'Dismiss')}
</Button>
</div>
<ProfileBanner
icon={<CompassIcon className="h-5 w-5" />}
onDismiss={() => setShowSignupBanner(false)}
dismissLabel={t('profiles.dismiss', 'Dismiss')}
>
<p className="text-sm font-medium text-ink-900 sm:text-[15px]">
{t(
'profiles.search_intention',
'Compass works best when you search with intention. Try using keywords or filters instead of scrolling.',
)}
</p>
<Row className="flex-wrap gap-2">
<Button
size="sm"
color="gray-white"
className="border border-canvas-300 bg-canvas-0 !text-ink-700 !rounded-full transition-colors hover:!border-primary-300 hover:!bg-primary-50 hover:!text-primary-700 dark:bg-canvas-100"
onClick={() => {
searchInputRef.current?.focus()
}}
>
{t('profiles.try_keyword_search', 'Try a keyword search')}
</Button>
<Button
size="sm"
color={'gray-white'}
className="border border-canvas-300 bg-canvas-0 !text-ink-700 !rounded-full transition-colors hover:!border-primary-300 hover:!bg-primary-50 hover:!text-primary-700 dark:bg-canvas-100"
onClick={() => {
setHighlightFilters(true)
setTimeout(() => {
setHighlightFilters(false)
setOpenFiltersModal(true)
}, 500)
}}
>
{t('profiles.show_filters', 'Show me the filters')}
</Button>
<Button
size="sm"
color={'gray-white'}
className="border border-canvas-300 bg-canvas-0 !text-ink-700 !rounded-full transition-colors hover:!border-primary-300 hover:!bg-primary-50 hover:!text-primary-700 dark:bg-canvas-100"
onClick={() => {
setHighlightSort(true)
setTimeout(() => {
setHighlightSort(false)
}, 500)
}}
>
{t('profiles.sort_differently', 'Sort differently')}
</Button>
</Row>
<p className="text-xs text-ink-500">
{t(
'profiles.interactive_profiles',
'Profiles are interactive — click any card to learn more and reach out.',
)}
</p>
</ProfileBanner>
)}
{showEarlyBanner && !showSignupBanner && (
<div className="w-full bg-canvas-50 rounded-lg text-center py-3 px-3 relative">
<Col className="items-center justify-center gap-2">
<span className={'mb-2'}>
{t(
'profiles.early_growth',
`Compass is in its early growth phase — 700+ members and ~100 new people joining every month. Build a strong profile now and be visible as the community expands.`,
)}
</span>
</Col>
<Button
size="2xs"
color="gray-white"
onClick={() => setShowEarlyBanner(false)}
className="absolute bottom-1 right-1"
>
{t('profiles.dismiss', 'Dismiss')}
</Button>
</div>
<ProfileBanner
icon={<TrendingUp className="h-5 w-5" />}
onDismiss={() => setShowEarlyBanner(false)}
dismissLabel={t('profiles.dismiss', 'Dismiss')}
>
<p className="text-sm font-medium text-ink-900 sm:text-[15px]">
{t(
'profiles.early_growth',
`Compass is in its early growth phase — 700+ members and ~100 new people joining every month. Build a strong profile now and be visible as the community expands.`,
)}
</p>
</ProfileBanner>
)}
{/*{user && !profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}*/}
<Title className="!mb-2 text-3xl">{t('profiles.title', 'People')}</Title>
@@ -353,7 +362,7 @@ export function ProfilesHome() {
filtersElement={filtersElement}
/>
{displayProfiles === undefined || compatibleProfiles === undefined ? (
<ProfileGridSkeleton count={6} />
<ProfileGridSkeleton cardSize={displayOptions.cardSize} />
) : (
<>
{fromSignup && isClearedFilters && (

View File

@@ -2,6 +2,7 @@ import {CheckBadgeIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {formatPercent} from 'common/util/format'
import {clamp} from 'lodash'
import {Row} from 'web/components/layout/row'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useT} from 'web/lib/locale'
@@ -18,3 +19,67 @@ export const CompatibleBadge = (props: {compatibility: CompatibilityScore; class
</Tooltip>
)
}
/**
* Same score as {@link CompatibleBadge}, drawn as a progress ring. Used on profile cards where the
* number has to be readable at a glance without competing with the name for attention.
*/
export const CompatibilityRing = (props: {
compatibility: CompatibilityScore
sizePx?: number
className?: string
}) => {
const {compatibility, sizePx = 34, className} = props
const t = useT()
const score = clamp(compatibility.score ?? 0, 0, 1)
const strokeWidth = sizePx <= 30 ? 2.5 : 3
const radius = (sizePx - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const toneClass =
score >= 0.75 ? 'text-primary-500' : score >= 0.5 ? 'text-primary-400' : 'text-ink-400'
return (
<Tooltip text={t('compatibility.tooltip', 'Compatibility score between you two')}>
<div
className={clsx('relative shrink-0', className)}
style={{width: sizePx, height: sizePx}}
data-testid="compatibility-ring"
>
<svg width={sizePx} height={sizePx} className="-rotate-90" aria-hidden>
<circle
cx={sizePx / 2}
cy={sizePx / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-canvas-300"
/>
<circle
cx={sizePx / 2}
cy={sizePx / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - score)}
className={clsx(toneClass, 'transition-[stroke-dashoffset] duration-500')}
/>
</svg>
<span
className={clsx(
// No `dark:` override — the ink scale already flips with the theme.
'absolute inset-0 grid place-items-center font-semibold tabular-nums text-ink-800',
sizePx <= 30 ? 'text-[10px]' : sizePx <= 36 ? 'text-[11px]' : 'text-xs',
)}
>
{Math.round(score * 100)}
</span>
</div>
</Tooltip>
)
}

View File

@@ -14,10 +14,9 @@ import clsx from 'clsx'
import {richTextToString} from 'common/util/parse'
import Iframe from 'common/util/tiptap-iframe'
import {debounce} from 'lodash'
import Image from 'next/image'
import {createElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {CustomLink} from 'web/components/links'
import {MediaModal} from 'web/components/media-modal'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {safeLocalStorage} from 'web/lib/util/local'
@@ -28,6 +27,7 @@ import {nodeViewMiddleware} from '../editor/nodeview-middleware'
import {StickyFormatMenu} from '../editor/sticky-format-menu'
import {Upload, useUploadMutation} from '../editor/upload-extension'
import {DisplayMention} from '../editor/user-mention/mention-extension'
import {BasicVideo, DisplayVideo} from '../editor/video'
import {Linkify} from './linkify'
import {linkClass} from './site-link'
@@ -52,6 +52,7 @@ const editorExtensions = (simple = false): Extensions =>
horizontalRule: simple ? false : {},
}),
simple ? DisplayImage : BasicImage,
simple ? DisplayVideo : BasicVideo,
EmojiExtension,
DisplayLink,
DisplayMention,
@@ -183,10 +184,10 @@ export function useTextEditor(props: {
editor.setOptions({
editorProps: {
handlePaste(_view, event) {
const imageFiles = getImages(event.clipboardData)
if (imageFiles.length) {
const mediaFiles = getMediaFiles(event.clipboardData)
if (mediaFiles.length) {
event.preventDefault()
upload.mutate(imageFiles)
upload.mutate(mediaFiles)
return true // Prevent image in text/html from getting pasted again
}
@@ -208,7 +209,7 @@ export function useTextEditor(props: {
// if dragged from outside
if (!moved) {
event.preventDefault()
upload.mutate(getImages(event.dataTransfer))
upload.mutate(getMediaFiles(event.dataTransfer))
}
},
},
@@ -217,8 +218,10 @@ export function useTextEditor(props: {
return editor
}
const getImages = (data: DataTransfer | null) =>
Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image'))
const getMediaFiles = (data: DataTransfer | null) =>
Array.from(data?.files ?? []).filter(
(file) => file.type.startsWith('image') || file.type.startsWith('video'),
)
/** Breathing room left below the format toolbar when we scroll it back into view. */
const TOOLBAR_VIEWPORT_GAP = 16
@@ -419,17 +422,7 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
>
{jsxContent}
</div>
<Modal open={lightboxOpen} setOpen={setLightboxOpen}>
<div className={MODAL_CLASS}>
<Image
src={lightboxUrl}
width={1000}
height={1000}
alt=""
className="max-h-[90vh] w-auto"
/>
</div>
</Modal>
<MediaModal url={lightboxUrl} open={lightboxOpen} setOpen={setLightboxOpen} />
</>
)
}
@@ -437,18 +430,18 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
function renderJSONContent(
doc: JSONContent,
size: 'sm' | 'md' | 'lg',
onImageClick?: (url: string) => void,
onMediaClick?: (url: string) => void,
): ReactNode {
return recurse(doc, 0, size, onImageClick)
return recurse(doc, 0, size, onMediaClick)
}
function recurse(
node: JSONContent,
key: number,
size: 'sm' | 'md' | 'lg',
onImageClick?: (url: string) => void,
onMediaClick?: (url: string) => void,
): ReactNode {
const children = node.content?.map((n, i) => recurse(n, i, size, onImageClick))
const children = node.content?.map((n, i) => recurse(n, i, size, onMediaClick))
switch (node.type) {
case 'doc':
@@ -484,7 +477,7 @@ function recurse(
<button
key={key}
type="button"
onClick={() => onImageClick?.(node.attrs?.src ?? '')}
onClick={() => onMediaClick?.(node.attrs?.src ?? '')}
className="cursor-pointer"
>
<img
@@ -495,6 +488,23 @@ function recurse(
/>
</button>
)
case 'video':
return (
<button
key={key}
type="button"
onClick={() => onMediaClick?.(node.attrs?.src ?? '')}
className="cursor-pointer"
>
<video
src={node.attrs?.src}
muted
playsInline
preload="metadata"
className={size === 'sm' ? 'max-h-32' : size === 'md' ? 'max-h-64' : undefined}
/>
</button>
)
case 'table':
return (
<table key={key}>

View File

@@ -0,0 +1,58 @@
import {CheckIcon, ShareIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {useState} from 'react'
import {copyToClipboard} from 'web/lib/util/copy'
import {nativeShare} from 'web/lib/util/share'
/**
* Primary-CTA share button, shared between the /about closing block and anywhere else a share action
* should read as the loudest thing on screen rather than a secondary icon button.
*
* Opens the OS share sheet first (phones can pick WhatsApp, Messages, ...), falling back to copying the
* link — with a "Copied!" confirmation — when no sheet appears: desktop, a browser without the Web Share
* API, or the sheet being dismissed. `nativeShare` is probed at click time, so there's no SSR/first-paint
* branch to get wrong, and it's what makes the Android app work: its WebView has no `navigator.share`, so
* a bare Web Share API check would silently degrade the app to copy-the-link.
*/
export function ShareCTAButton(props: {
url: string
shareTitle: string
shareText: string
label: string
copiedLabel: string
className?: string
}) {
const {url, shareTitle, shareText, label, copiedLabel, className} = props
const [copied, setCopied] = useState(false)
const onClick = async () => {
// The user dismissing the share sheet also reports false; re-copying the link is a harmless outcome.
const shared = await nativeShare({title: shareTitle, text: shareText, url})
if (shared) return
copyToClipboard(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
type="button"
onClick={onClick}
className={clsx(
'inline-flex items-center gap-2 rounded-xl border px-5 py-2.5 text-sm font-semibold',
'transition-all duration-200 ease-out',
'bg-cta text-white border-cta hover:bg-cta-hover',
'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]',
className,
)}
>
{copied ? (
<CheckIcon className="h-[1.05rem] w-[1.05rem]" strokeWidth={2.5} aria-hidden="true" />
) : (
<ShareIcon className="h-[1.05rem] w-[1.05rem]" strokeWidth={2} aria-hidden="true" />
)}
{copied ? copiedLabel : label}
</button>
)
}

View File

@@ -0,0 +1,46 @@
import {clamp} from 'lodash'
import {RefObject, useEffect, useLayoutEffect, useState} from 'react'
/** Layout effect on the client so the first paint already has the right column count. */
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
export type ColumnCountOptions = {
/** Narrowest a card may get before dropping a column. Drives the reading measure. */
minCardWidth: number
maxColumns: number
/** Gap between columns, so the fit accounts for it. */
gap?: number
}
/**
* Column count for JS-driven masonry layouts, measured from the container rather than the
* viewport — the people page has a sidebar and an optional docked filter panel, so the same
* viewport width can leave very different amounts of room for cards.
*/
export function useColumnCount(
containerRef: RefObject<HTMLElement | null>,
options: ColumnCountOptions,
) {
const {minCardWidth, maxColumns, gap = 16} = options
const [columnCount, setColumnCount] = useState(1)
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const measure = () => {
const width = el.clientWidth
if (!width) return
// n columns fit when (width - gap * (n - 1)) / n >= minCardWidth.
const fits = Math.floor((width + gap) / (minCardWidth + gap))
setColumnCount(clamp(fits, 1, maxColumns))
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [containerRef, minCardWidth, maxColumns, gap])
return columnCount
}

View File

@@ -1,8 +1,7 @@
import {DisplayOptions, initialDisplayOptions} from 'common/profiles-rendering'
import {useEffect} from 'react'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
export function useDisplayOptions(profile?: any) {
export function useDisplayOptions() {
const [displayOptions, setDisplayOptions] = usePersistentLocalState<DisplayOptions>(
initialDisplayOptions,
'rendering-options',
@@ -13,15 +12,15 @@ export function useDisplayOptions(profile?: any) {
setDisplayOptions((prevState) => ({...prevState, ...updatedState}))
}
useEffect(() => {
if (profile && displayOptions.showPhotos === undefined) {
const isLookingForRelationship = !!profile?.pref_relation_styles?.includes('relationship')
updateDisplayOptions({
showAge: isLookingForRelationship,
showGender: isLookingForRelationship,
})
}
}, [profile])
// useEffect(() => {
// if (profile && displayOptions.showPhotos === undefined) {
// const isLookingForRelationship = !!profile?.pref_relation_styles?.includes('relationship')
// updateDisplayOptions({
// showAge: isLookingForRelationship,
// showGender: isLookingForRelationship,
// })
// }
// }, [profile])
return {displayOptions, updateDisplayOptions}
}

View File

@@ -60,8 +60,8 @@ export const uploadImage = async (
return Promise.reject('File is over 20 MB')
}
// 2⃣ Compress if > 1MB
if (file.size > 1024 ** 2) {
// 2⃣ Compress if > 1MB (images only; Compressor operates on raster images)
if (file.size > 1024 ** 2 && file.type.startsWith('image')) {
file = await new Promise((resolve, reject) => {
new Compressor(file, {
quality: 0.6,

View File

@@ -2,7 +2,6 @@ import {
BellIcon,
BookmarkIcon,
ChatBubbleLeftRightIcon,
CheckIcon,
CodeBracketIcon,
EnvelopeIcon,
FlagIcon,
@@ -11,7 +10,6 @@ import {
LightBulbIcon,
MagnifyingGlassIcon,
MegaphoneIcon,
ShareIcon,
SparklesIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline'
@@ -20,7 +18,7 @@ import clsx from 'clsx'
import {discordLink, formLink, githubRepo, OG_DESCRIPTION} from 'common/constants'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import Link from 'next/link'
import {ComponentType, ReactNode, SVGProps, useState} from 'react'
import {ComponentType, ReactNode, SVGProps} from 'react'
import {StatBand} from 'web/components/about/platform-stats'
import {RepoActivity} from 'web/components/about/repo-activity'
import {AlertDemo} from 'web/components/about/search-alert-demo'
@@ -30,11 +28,10 @@ import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
import {MemberGrowth} from 'web/components/widgets/charts'
import {Reveal} from 'web/components/widgets/reveal'
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
import {eyebrow, Section, surface, surfaceHover} from 'web/components/widgets/surface'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {copyToClipboard} from 'web/lib/util/copy'
import {nativeShare} from 'web/lib/util/share'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -362,13 +359,8 @@ function ShareBenefit({icon: Icon, title, text}: {icon: IconType; title: string;
* The share control on the closing block.
*
* Universal, not mobile-only: the block's whole argument is that sharing is easy and in the reader's
* interest, so a desktop with no button would undercut it. Phones get the native share sheet (they can
* pick WhatsApp, Messages, ...); everywhere else — and any browser without the Web Share API — it copies
* the link and confirms. The sheet is probed at click time, so there is no SSR/first-paint branch to get
* wrong and the button always renders.
*
* `nativeShare` is what makes the Android app work: its WebView has no `navigator.share`, so a bare Web
* Share API check would silently degrade the app to copy-the-link.
* interest, so a desktop with no button would undercut it. Uses the shared `ShareCTAButton` (mobile share
* sheet, desktop copy-and-confirm fallback).
*
* When the sharer is signed in the link carries their `?referrer=` tag, the same attribution the
* /referrals page and users.ts already speak — so the shares this block is arguing for actually get
@@ -378,53 +370,26 @@ function ShareBenefit({icon: Icon, title, text}: {icon: IconType; title: string;
function ShareCTA() {
const t = useT()
const user = useUser()
const [copied, setCopied] = useState(false)
const shareUrl = user?.username
? `${DEPLOYED_WEB_URL}/?referrer=${user.username}`
: DEPLOYED_WEB_URL
const onClick = async () => {
// The user dismissing the share sheet also reports false; re-copying the link is a harmless outcome.
const shared = await nativeShare({
title: t('about.share.title', 'Compass — Find your people'),
return (
<ShareCTAButton
url={shareUrl}
shareTitle={t('about.share.title', 'Compass — Find your people')}
// Two paragraphs, and long for a share sheet, deliberately: this is the referral message a person
// sends their friends, so it carries the same three beats as the ShareStrip below — what Compass
// is, how it works, and why bringing someone is in the sharer's own interest, not a favour. The
// closing line is mutual on purpose: the receiver reads it, but the sender has to feel it too.
text: t(
shareText={t(
'about.share.text',
"Hi! Reaching out about something I care about: Compass, a free directory for finding your people — fully searchable by values, interests, and demographics. No ads, no swiping, no dubious algorithm.\n\nIt gets better with every person who joins. Even if a friend isn't who you're looking for, they bring their world with them — their circles, the thoughtful people you'd never have met otherwise. So whether you join or simply pass it along, you're widening the circle for both of us.",
),
url: shareUrl,
})
if (shared) return
copyToClipboard(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
type="button"
onClick={onClick}
className={clsx(
'inline-flex items-center gap-2 rounded-xl border px-5 py-2.5 text-sm font-semibold',
'transition-all duration-200 ease-out',
'bg-cta text-white border-cta hover:bg-cta-hover',
'shadow-[0_6px_20px_-6px_rgba(193,127,62,0.6)]',
)}
>
{copied ? (
<CheckIcon className="h-[1.05rem] w-[1.05rem]" strokeWidth={2.5} aria-hidden="true" />
) : (
<ShareIcon className="h-[1.05rem] w-[1.05rem]" strokeWidth={2} aria-hidden="true" />
)}
{copied
? t('about.share.copied', 'Link copied!')
: t('about.share.button_cta', 'Share Compass')}
</button>
label={t('about.share.button_cta', 'Share Compass')}
copiedLabel={t('about.share.copied', 'Link copied!')}
/>
)
}

View File

@@ -156,7 +156,7 @@ function Step1NoHiddenAlgorithms({onNext, onSkip}: OnboardingStepProps) {
<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.',
'There is no engagement algorithm, no swipe-ranking, no boosted profiles, and no attention optimization. You can browse the full directory, apply your own filters, and see exactly why someone matches with you.',
)}
</p>
<p className="font-semibold text-ink-900">

View File

@@ -1,12 +1,13 @@
import {CheckIcon} from '@heroicons/react/24/outline'
import {ArrowRightIcon, CheckIcon} from '@heroicons/react/24/outline'
import clsx from 'clsx'
import {ENV_CONFIG} from 'common/envs/constants'
import Router from 'next/router'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {PageBase} from 'web/components/page-base'
import {ProfileCardViewer} from 'web/components/profile-card-viewer'
import {SEO} from 'web/components/SEO'
import {ShareProfileButtons} from 'web/components/widgets/share-profile-button'
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
import {surface} from 'web/components/widgets/surface'
import {useProfile} from 'web/hooks/use-profile'
import {useUser} from 'web/hooks/use-user'
@@ -18,8 +19,11 @@ function Bullets({items}: {items: string[]}) {
<ul className="space-y-3">
{items.map((item) => (
<li key={item} className="flex items-start gap-3">
<span className="mt-0.5 w-5 h-5 rounded-full bg-primary-100 ring-1 ring-primary-200 flex items-center justify-center flex-shrink-0">
<CheckIcon className="w-3 h-3 text-primary-700" strokeWidth={2.5} />
{/* bg-green-500/N rather than bg-green-200: the latter's light-theme value is a raw hex
string in globals.css, which breaks Tailwind's rgb(var(...) / alpha) composition and
renders invisibly. */}
<span className="mt-0.5 w-5 h-5 rounded-full bg-green-500/15 ring-1 ring-green-500/40 flex items-center justify-center flex-shrink-0">
<CheckIcon className="w-3 h-3 text-green-500" strokeWidth={2.5} />
</span>
<span className="text-ink-700">{item}</span>
</li>
@@ -65,7 +69,18 @@ export default function SoftGatePage() {
'You created a public profile card and values-based profile. Share them to attract people who think like you. Shared profiles are discovered much more often.',
)}
</p>
<ShareProfileButtons username={user?.username} />
{user?.username && (
<ShareCTAButton
url={`https://${ENV_CONFIG.domain}/${user.username}`}
shareTitle={t('share_profile.share.title', 'A profile worth seeing on Compass')}
shareText={t(
'share_profile.share.text',
'Thought you might want to see this profile on Compass — a free directory for finding your people, searchable by values, interests, and demographics. No ads, no swiping, no dubious algorithm.',
)}
label={t('onboarding.soft-gate.share_button', 'Share My Profile')}
copiedLabel={t('about.share.copied', 'Link copied!')}
/>
)}
</Col>
)}
@@ -96,8 +111,19 @@ export default function SoftGatePage() {
</Col>
<Col className="gap-4 mt-9">
<Button onClick={handleExplore} size="lg" className="w-full">
{/* primary-500 (not primary-50/900) for the tint: this ramp inverts between themes
(900 is a deep bronze in light mode but a near-cream tint in dark mode), so only the
500 step is the same RGB in both and gives a consistent amber tint via opacity.
text-primary-700 needs no dark: override — it already inverts to a readable
light-on-dark tone on its own, same as the rest of the ramp. */}
<Button
onClick={handleExplore}
size="lg"
color="none"
className="w-full !rounded-xl gap-2 border border-primary-500/30 bg-primary-500/10 font-semibold text-primary-700 transition-colors hover:border-primary-500/50 hover:bg-primary-500/15"
>
{t('onboarding.soft-gate.explore_button', 'Explore Profiles Now')}
<ArrowRightIcon className="h-4 w-4" />
</Button>
{/* Was `text-ink-500` at 3.5:1. This is the escape hatch from a screen whose primary

View File

@@ -27,7 +27,7 @@ grounded in **shared values, trust, and understanding**, Compass is for you.
- **Keyword Search**: Find people who share your niche interests (e.g., “Minimalism”, “Thinking, Fast and Slow”, “Indie
film”).
- **Transparent Database**: See all profiles, apply filters, and search freely — no hidden algorithms.
- **Transparent Directory**: See all profiles, apply filters, and search freely — no hidden algorithms.
- **Notification System**: Get alerts when new people match your searches — no endless scrolling required.
- **Personality-Centered**: Values and ideas first. Photos stay secondary.
- **Democratic & Open Source**: Built by the community, for the community — no ads, no hidden monetization.