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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user