Add profile fields: work areas, moral causes, and interests

This commit is contained in:
MartinBraquet
2025-12-03 16:56:02 +01:00
parent 43238ecc44
commit 3b0465c65c
31 changed files with 1134 additions and 109 deletions

View File

@@ -73,6 +73,8 @@ import {IS_LOCAL} from "common/hosting/constants";
import {editMessage} from "api/edit-message";
import {reactToMessage} from "api/react-to-message";
import {deleteMessage} from "api/delete-message";
import {updateOptions} from "api/update-options";
import {getOptions} from "api/get-options";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
@@ -366,6 +368,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'delete-message': deleteMessage,
'edit-message': editMessage,
'react-to-message': reactToMessage,
'update-options': updateOptions,
'get-options': getOptions,
// 'auth-google': authGoogle,
}

View File

@@ -0,0 +1,28 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
import {tryCatch} from 'common/util/try-catch'
import {OPTION_TABLES} from "common/profiles/constants";
export const getOptions: APIHandler<'get-options'> = async (
{table},
_auth
) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
const pg = createSupabaseDirectClient()
const result = await tryCatch(
pg.manyOrNone<{ name: string }>(`SELECT interests.name
FROM interests`)
)
if (result.error) {
log('Error getting profile options', result.error)
throw new APIError(500, 'Error getting profile options')
}
const names = result.data.map(row => row.name)
return {names}
}

View File

@@ -4,6 +4,7 @@ import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {MIN_BIO_LENGTH} from "common/constants";
import {compact} from "lodash";
import {OptionTableKey} from "common/profiles/constants";
export type profileQueryType = {
limit?: number | undefined,
@@ -37,6 +38,8 @@ export type profileQueryType = {
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
} & {
[K in OptionTableKey]?: string[] | undefined
}
// const userActivityColumns = ['last_online_time']
@@ -44,7 +47,7 @@ export type profileQueryType = {
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.debug(props)
console.debug('loadProfiles', props)
const {
limit: limitParam,
after,
@@ -66,6 +69,9 @@ export const loadProfiles = async (props: profileQueryType) => {
religion,
wants_kids_strength,
has_kids,
interests,
causes,
work,
is_smoker,
shortBio,
geodbCityIds,
@@ -95,24 +101,55 @@ export const loadProfiles = async (props: profileQueryType) => {
: 'profiles'
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
// Pre-aggregated interests per profile
function getManyToManyJoin(label: OptionTableKey) {
return `(
SELECT
profile_${label}.profile_id,
ARRAY_AGG(${label}.name ORDER BY ${label}.name) AS ${label}
FROM profile_${label}
JOIN ${label} ON ${label}.id = profile_${label}.option_id
GROUP BY profile_${label}.profile_id
) i ON i.profile_id = profiles.id`
}
const interestsJoin = getManyToManyJoin('interests')
const causesJoin = getManyToManyJoin('causes')
const workJoin = getManyToManyJoin('work')
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
const joins = [
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
interests && leftJoin(interestsJoin),
causes && leftJoin(causesJoin),
work && leftJoin(workJoin),
]
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
const afterFilter = renderSql(
select(_orderBy),
from('profiles'),
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
...joins,
where('profiles.id = $(after)', {after}),
)
const tableSelection = compact([
from('profiles'),
join('users on users.id = profiles.user_id'),
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
...joins,
])
function getManyToManyClause(label: OptionTableKey) {
return `EXISTS (
SELECT 1 FROM profile_${label} pi2
JOIN ${label} ii2 ON ii2.id = pi2.option_id
WHERE pi2.profile_id = profiles.id
AND ii2.name = ANY (ARRAY[$(values)])
)`
}
const filters = [
where('looking_for_matches = true'),
where(`profiles.disabled != true`),
@@ -190,6 +227,12 @@ export const loadProfiles = async (props: profileQueryType) => {
{religion}
),
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
work?.length && where(getManyToManyClause('work'), {values: work}),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
@@ -229,12 +272,15 @@ export const loadProfiles = async (props: profileQueryType) => {
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
]
let selectCols = 'profiles.*, name, username, users.data as user'
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
if (orderByParam === 'compatibility_score') {
selectCols += ', cs.score as compatibility_score'
} else if (orderByParam === 'last_online_time') {
selectCols += ', user_activity.last_online_time'
}
if (interests) selectCols += `, COALESCE(i.interests, '{}') AS interests`
if (causes) selectCols += `, COALESCE(i.causes, '{}') AS causes`
if (work) selectCols += `, COALESCE(i.work, '{}') AS work`
const query = renderSql(
select(selectCols),
@@ -267,7 +313,8 @@ export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
if (!props.skipId) props.skipId = auth.uid
const {profiles, count} = await loadProfiles(props)
return {status: 'success', profiles: profiles, count: count}
} catch {
} catch (error) {
console.log(error)
return {status: 'fail', profiles: [], count: 0}
}
}

View File

@@ -0,0 +1,63 @@
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
import {tryCatch} from 'common/util/try-catch'
import {OPTION_TABLES} from "common/profiles/constants";
export const updateOptions: APIHandler<'update-options'> = async (
{table, names},
auth
) => {
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
if (!names || !Array.isArray(names) || names.length === 0) {
throw new APIError(400, 'No names provided')
}
log('Updating profile options', {table, names})
const pg = createSupabaseDirectClient()
const profileIdResult = await pg.oneOrNone<{ id: number }>(
'SELECT id FROM profiles WHERE user_id = $1',
[auth.uid]
)
if (!profileIdResult) throw new APIError(404, 'Profile not found')
const profileId = profileIdResult.id
const result = await tryCatch(pg.tx(async (t) => {
const ids: number[] = []
for (const name of names) {
const row = await t.one<{ id: number }>(
`INSERT INTO ${table} (name, creator_id)
VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE
SET name = ${table}.name
RETURNING id`,
[name, auth.uid]
)
ids.push(row.id)
}
// Delete old options for this profile
await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId])
// Insert new option_ids
if (ids.length > 0) {
const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ')
await t.none(
`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`,
[profileId, ...ids]
)
}
return ids
}))
if (result.error) {
log('Error updating profile options', result.error)
throw new APIError(500, 'Error updating profile options')
}
return {updatedIds: result.data}
}

View File

@@ -11,7 +11,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
parsedBody,
auth
) => {
log('parsedBody', parsedBody)
log('Updating profile', parsedBody)
const pg = createSupabaseDirectClient()
const { data: existingProfile } = await tryCatch(

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS causes
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
creator_id text REFERENCES users (id) ON DELETE set null,
name TEXT NOT NULL,
CONSTRAINT causes_name_unique UNIQUE (name)
);
-- Row Level Security
ALTER TABLE causes
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON causes;
CREATE POLICY "public read" ON causes
FOR SELECT USING (true);
CREATE UNIQUE INDEX idx_causes_name_ci
ON causes (name);

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS interests
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
creator_id text REFERENCES users (id) ON DELETE set null,
name TEXT NOT NULL,
CONSTRAINT interests_name_unique UNIQUE (name)
);
-- Row Level Security
ALTER TABLE interests
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON interests;
CREATE POLICY "public read" ON interests
FOR SELECT USING (true);
CREATE UNIQUE INDEX idx_interests_name_ci
ON interests (name);

View File

@@ -0,0 +1,23 @@
CREATE TABLE profile_causes
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES causes (id) ON DELETE CASCADE
);
-- Row Level Security
ALTER TABLE profile_causes
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON profile_causes;
CREATE POLICY "public read" ON profile_causes
FOR SELECT USING (true);
ALTER TABLE profile_causes
ADD CONSTRAINT profile_causes_option_unique UNIQUE (profile_id, option_id);
CREATE INDEX idx_profile_causes_profile
ON profile_causes (profile_id);
CREATE INDEX idx_profile_causes_interest
ON profile_causes (option_id);

View File

@@ -0,0 +1,23 @@
CREATE TABLE profile_interests
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES interests (id) ON DELETE CASCADE
);
-- Row Level Security
ALTER TABLE profile_interests
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON profile_interests;
CREATE POLICY "public read" ON profile_interests
FOR SELECT USING (true);
ALTER TABLE profile_interests
ADD CONSTRAINT profile_interests_option_unique UNIQUE (profile_id, option_id);
CREATE INDEX idx_profile_interests_profile
ON profile_interests (profile_id);
CREATE INDEX idx_profile_interests_interest
ON profile_interests (option_id);

