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:
AlacrisDevs
2026-02-07 12:53:56 +02:00
parent 1f2484da3d
commit 676468d3ec
7 changed files with 126 additions and 12 deletions

View File

@@ -175,6 +175,14 @@
"account_display_name": "Display Name", "account_display_name": "Display Name",
"account_display_name_placeholder": "Your name", "account_display_name_placeholder": "Your name",
"account_email": "Email", "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_save_profile": "Save Profile",
"account_appearance": "Appearance", "account_appearance": "Appearance",
"account_theme": "Theme", "account_theme": "Theme",

View File

@@ -175,6 +175,14 @@
"account_display_name": "Kuvatav nimi", "account_display_name": "Kuvatav nimi",
"account_display_name_placeholder": "Sinu nimi", "account_display_name_placeholder": "Sinu nimi",
"account_email": "E-post", "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_save_profile": "Salvesta profiil",
"account_appearance": "Välimus", "account_appearance": "Välimus",
"account_theme": "Teema", "account_theme": "Teema",

View File

@@ -60,7 +60,7 @@ export interface EventMemberDepartment {
} }
export interface EventMemberWithDetails extends EventMember { 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; event_role?: EventRole;
departments: EventDepartment[]; departments: EventDepartment[];
} }
@@ -262,12 +262,12 @@ export async function fetchEventMembers(
// Fetch profiles separately (same pattern as org_members) // Fetch profiles separately (same pattern as org_members)
const userIds = members.map((m: any) => m.user_id); const userIds = members.map((m: any) => m.user_id);
const { data: profiles } = await supabase const { data: profiles } = await (supabase as any)
.from('profiles') .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); .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 // Fetch roles for this event
const { data: roles } = await (supabase as any) const { data: roles } = await (supabase as any)

View File

@@ -49,9 +49,9 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
.eq('org_id', org.id) .eq('org_id', org.id)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(10), .limit(10),
locals.supabase (locals.supabase as any)
.from('profiles') .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) .eq('id', user.id)
.single(), .single(),
locals.supabase 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 // 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); 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) { if (memberUserIds.length > 0) {
const { data: memberProfiles } = await locals.supabase const { data: memberProfiles } = await (locals.supabase as any)
.from('profiles') .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); .in('id', memberUserIds);
if (memberProfiles) { if (memberProfiles) {
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p])); memberProfilesMap = Object.fromEntries(memberProfiles.map((p: any) => [p.id, p]));
} }
} }

View File

@@ -16,6 +16,10 @@
email: string; email: string;
full_name: string | null; full_name: string | null;
avatar_url: string | null; avatar_url: string | null;
phone: string | null;
discord_handle: string | null;
shirt_size: string | null;
hoodie_size: string | null;
}; };
preferences: { preferences: {
id: string; id: string;
@@ -34,10 +38,16 @@
// Profile state // Profile state
let fullName = $state(data.profile.full_name ?? ""); let fullName = $state(data.profile.full_name ?? "");
let avatarUrl = $state(data.profile.avatar_url ?? null); 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 isSaving = $state(false);
let isUploading = $state(false); let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null); let avatarInput = $state<HTMLInputElement | null>(null);
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
// Preferences state // Preferences state
let theme = $state(data.preferences?.theme ?? "dark"); let theme = $state(data.preferences?.theme ?? "dark");
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0"); let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
@@ -57,6 +67,10 @@
$effect(() => { $effect(() => {
fullName = data.profile.full_name ?? ""; fullName = data.profile.full_name ?? "";
avatarUrl = data.profile.avatar_url ?? null; 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"; theme = data.preferences?.theme ?? "dark";
accentColor = data.preferences?.accent_color ?? "#00A3E0"; accentColor = data.preferences?.accent_color ?? "#00A3E0";
useOrgTheme = data.preferences?.use_org_theme ?? true; useOrgTheme = data.preferences?.use_org_theme ?? true;
@@ -161,9 +175,15 @@
async function saveProfile() { async function saveProfile() {
isSaving = true; isSaving = true;
const { error } = await supabase const { error } = await (supabase as any)
.from("profiles") .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); .eq("id", data.profile.id);
if (error) { if (error) {
@@ -305,6 +325,58 @@
</div> </div>
</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 --> <!-- Appearance Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"> <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"> <h2 class="font-heading text-body text-white">

View File

@@ -21,6 +21,10 @@
email: string; email: string;
full_name: string | null; full_name: string | null;
avatar_url: string | null; avatar_url: string | null;
phone: string | null;
discord_handle: string | null;
shirt_size: string | null;
hoodie_size: string | null;
} | null; } | null;
} }
@@ -626,6 +630,20 @@
</span> </span>
{/each} {/each}
</div> </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>
</div> </div>
{#if isEditor} {#if isEditor}

View 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;