feat: extended profile fields (phone, discord, shirt/hoodie sizes) - Migration 024: add phone, discord_handle, shirt_size, hoodie_size to profiles - Account page: new Contact & Sizing section with phone, discord, size dropdowns - Save profile persists all new fields - Layout server: profile queries include new fields for org members and user - Event team page: member rows show phone, discord, T-shirt and hoodie sizes - EventMemberWithDetails profile type extended - i18n: 8 new keys in EN and ET - svelte-check: 0 errors, vitest: 112/112 passed
This commit is contained in:
@@ -175,6 +175,14 @@
|
||||
"account_display_name": "Display Name",
|
||||
"account_display_name_placeholder": "Your name",
|
||||
"account_email": "Email",
|
||||
"account_phone": "Phone",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "username",
|
||||
"account_contact_info": "Contact & Sizing",
|
||||
"account_shirt_size": "Shirt Size",
|
||||
"account_hoodie_size": "Hoodie Size",
|
||||
"account_size_placeholder": "Select size",
|
||||
"account_save_profile": "Save Profile",
|
||||
"account_appearance": "Appearance",
|
||||
"account_theme": "Theme",
|
||||
|
||||
@@ -175,6 +175,14 @@
|
||||
"account_display_name": "Kuvatav nimi",
|
||||
"account_display_name_placeholder": "Sinu nimi",
|
||||
"account_email": "E-post",
|
||||
"account_phone": "Telefon",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "kasutajanimi",
|
||||
"account_contact_info": "Kontakt ja suurused",
|
||||
"account_shirt_size": "Särgi suurus",
|
||||
"account_hoodie_size": "Pusa suurus",
|
||||
"account_size_placeholder": "Vali suurus",
|
||||
"account_save_profile": "Salvesta profiil",
|
||||
"account_appearance": "Välimus",
|
||||
"account_theme": "Teema",
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface EventMemberDepartment {
|
||||
}
|
||||
|
||||
export interface EventMemberWithDetails extends EventMember {
|
||||
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null };
|
||||
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
|
||||
event_role?: EventRole;
|
||||
departments: EventDepartment[];
|
||||
}
|
||||
@@ -262,12 +262,12 @@ export async function fetchEventMembers(
|
||||
|
||||
// Fetch profiles separately (same pattern as org_members)
|
||||
const userIds = members.map((m: any) => m.user_id);
|
||||
const { data: profiles } = await supabase
|
||||
const { data: profiles } = await (supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.in('id', userIds);
|
||||
|
||||
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
|
||||
const profileMap = Object.fromEntries((profiles ?? []).map((p: any) => [p.id, p]));
|
||||
|
||||
// Fetch roles for this event
|
||||
const { data: roles } = await (supabase as any)
|
||||
|
||||
@@ -49,9 +49,9 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
locals.supabase
|
||||
(locals.supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.eq('id', user.id)
|
||||
.single(),
|
||||
locals.supabase
|
||||
@@ -108,16 +108,16 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
|
||||
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
|
||||
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
|
||||
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
|
||||
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null }> = {};
|
||||
|
||||
if (memberUserIds.length > 0) {
|
||||
const { data: memberProfiles } = await locals.supabase
|
||||
const { data: memberProfiles } = await (locals.supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.in('id', memberUserIds);
|
||||
|
||||
if (memberProfiles) {
|
||||
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
|
||||
memberProfilesMap = Object.fromEntries(memberProfiles.map((p: any) => [p.id, p]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
phone: string | null;
|
||||
discord_handle: string | null;
|
||||
shirt_size: string | null;
|
||||
hoodie_size: string | null;
|
||||
};
|
||||
preferences: {
|
||||
id: string;
|
||||
@@ -34,10 +38,16 @@
|
||||
// Profile state
|
||||
let fullName = $state(data.profile.full_name ?? "");
|
||||
let avatarUrl = $state(data.profile.avatar_url ?? null);
|
||||
let phone = $state(data.profile.phone ?? "");
|
||||
let discordHandle = $state(data.profile.discord_handle ?? "");
|
||||
let shirtSize = $state(data.profile.shirt_size ?? "");
|
||||
let hoodieSize = $state(data.profile.hoodie_size ?? "");
|
||||
let isSaving = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let avatarInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
|
||||
|
||||
// Preferences state
|
||||
let theme = $state(data.preferences?.theme ?? "dark");
|
||||
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
|
||||
@@ -57,6 +67,10 @@
|
||||
$effect(() => {
|
||||
fullName = data.profile.full_name ?? "";
|
||||
avatarUrl = data.profile.avatar_url ?? null;
|
||||
phone = data.profile.phone ?? "";
|
||||
discordHandle = data.profile.discord_handle ?? "";
|
||||
shirtSize = data.profile.shirt_size ?? "";
|
||||
hoodieSize = data.profile.hoodie_size ?? "";
|
||||
theme = data.preferences?.theme ?? "dark";
|
||||
accentColor = data.preferences?.accent_color ?? "#00A3E0";
|
||||
useOrgTheme = data.preferences?.use_org_theme ?? true;
|
||||
@@ -161,9 +175,15 @@
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
const { error } = await supabase
|
||||
const { error } = await (supabase as any)
|
||||
.from("profiles")
|
||||
.update({ full_name: fullName || null })
|
||||
.update({
|
||||
full_name: fullName || null,
|
||||
phone: phone || null,
|
||||
discord_handle: discordHandle || null,
|
||||
shirt_size: shirtSize || null,
|
||||
hoodie_size: hoodieSize || null,
|
||||
})
|
||||
.eq("id", data.profile.id);
|
||||
|
||||
if (error) {
|
||||
@@ -305,6 +325,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Sizing Section -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_contact_info()}
|
||||
</h2>
|
||||
|
||||
<Input
|
||||
label={m.account_phone()}
|
||||
bind:value={phone}
|
||||
placeholder={m.account_phone_placeholder()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={m.account_discord()}
|
||||
bind:value={discordHandle}
|
||||
placeholder={m.account_discord_placeholder()}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-body text-body-sm text-light/60">{m.account_shirt_size()}</span>
|
||||
<select
|
||||
bind:value={shirtSize}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">{m.account_size_placeholder()}</option>
|
||||
{#each clothingSizes as size}
|
||||
<option value={size}>{size}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-body text-body-sm text-light/60">{m.account_hoodie_size()}</span>
|
||||
<select
|
||||
bind:value={hoodieSize}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">{m.account_size_placeholder()}</option>
|
||||
{#each clothingSizes as size}
|
||||
<option value={size}>{size}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||
<h2 class="font-heading text-body text-white">
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
phone: string | null;
|
||||
discord_handle: string | null;
|
||||
shirt_size: string | null;
|
||||
hoodie_size: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -626,6 +630,20 @@
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5 text-[10px] text-light/30">
|
||||
{#if member.profile?.phone}
|
||||
<span class="flex items-center gap-0.5"><span class="material-symbols-rounded" style="font-size: 12px;">phone</span>{member.profile.phone}</span>
|
||||
{/if}
|
||||
{#if member.profile?.discord_handle}
|
||||
<span class="flex items-center gap-0.5"><span class="material-symbols-rounded" style="font-size: 12px;">chat</span>{member.profile.discord_handle}</span>
|
||||
{/if}
|
||||
{#if member.profile?.shirt_size}
|
||||
<span>T: {member.profile.shirt_size}</span>
|
||||
{/if}
|
||||
{#if member.profile?.hoodie_size}
|
||||
<span>H: {member.profile.hoodie_size}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if isEditor}
|
||||
|
||||
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Extended profile fields: contact info and clothing sizes
|
||||
-- These are collected during onboarding and visible to event team managers
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN phone TEXT,
|
||||
ADD COLUMN discord_handle TEXT,
|
||||
ADD COLUMN shirt_size TEXT,
|
||||
ADD COLUMN hoodie_size TEXT;
|
||||
Reference in New Issue
Block a user