View File

@@ -0,0 +1,23 @@
CREATE TABLE profile_work
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
profile_id BIGINT NOT NULL REFERENCES profiles (id) ON DELETE CASCADE,
option_id BIGINT NOT NULL REFERENCES work (id) ON DELETE CASCADE
);
-- Row Level Security
ALTER TABLE profile_work
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON profile_work;
CREATE POLICY "public read" ON profile_work
FOR SELECT USING (true);
ALTER TABLE profile_work
ADD CONSTRAINT profile_work_option_unique UNIQUE (profile_id, option_id);
CREATE INDEX idx_profile_work_profile
ON profile_work (profile_id);
CREATE INDEX idx_profile_work_interest
ON profile_work (option_id);

18
backend/supabase/work.sql Normal file
View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS work
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
creator_id text REFERENCES users (id) ON DELETE set null,
name TEXT NOT NULL,
CONSTRAINT work_name_unique UNIQUE (name)
);
-- Row Level Security
ALTER TABLE work
ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON work;
CREATE POLICY "public read" ON work
FOR SELECT USING (true);
CREATE UNIQUE INDEX idx_work_name_ci
ON work (name);

View File

@@ -1,7 +1,7 @@
import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types'
import {PrivateChatMessage} from 'common/chat-message'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
import {Profile, ProfileRow} from 'common/profiles/profile'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
@@ -460,6 +460,9 @@ export const API = (_apiTypeCheck = {
diet: arraybeSchema.optional(),
political_beliefs: arraybeSchema.optional(),
mbti: arraybeSchema.optional(),
interests: arraybeSchema.optional(),
causes: arraybeSchema.optional(),
work: arraybeSchema.optional(),
relationship_status: arraybeSchema.optional(),
languages: arraybeSchema.optional(),
wants_kids_strength: z.coerce.number().optional(),
@@ -486,6 +489,33 @@ export const API = (_apiTypeCheck = {
summary: 'List profiles with filters, pagination and ordering',
tag: 'Profiles',
},
'get-options': {
method: 'GET',
authed: true,
rateLimited: true,
returns: {},
props: z
.object({
table: z.enum(OPTION_TABLES),
})
.strict(),
summary: 'Get profile options like interests',
tag: 'Profiles',
},
'update-options': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {},
props: z
.object({
table: z.enum(OPTION_TABLES),
names: arraybeSchema.optional(),
})
.strict(),
summary: 'Update profile options like interests',
tag: 'Profiles',
},
'create-comment': {
method: 'POST',
authed: true,

View File

@@ -91,6 +91,9 @@ const optionalProfilesSchema = z.object({
occupation: z.string().optional().nullable(),
occupation_title: z.string().optional().nullable(),
political_beliefs: z.array(z.string()).optional().nullable(),
interests: z.array(z.string()).optional().nullable(),
work: z.array(z.string()).optional().nullable(),
causes: z.array(z.string()).optional().nullable(),
relationship_status: z.array(z.string()).optional().nullable(),
political_details: z.string().optional().nullable(),
pref_romantic_styles: z.array(z.string()).nullable(),

View File

@@ -1,6 +1,7 @@
import {Profile, ProfileRow} from "common/profiles/profile";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
import {OptionTableKey} from "common/profiles/constants";
// export type TargetArea = {
// lat: number
@@ -21,7 +22,10 @@ export type FilterFields = {
shortBio: boolean | undefined
drinks_min: number | undefined
drinks_max: number | undefined
} & Pick<
} & {
[K in OptionTableKey]: string[]
}
& Pick<
ProfileRow,
| 'wants_kids_strength'
| 'pref_relation_styles'
@@ -74,6 +78,9 @@ export const initialFilters: Partial<FilterFields> = {
pref_romantic_styles: undefined,
diet: undefined,
political_beliefs: undefined,
interests: undefined,
causes: undefined,
work: undefined,
relationship_status: undefined,
languages: undefined,
religion: undefined,

View File

@@ -1,7 +1,10 @@
import { isProd } from 'common/envs/is-prod'
import {isProd} from 'common/envs/is-prod'
export const compassUserId = isProd()
? 'tRZZ6ihugZQLXPf6aPRneGpWLmz1'
: 'RlXR2xa4EFfAzdCbSe45wkcdarh1'
export const MAX_COMPATIBILITY_QUESTION_LENGTH = 240
export const OPTION_TABLES = ['interests', 'causes', 'work'] as const
export type OptionTableKey = typeof OPTION_TABLES[number]

View File

@@ -1,10 +1,55 @@
import { Row, run, SupabaseClient } from 'common/supabase/utils'
import { User } from 'common/user'
import {Row, run, SupabaseClient} from 'common/supabase/utils'
import {User} from 'common/user'
import {OptionTableKey} from "common/profiles/constants";
export type ProfileRow = Row<'profiles'>
export type Profile = ProfileRow & { user: User }
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
// console.debug('getProfileRow', userId)
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
return res.data[0]
export type ProfileWithoutUser = ProfileRow & {[K in OptionTableKey]?: string[]}
export type Profile = ProfileWithoutUser & { user: User }
export const getProfileRow = async (userId: string, db: SupabaseClient): Promise<ProfileWithoutUser | null> => {
// Fetch profile
const profileRes = await run(
db
.from('profiles')
.select('*')
.eq('user_id', userId)
)
const profile = profileRes.data?.[0]
if (!profile) return null
// Fetch interests
const interestsRes = await run(
db
.from('profile_interests')
.select('interests(name)')
.eq('profile_id', profile.id)
)
const interests = interestsRes.data?.map((row: any) => row.interests.name) || []
// Fetch causes
const causesRes = await run(
db
.from('profile_causes')
.select('causes(name)')
.eq('profile_id', profile.id)
)
const causes = causesRes.data?.map((row: any) => row.causes.name) || []
// Fetch causes
const workRes = await run(
db
.from('profile_work')
.select('work(name)')
.eq('profile_id', profile.id)
)
const work = workRes.data?.map((row: any) => row.work.name) || []
// console.debug('work', work)
return {
...profile,
interests,
causes,
work,
}
}

View File

@@ -52,6 +52,32 @@ export type Database = {
},
]
}
causes: {
Row: {
creator_id: string | null
id: number
name: string
}
Insert: {
creator_id?: string | null
id?: never
name: string
}
Update: {
creator_id?: string | null
id?: never
name?: string
}
Relationships: [
{
foreignKeyName: 'causes_creator_id_fkey'
columns: ['creator_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
compatibility_answers: {
Row: {
created_time: string
@@ -243,6 +269,32 @@ export type Database = {
},
]
}
interests: {
Row: {
creator_id: string | null
id: number
name: string
}
Insert: {
creator_id?: string | null
id?: never
name: string
}
Update: {
creator_id?: string | null
id?: never
name?: string
}
Relationships: [
{
foreignKeyName: 'interests_creator_id_fkey'
columns: ['creator_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -431,6 +483,39 @@ export type Database = {
},
]
}
profile_causes: {
Row: {
id: number
option_id: number
profile_id: number
}
Insert: {
id?: never
option_id: number
profile_id: number
}
Update: {
id?: never
option_id?: number
profile_id?: number
}
Relationships: [
{
foreignKeyName: 'profile_causes_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'causes'
referencedColumns: ['id']
},
{
foreignKeyName: 'profile_causes_profile_id_fkey'
columns: ['profile_id']
isOneToOne: false
referencedRelation: 'profiles'
referencedColumns: ['id']
},
]
}
profile_comments: {
Row: {
content: Json
@@ -485,6 +570,39 @@ export type Database = {
},
]
}
profile_interests: {
Row: {
id: number
option_id: number
profile_id: number
}
Insert: {
id?: never
option_id: number
profile_id: number
}
Update: {
id?: never
option_id?: number
profile_id?: number
}
Relationships: [
{
foreignKeyName: 'profile_interests_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'interests'
referencedColumns: ['id']
},
{
foreignKeyName: 'profile_interests_profile_id_fkey'
columns: ['profile_id']
isOneToOne: false
referencedRelation: 'profiles'
referencedColumns: ['id']
},
]
}
profile_likes: {
Row: {
created_time: string
@@ -582,6 +700,39 @@ export type Database = {
},
]
}
profile_work: {
Row: {
id: number
option_id: number
profile_id: number
}
Insert: {
id?: never
option_id: number
profile_id: number
}
Update: {
id?: never
option_id?: number
profile_id?: number
}
Relationships: [
{
foreignKeyName: 'profile_work_option_id_fkey'
columns: ['option_id']
isOneToOne: false
referencedRelation: 'work'
referencedColumns: ['id']
},
{
foreignKeyName: 'profile_work_profile_id_fkey'
columns: ['profile_id']
isOneToOne: false
referencedRelation: 'profiles'
referencedColumns: ['id']
},
]
}
profiles: {
Row: {
age: number | null
@@ -1077,6 +1228,32 @@ export type Database = {
},
]
}
work: {
Row: {
creator_id: string | null
id: number
name: string
}
Insert: {
creator_id?: string | null
id?: never
name: string
}
Update: {
creator_id?: string | null
id?: never
name?: string
}
Relationships: [
{
foreignKeyName: 'work_creator_id_fkey'
columns: ['creator_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
]
}
}
Views: {
[_ in never]: never

View File

@@ -0,0 +1,31 @@
import {ProfileWithoutUser} from "common/profiles/profile";
import {OptionTableKey} from "common/profiles/constants";
import {Col} from "web/components/layout/col";
import clsx from "clsx";
import {colClassName, labelClassName} from "web/pages/signup";
import {MultiCheckbox} from "web/components/multi-checkbox";
import {capitalize} from "lodash";
export function AddOptionEntry(props: {
choices: {},
setChoices: (choices: {}) => void
profile: ProfileWithoutUser,
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
label: OptionTableKey,
}) {
const {profile, setProfile, label, choices, setChoices} = props
return <Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>{capitalize(label)}</label>
<MultiCheckbox
choices={choices}
selected={profile[label] ?? []}
onChange={(selected) => setProfile(label, selected)}
addOption={(v: string) => {
console.log(`Adding ${label}:`, v)
setChoices((prev: string[]) => ({...prev, [v]: v}))
setProfile(label, [...(profile[label] ?? []), v])
return {key: v, value: v}
}}
/>
</Col>
}

View File

@@ -20,7 +20,7 @@ import {KidsLabel, wantsKidsLabelsWithIcon} from "web/components/filters/wants-k
import {hasKidsLabels} from "common/has-kids";
import {HasKidsLabel} from "web/components/filters/has-kids-filter";
import {RomanticFilter, RomanticFilterText} from "web/components/filters/romantic-filter";
import {FaHeart} from "react-icons/fa";
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
import {DietFilter, DietFilterText} from "web/components/filters/diet-filter";
import {PoliticalFilter, PoliticalFilterText} from "web/components/filters/political-filter";
import {GiFruitBowl} from "react-icons/gi";
@@ -40,6 +40,8 @@ import {
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {BsPersonHeart} from "react-icons/bs";
import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter";
import {OptionTableKey} from "common/profiles/constants";
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -50,6 +52,7 @@ export function DesktopFilters(props: {
isYourFilters: boolean
locationFilterProps: LocationFilterProps
includeRelationshipFilters: boolean | undefined
choices: Record<OptionTableKey, Record<string, string[]>>
}) {
const {
filters,
@@ -60,6 +63,7 @@ export function DesktopFilters(props: {
isYourFilters,
locationFilterProps,
includeRelationshipFilters,
choices,
} = props
return (
@@ -418,6 +422,105 @@ export function DesktopFilters(props: {
menuWidth="w-50"
/>
{/* Interests */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaStar className="h-4 w-4"/>
<InterestFilterText
options={
filters.interests as
| string[]
| undefined
}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'interest'}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.interests}
label={'interests'}
/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50 max-h-[400px] overflow-y-auto"
/>
{/* Causes */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaHandsHelping className="h-4 w-4"/>
<InterestFilterText
options={
filters.causes as
| string[]
| undefined
}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'cause'}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.causes}
label={'causes'}
/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50 max-h-[400px] overflow-y-auto"
/>
{/* Work */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaBriefcase className="h-4 w-4"/>
<InterestFilterText
options={
filters.work as
| string[]
| undefined
}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'work'}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.work}
label={'work'}
/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50 max-h-[400px] overflow-y-auto"
/>
{/* POLITICS */}
<CustomizeableDropdown
buttonContent={(open) => (

View File

@@ -0,0 +1,59 @@
import clsx from 'clsx'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from "common/filters";
import {OptionTableKey} from "common/profiles/constants";
export function InterestFilterText(props: {
options: string[] | undefined
highlightedClass?: string
label: string
}) {
const {options, highlightedClass, label} = props
const length = (options ?? []).length
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>Any {label}</span>
)
}
if (length > 2) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
Multiple
</span>
</span>
)
}
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: options,
capitalizeFirstLetterOption: true,
})}{' '}
</span>
</div>
)
}
export function InterestFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
choices: Record<string, string[]>
label: OptionTableKey
}) {
const {filters, updateFilter, choices, label} = props
return (
<MultiCheckbox
selected={filters[label] ?? []}
choices={choices as any}
onChange={(c) => {
updateFilter({[label]: c})
}}
/>
)
}

View File

@@ -31,6 +31,8 @@ import {
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {MbtiFilter, MbtiFilterText} from "web/components/filters/mbti-filter";
import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter";
import {OptionTableKey} from "common/profiles/constants";
function MobileFilters(props: {
filters: Partial<FilterFields>
@@ -41,6 +43,7 @@ function MobileFilters(props: {
isYourFilters: boolean
locationFilterProps: LocationFilterProps
includeRelationshipFilters: boolean | undefined
choices: Record<OptionTableKey, Record<string, string[]>>
}) {
const {
filters,
@@ -51,6 +54,7 @@ function MobileFilters(props: {
isYourFilters,
locationFilterProps,
includeRelationshipFilters,
choices,
} = props
const [openFilter, setOpenFilter] = useState<string | undefined>(undefined)
@@ -364,6 +368,84 @@ function MobileFilters(props: {
<LanguageFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* INTERESTS */}
<MobileFilterSection
title="Interests"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.interests || undefined)}
selection={
<InterestFilterText
options={filters.interests as string[]}
highlightedClass={
hasAny(filters.interests || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
label={'interests'}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.interests}
label={'interests'}
/>
</MobileFilterSection>
{/* CAUSES */}
<MobileFilterSection
title="Causes"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.causes || undefined)}
selection={
<InterestFilterText
options={filters.causes as string[]}
highlightedClass={
hasAny(filters.causes || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
label={'causes'}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.causes}
label={'causes'}
/>
</MobileFilterSection>
{/* WORK */}
<MobileFilterSection
title="Work"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.work || undefined)}
selection={
<InterestFilterText
options={filters.work as string[]}
highlightedClass={
hasAny(filters.work || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
label={'work'}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.work}
label={'work'}
/>
</MobileFilterSection>
{/* POLITICS */}
<MobileFilterSection
title="Politics"

View File

@@ -17,6 +17,7 @@ import {useUser} from "web/hooks/use-user";
import toast from "react-hot-toast";
import {FilterFields} from "common/filters";
import {DisplayUser} from "common/api/user-types";
import {useChoices} from "web/hooks/use-choices";
function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(
@@ -137,6 +138,14 @@ export const Search = (props: {
const [openStarBookmarks, setOpenStarBookmarks] = useState(false);
const user = useUser()
const youSeekingRelationship = youProfile?.pref_relation_styles?.includes('relationship')
const {choices: interestChoices} = useChoices('interests')
const {choices: causeChoices} = useChoices('causes')
const {choices: workChoices} = useChoices('work')
const choices = {
interests: interestChoices,
causes: causeChoices,
work: workChoices,
}
useEffect(() => {
if (isHolding) return;
@@ -221,6 +230,7 @@ export const Search = (props: {
isYourFilters={isYourFilters}
locationFilterProps={locationFilterProps}
includeRelationshipFilters={youSeekingRelationship}
choices={choices}
/>
</Row>
<RightModal
@@ -237,6 +247,7 @@ export const Search = (props: {
isYourFilters={isYourFilters}
locationFilterProps={locationFilterProps}
includeRelationshipFilters={youSeekingRelationship}
choices={choices}
/>
</RightModal>
<Row className="items-center justify-between w-full flex-wrap gap-2">

View File

@@ -74,6 +74,9 @@ export const useFilters = (you: Profile | undefined) => {
pref_romantic_styles: you?.pref_romantic_styles?.length ? you.pref_romantic_styles : undefined,
diet: you?.diet?.length ? you.diet : undefined,
political_beliefs: you?.political_beliefs?.length ? you.political_beliefs : undefined,
interests: you?.interests?.length ? you.interests : undefined,
work: you?.work?.length ? you.work : undefined,
causes: you?.causes?.length ? you.causes : undefined,
mbti: you?.mbti ? [you.mbti] : undefined,
relationship_status: you?.relationship_status?.length ? you.relationship_status : undefined,
languages: you?.languages?.length ? you.languages : undefined,
@@ -99,6 +102,9 @@ export const useFilters = (you: Profile | undefined) => {
&& isEqual(new Set(filters.pref_relation_styles), new Set(you.pref_relation_styles))
&& isEqual(new Set(filters.diet), new Set(you.diet))
&& isEqual(new Set(filters.political_beliefs), new Set(you.political_beliefs))
&& isEqual(new Set(filters.interests), new Set(you.interests))
&& isEqual(new Set(filters.causes), new Set(you.causes))
&& isEqual(new Set(filters.work), new Set(you.work))
&& isEqual(new Set(filters.relationship_status), new Set(you.relationship_status))
&& isEqual(new Set(filters.languages), new Set(you.languages))
&& isEqual(new Set(filters.religion), new Set(you.religion))

View File

@@ -1,30 +1,134 @@
import { Row } from 'web/components/layout/row'
import { Checkbox } from 'web/components/widgets/checkbox'
import clsx from "clsx";
import { Input } from 'web/components/widgets/input'
import { Button } from 'web/components/buttons/button'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
export const MultiCheckbox = (props: {
// Map of label -> value
choices: { [key: string]: string }
// Selected values (should match the "value" side of choices)
selected: string[]
onChange: (selected: string[]) => void
className?: string
// If provided, enables adding a new option and should persist it (e.g. to DB)
// Return value can be:
// - string: the stored value for the new option; label will be the input text
// - { key, value }: explicit label (key) and stored value
// - null/undefined to indicate failure/cancellation
addOption?: (label: string) => string | { key: string; value: string } | null | undefined
addPlaceholder?: string
}) => {
const { choices, selected, onChange, className } = props
const { choices, selected, onChange, className, addOption, addPlaceholder } = props
// Keep a local merged copy to allow optimistic adds while remaining in sync with props
const [localChoices, setLocalChoices] = useState<{ [key: string]: string }>(choices)
useEffect(() => {
setLocalChoices((prev) => {
// If incoming choices changed, merge them with any locally added that still don't collide
// Props should be source of truth on conflicts
return { ...prev, ...choices }
})
}, [choices])
const entries = useMemo(() => Object.entries(localChoices), [localChoices])
// Add-new option state
const [newLabel, setNewLabel] = useState('')
const [adding, setAdding] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filter visible options while typing a new option (case-insensitive label match)
const filteredEntries = useMemo(() => {
if (!addOption) return entries
const q = newLabel.trim().toLowerCase()
if (!q) return entries
return entries.filter(([key]) => key.toLowerCase().includes(q))
}, [addOption, entries, newLabel])
const submitAdd = async () => {
if (!addOption) return
const label = newLabel.trim()
setError(null)
if (!label) {
setError('Please enter a value.')
return
}
// prevent duplicate by label or by value already selected
const lowerCaseChoices = Object.keys(localChoices).map((k: string) => k.toLowerCase())
if (lowerCaseChoices.includes(label.toLowerCase())) {
setError('That option already exists.')
// const key = Object.keys(lowerCaseChoices).find((k) => k.toLowerCase() === label.toLowerCase())
// if (!key) return
// setProfile('interests', [...(profile['interests'] ?? []), key])
return
}
setAdding(true)
try {
const result = addOption(label)
if (!result) {
setError('Could not add option.')
setAdding(false)
return
}
const { key, value } = typeof result === 'string' ? { key: label, value: result } : result
setLocalChoices((prev) => ({ ...prev, [key]: value }))
// auto-select newly added option if not already selected
if (!selected.includes(value)) onChange([...selected, value])
setNewLabel('')
} catch (e) {
setError('Failed to add option.')
} finally {
setAdding(false)
}
}
return (
<Row className={clsx('flex-wrap', className)}>
{Object.entries(choices).map(([key, value]) => (
<Checkbox
key={key}
label={key}
checked={selected.includes(value)}
toggle={(checked: boolean) => {
if (checked) {
onChange([...selected, value])
} else {
onChange(selected.filter((s) => s !== value))
}
}}
/>
))}
</Row>
<div className={clsx('space-y-2', className)}>
{addOption && (
<Row className="items-center gap-2">
<Input
value={newLabel}
placeholder={addPlaceholder ?? 'Search or add'}
onChange={(e) => {
setNewLabel(e.target.value)
setError(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
submitAdd()
}
}}
className="h-10"
/>
<Button size="sm" onClick={submitAdd} loading={adding} disabled={adding}>
Add
</Button>
{error && <span className="text-sm text-error">{error}</span>}
</Row>
)}
<Row className={clsx('flex-wrap')}>
{filteredEntries.map(([key, value]) => (
<Checkbox
key={key}
label={key}
checked={selected.includes(value)}
toggle={(checked: boolean) => {
if (checked) {
onChange([...selected, value])
} else {
onChange(selected.filter((s) => s !== value))
}
}}
/>
))}
</Row>
{addOption && newLabel.trim() && filteredEntries.length === 0 && (
<div className="px-2 text-sm text-ink-500">No matching options, feel free to add it.</div>
)}
</div>
)
}
}

View File

@@ -1,4 +1,4 @@
import {Fragment, useRef, useState} from 'react'
import {Fragment, useEffect, useRef, useState} from 'react'
import {Title} from 'web/components/widgets/title'
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
@@ -9,13 +9,12 @@ import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {Button, IconButton} from 'web/components/buttons/button'
import {colClassName, labelClassName} from 'web/pages/signup'
import {useRouter} from 'next/router'
import {updateProfile, updateUser} from 'web/lib/api'
import {Column} from 'common/supabase/utils'
import {api, updateProfile, updateUser} from 'web/lib/api'
import {User} from 'common/user'
import {track} from 'web/lib/service/analytics'
import {Carousel} from 'web/components/widgets/carousel'
import {tryCatch} from 'common/util/try-catch'
import {ProfileRow} from 'common/profiles/profile'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {removeUndefinedProps} from 'common/util/object'
import {isEqual, range} from 'lodash'
import {PlatformSelect} from 'web/components/widgets/platform-select'
@@ -32,7 +31,7 @@ import {
EDUCATION_CHOICES,
LANGUAGE_CHOICES,
MBTI_CHOICES,
POLITICAL_CHOICES,
POLITICAL_CHOICES,
RACE_CHOICES,
RELATIONSHIP_CHOICES,
RELATIONSHIP_STATUS_CHOICES,
@@ -40,10 +39,14 @@ import {
ROMANTIC_CHOICES
} from "web/components/filters/choices";
import toast from "react-hot-toast";
import {db} from "web/lib/supabase/db";
import {fetchChoices} from "web/hooks/use-choices";
import {AddOptionEntry} from "web/components/add-option-entry";
export const OptionalProfileUserForm = (props: {
profile: ProfileRow
setProfile: <K extends Column<'profiles'>>(key: K, value: ProfileRow[K]) => void
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
user: User
buttonLabel?: string
fromSignup?: boolean
@@ -71,15 +74,44 @@ export const OptionalProfileUserForm = (props: {
const [newLinkPlatform, setNewLinkPlatform] = useState('')
const [newLinkValue, setNewLinkValue] = useState('')
const [interestChoices, setInterestChoices] = useState({})
const [causeChoices, setCauseChoices] = useState({})
const [workChoices, setWorkChoices] = useState({})
useEffect(() => {
fetchChoices('interests').then(setInterestChoices)
fetchChoices('causes').then(setCauseChoices)
fetchChoices('work').then(setWorkChoices)
}, [db])
const handleSubmit = async () => {
setIsSubmitting(true)
const {bio: _bio, bio_text: _bio_text, bio_tsv: _bio_tsv, bio_length: _bio_length, ...otherProfileProps} = profile
const {
bio: _bio,
bio_text: _bio_text,
bio_tsv: _bio_tsv,
bio_length: _bio_length,
interests,
causes,
work,
...otherProfileProps
} = profile
console.debug('otherProfileProps', removeUndefinedProps(otherProfileProps))
const {error} = await tryCatch(
updateProfile(removeUndefinedProps(otherProfileProps) as any)
)
if (error) {
const promises: Promise<any>[] = [
tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any))
]
if (interests?.length) {
promises.push(api('update-options', {table: 'interests', names: interests}))
}
if (causes?.length) {
promises.push(api('update-options', {table: 'causes', names: causes}))
}
if (work?.length) {
promises.push(api('update-options', {table: 'work', names: work}))
}
try {
await Promise.all(promises)
} catch (error) {
console.error(error)
toast.error(
`We ran into an issue saving your profile. Please try again or contact us if the issue persists.`
@@ -94,7 +126,6 @@ export const OptionalProfileUserForm = (props: {
return
}
}
onSubmit && (await onSubmit())
setIsSubmitting(false)
track('submit optional profile')
@@ -441,6 +472,22 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<AddOptionEntry
choices={interestChoices}
setChoices={setInterestChoices}
profile={profile}
setProfile={setProfile}
label={'interests'}
/>
<AddOptionEntry
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>MBTI Personality Type</label>
<ChoicesToggleGroup
@@ -460,6 +507,59 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<AddOptionEntry
choices={workChoices}
setChoices={setWorkChoices}
profile={profile}
setProfile={setProfile}
label={'work'}
/>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Company</label>
<Input
type="text"
onChange={(e) => setProfile('company', e.target.value)}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
Job title {profile['company'] ? 'at ' + profile['company'] : ''}
</label>
<Input
type="text"
onChange={(e) => setProfile('occupation_title', e.target.value)}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
Highest completed education level
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level'] ?? ''}
choicesMap={EDUCATION_CHOICES}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>University</label>
<Input
type="text"
onChange={(e) => setProfile('university', e.target.value)}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Do you smoke?</label>
<ChoicesToggleGroup
@@ -572,49 +672,6 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
Highest completed education level
</label>
<Carousel className="max-w-full">
<ChoicesToggleGroup
currentChoice={profile['education_level'] ?? ''}
choicesMap={EDUCATION_CHOICES}
setChoice={(c) => setProfile('education_level', c)}
/>
</Carousel>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>University</label>
<Input
type="text"
onChange={(e) => setProfile('university', e.target.value)}
className={'w-52'}
value={profile['university'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Company</label>
<Input
type="text"
onChange={(e) => setProfile('company', e.target.value)}
className={'w-52'}
value={profile['company'] ?? undefined}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
Job title {profile['company'] ? 'at ' + profile['company'] : ''}
</label>
<Input
type="text"
onChange={(e) => setProfile('occupation_title', e.target.value)}
className={'w-52'}
value={profile['occupation_title'] ?? undefined}
/>
</Col>
{/*<Col className={clsx(colClassName)}>*/}
{/* <label className={clsx(labelClassName)}>Looking for a relationship?</label>*/}
{/* <ChoicesToggleGroup*/}

View File

@@ -30,6 +30,7 @@ import {UserActivity} from "common/user";
import {ClockIcon} from "@heroicons/react/solid";
import {MAX_INT, MIN_INT} from "common/constants";
import {GiFruitBowl} from "react-icons/gi";
import {FaBriefcase, FaHandsHelping, FaStar} from "react-icons/fa";
export function AboutRow(props: {
icon: ReactNode
@@ -85,8 +86,8 @@ export default function ProfileAbout(props: {
<Education profile={profile}/>
<Occupation profile={profile}/>
<AboutRow
icon={<MdLanguage className="h-5 w-5"/>}
text={profile.languages?.map(v => INVERTED_LANGUAGE_CHOICES[v])}
icon={<FaBriefcase className="h-5 w-5"/>}
text={profile.work}
/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5"/>}
@@ -98,6 +99,14 @@ export default function ProfileAbout(props: {
text={profile.religion?.map(belief => INVERTED_RELIGION_CHOICES[belief])}
suffix={profile.religious_beliefs}
/>
<AboutRow
icon={<FaStar className="h-5 w-5"/>}
text={profile.interests}
/>
<AboutRow
icon={<FaHandsHelping className="h-5 w-5"/>}
text={profile.causes}
/>
<AboutRow
icon={<BsPersonVcard className="h-5 w-5"/>}
text={profile.mbti ? INVERTED_MBTI_CHOICES[profile.mbti] : null}
@@ -114,6 +123,10 @@ export default function ProfileAbout(props: {
icon={<GiFruitBowl className="h-5 w-5"/>}
text={profile.diet?.map(e => INVERTED_DIET_CHOICES[e])}
/>
<AboutRow
icon={<MdLanguage className="h-5 w-5"/>}
text={profile.languages?.map(v => INVERTED_LANGUAGE_CHOICES[v])}
/>
<HasKids profile={profile}/>
<WantsKids profile={profile}/>
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time}/>}

View File

@@ -9,8 +9,7 @@ import {labelClassName} from 'web/pages/signup'
import {User} from 'common/user'
import {useEditableUserInfo} from 'web/hooks/use-editable-user-info'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Column} from 'common/supabase/utils'
import {ProfileRow} from 'common/profiles/profile'
import {ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
import {SignupBio} from "web/components/bio/editable-bio";
import {Editor} from "@tiptap/core";
@@ -42,7 +41,7 @@ export const RequiredProfileUserForm = (props: {
setEditUsername?: (name: string) => unknown
setEditDisplayName?: (name: string) => unknown
profile: ProfileRow
setProfile: <K extends Column<'profiles'>>(key: K, value: ProfileRow[K] | undefined) => void
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K] | undefined) => void
isSubmitting: boolean
onSubmit?: () => void
profileCreatedAlready?: boolean

32
web/hooks/use-choices.ts Normal file
View File

@@ -0,0 +1,32 @@
import {useEffect} from 'react'
import {run} from 'common/supabase/utils'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {db} from 'web/lib/supabase/db'
export async function fetchChoices(label: string) {
const {data} = await run(db.from(label).select('name').order('name'))
console.log('Fetched choices:', data)
const results = Object.fromEntries(data.map((row: { name: string }) => [row.name, row.name]))
return results;
}
export const useChoices = (label: string) => {
const [choices, setChoices] = usePersistentInMemoryState({}, `${label}-choices`)
const refreshChoices = async () => {
try {
const results = await fetchChoices(label)
setChoices(results)
} catch (err) {
console.error('Error fetching choices:', err)
return {}
}
}
useEffect(() => {
console.log('Fetching choices in use effect...')
refreshChoices()
}, [])
return {choices, refreshChoices}
}

View File

@@ -3,10 +3,9 @@ import {useEffect} from 'react'
import {Row} from 'common/supabase/utils'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {User} from 'common/user'
import {getProfileRow, Profile, ProfileRow} from 'common/profiles/profile'
import {getProfileRow, Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {db} from 'web/lib/supabase/db'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {logger} from "common/logging";
export const useProfile = () => {
const user = useUser()
@@ -63,7 +62,7 @@ export const useProfileByUser = (user: User | undefined) => {
export const useProfileByUserId = (userId: string | undefined) => {
const [profile, setProfile] = usePersistentInMemoryState<
ProfileRow | undefined | null
ProfileWithoutUser | undefined | null
>(undefined, `profile-${userId}`)
useEffect(() => {

View File

@@ -1,5 +1,4 @@
import {Profile, ProfileRow} from 'common/profiles/profile'
import {Column} from 'common/supabase/utils'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {User} from 'common/user'
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
import {RequiredProfileUserForm} from 'web/components/required-profile-form'
@@ -33,7 +32,7 @@ function ProfilePageInner(props: { user: User; profile: Profile }) {
user,
})
const setProfileState = <K extends Column<'profiles'>>(key: K, value: ProfileRow[K] | undefined) => {
const setProfileState = <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K] | undefined) => {
setProfile((prevState) => ({...prevState, [key]: value}))
}

View File

@@ -12,7 +12,7 @@ import {track} from 'web/lib/service/analytics'
import {safeLocalStorage} from 'web/lib/util/local'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {useProfileByUserId} from 'web/hooks/use-profile'
import {ProfileRow} from 'common/profiles/profile'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {PageBase} from "web/components/page-base";
import {SEO} from "web/components/SEO";
@@ -54,10 +54,10 @@ export default function SignupPage() {
}, [user, holdLoading])
// Omit the id, created_time?
const [profileForm, setProfileForm] = useState<ProfileRow>({
const [profileForm, setProfileForm] = useState<ProfileWithoutUser>({
...initialRequiredState,
} as any)
const setProfileState = (key: keyof ProfileRow, value: any) => {
const setProfileState = (key: keyof ProfileWithoutUser, value: any) => {
setProfileForm((prevState) => ({...prevState, [key]: value}))
}