feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel

- MemberCard: white bg, 1px border + shadow-card, centered column layout,
  avatar color by role (green-dark/blue), role badge with role-specific colors,
  join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
  and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
  label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
  box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 19:54:26 +02:00
parent df3b774f0c
commit 4e67ff4258
8 changed files with 505 additions and 143 deletions

View File

@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
return {
members: membersRes.data ?? [],
currentUserId: locals.benutzer!.id,
activeInvite: inviteRes.data?.data ?? null
activeInvite: inviteRes.data?.data ?? null,
householdName: locals.haushalt?.name ?? ''
};
};

View File

@@ -70,7 +70,8 @@
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
<div class="p-[16px_20px] md:p-[40px_56px]">
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Mitglieder</h1>
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
<MemberGrid
{members}

View File

@@ -10,8 +10,75 @@
type="button"
data-testid="invite-card"
{onclick}
style="background: var(--color-surface); border-radius: var(--radius-xl); border: 2px dashed var(--color-border); padding: 20px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%;"
class="invite-card"
>
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">+</span>
<span>Einladen</span>
<div class="invite-plus">+</div>
<div class="invite-label">Mitglied einladen</div>
</button>
<style>
.invite-card {
background: white;
border: 1.5px dashed var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
min-height: 180px;
gap: 10px;
width: 100%;
}
.invite-card:hover {
border-color: var(--green-light);
background: var(--green-tint);
}
.invite-plus {
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-subtle);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--color-text-muted);
}
.invite-card:hover .invite-plus {
background: var(--green-light);
color: var(--green-dark);
}
.invite-label {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
}
.invite-card:hover .invite-label {
color: var(--green-dark);
}
@media (max-width: 768px) {
.invite-card {
padding: 16px;
min-height: 120px;
}
.invite-plus {
width: 36px;
height: 36px;
font-size: 18px;
}
.invite-label {
font-size: 11px;
}
}
</style>

View File

@@ -23,28 +23,117 @@
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const isExpiringSoon = $derived(
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
);
</script>
<div
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 20px; margin-top: 16px; border: 1px solid var(--color-border);"
>
<p
style="font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 0 12px;"
>
{invite.shareUrl || invite.inviteCode}
</p>
<div class="invite-panel">
<div class="invite-panel-title">Einladelink teilen</div>
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
<button type="button" data-testid="copy-btn" onclick={copy}>
<div class="invite-link-row">
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
{copied ? 'Kopiert ✓' : 'Kopieren'}
</button>
<button type="button" data-testid="regenerate-btn" onclick={onregenerate}>
Neu generieren
</button>
</div>
<p style="margin: 0; font-size: 0.875rem;">
Läuft ab: {formatExpiry(invite.expiresAt)}
</p>
<div class="invite-expiry">
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
</div>
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
Neuen Link generieren
</button>
</div>
<style>
.invite-panel {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px;
margin-top: 8px;
}
.invite-panel-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.invite-panel-desc {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 16px;
}
.invite-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.invite-link-box {
flex: 1;
background: var(--color-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-copy {
padding: 8px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: white;
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--color-subtle);
}
.invite-expiry {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.invite-expiry span {
font-weight: 500;
}
.invite-expiry span.yellow {
background: var(--yellow-tint);
color: var(--yellow-text);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.btn-regen {
margin-top: 12px;
font-size: 12px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
display: block;
}
.btn-regen:hover {
color: var(--color-text);
}
</style>

View File

@@ -31,4 +31,9 @@ describe('InvitePanel', () => {
await userEvent.click(screen.getByTestId('regenerate-btn'));
expect(onregenerate).toHaveBeenCalledOnce();
});
it('shows the panel title', () => {
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
type Member = {
userId: string;
@@ -26,13 +25,18 @@
let menuOpen = $state(false);
let editingRole = $state(false);
const roleOptions = [
{ value: 'planner', label: 'Planer' },
{ value: 'member', label: 'Mitglied' }
];
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
const joinDateFormatted = $derived(
new Date(member.joinedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
);
let cardEl: HTMLElement | undefined = $state(undefined);
$effect(() => {
@@ -65,151 +69,318 @@
<article
bind:this={cardEl}
data-testid="member-card"
style="
background: var(--color-surface);
border-radius: var(--radius-xl);
padding: 20px;
position: relative;
{isCurrentUser ? 'border: 2px solid var(--color-success);' : ''}
"
class="member-card"
class:own={isCurrentUser}
class:editing={editingRole}
>
<!-- Avatar -->
<div
style="
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--color-success-dark);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
margin-bottom: 12px;
"
>
<div class="avatar" style="background: {avatarBg};">
{initials}
</div>
<!-- Name row -->
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
<span style="font-weight: 500;">{member.displayName}</span>
{#if isCurrentUser}
<span
style="
background: var(--color-success-tint);
color: var(--color-success-dark);
border-radius: 999px;
font-size: 11px;
padding: 2px 8px;
"
>Du</span>
{/if}
</div>
<!-- Name -->
<div class="member-name">{member.displayName}</div>
<!-- Role badge or SegmentedControl -->
<!-- Role badge or inline role control -->
{#if editingRole}
<SegmentedControl
options={roleOptions}
value={member.role}
onchange={(newValue) => {
onrolechange(member, newValue);
editingRole = false;
}}
/>
<div role="group" class="role-control">
<button
type="button"
class="role-control-btn"
class:active={member.role === 'planner'}
onclick={() => {
if (member.role !== 'planner') {
onrolechange(member, 'planner');
editingRole = false;
}
}}
>Planer</button>
<button
type="button"
class="role-control-btn"
class:active={member.role === 'member'}
onclick={() => {
if (member.role !== 'member') {
onrolechange(member, 'member');
editingRole = false;
}
}}
>Mitglied</button>
</div>
{:else}
<span style="font-size: 13px; color: var(--color-text-muted);">
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
</span>
{/if}
<!-- Join date -->
<div class="join-date">seit {joinDateFormatted}</div>
<!-- Du badge (own card) or Abbrechen (when editing role) -->
{#if isCurrentUser}
<div class="self-badge-wrap">
<span class="self-badge">Du</span>
</div>
{:else if editingRole}
<button
type="button"
class="cancel-btn"
onclick={() => { editingRole = false; }}
>Abbrechen</button>
{/if}
<!-- Kebab button -->
{#if isPlanner && !isCurrentUser}
{#if isPlanner && !isCurrentUser && !editingRole}
<button
data-testid="kebab-btn"
type="button"
class="kebab-btn"
onclick={() => { menuOpen = true; }}
aria-label="Optionen"
style="
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
line-height: 1;
font-size: 18px;
color: var(--color-text-muted);
"
></button>
></button>
<!-- Dropdown -->
{#if menuOpen}
<div
style="
position: absolute;
top: 40px;
right: 12px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-raised);
z-index: 10;
min-width: 140px;
overflow: hidden;
"
>
<div class="dropdown">
<button
type="button"
style="
display: block;
width: 100%;
padding: 10px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
"
class="dropdown-item"
onclick={() => { menuOpen = false; editingRole = true; }}
>Rolle ändern</button>
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
<div class="dropdown-divider"></div>
<button
type="button"
style="
display: block;
width: 100%;
padding: 10px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--color-danger, #e53e3e);
"
class="dropdown-item danger"
onclick={() => { menuOpen = false; onremove(member); }}
>Entfernen</button>
><span class="dropdown-icon"></span>Entfernen</button>
</div>
{/if}
{/if}
</article>
<style>
.member-card {
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 24px 20px 20px;
box-shadow: var(--shadow-card);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.member-card.own {
border-color: var(--green-light);
}
.member-card.editing {
border-color: #B5D4F4;
}
.avatar {
width: 56px;
height: 56px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 20px;
font-weight: 500;
margin-bottom: 12px;
flex-shrink: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.role-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
white-space: nowrap;
}
.role-badge.planer {
background: var(--green-tint);
color: var(--green-dark);
}
.role-badge.mitglied {
background: var(--blue-tint);
color: var(--blue-dark);
}
.join-date {
font-size: 11px;
color: var(--color-text-muted);
margin-top: 8px;
}
.self-badge-wrap {
margin-top: 8px;
}
.self-badge {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--green-tint);
color: var(--green-dark);
}
.cancel-btn {
margin-top: 8px;
font-size: 11px;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.kebab-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-text-muted);
opacity: 0;
}
.kebab-btn:hover {
background: var(--color-subtle);
color: var(--color-text);
}
@media (hover: none) {
.kebab-btn {
opacity: 1;
}
}
.member-card:hover .kebab-btn {
.member-card:hover .kebab-btn,
.member-card:focus-within .kebab-btn {
opacity: 1;
}
.dropdown {
position: absolute;
top: 44px;
right: 12px;
background: white;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-raised);
min-width: 160px;
z-index: 10;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
width: 100%;
background: none;
border: none;
text-align: left;
}
.dropdown-item:hover {
background: var(--color-subtle);
}
.dropdown-item.danger {
color: var(--color-error);
}
.dropdown-item.danger:hover {
background: var(--error-tint, #FDECEA);
}
.dropdown-icon {
font-size: 14px;
width: 16px;
text-align: center;
}
.dropdown-divider {
height: 1px;
background: var(--color-border);
margin: 2px 0;
}
.role-control {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-top: 8px;
width: 100%;
}
.role-control-btn {
flex: 1;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
background: white;
border: none;
cursor: pointer;
color: var(--color-text-muted);
}
.role-control-btn:first-child {
border-right: 1px solid var(--color-border);
}
.role-control-btn.active {
background: var(--green-dark);
color: white;
}
@media (max-width: 768px) {
.member-card {
padding: 16px;
min-height: auto;
}
.avatar {
width: 44px;
height: 44px;
font-size: 16px;
margin-bottom: 8px;
}
.member-name {
font-size: 12px;
}
}
</style>

View File

@@ -144,4 +144,32 @@ describe('MemberCard', () => {
await userEvent.keyboard('{Escape}');
expect(screen.queryByText('Entfernen')).toBeNull();
});
it('shows formatted join date', () => {
render(MemberCard, {
props: {
member: plannerMember,
isCurrentUser: false,
isPlanner: false,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
});
it('shows Abbrechen button when editing role', async () => {
render(MemberCard, {
props: {
member: regularMember,
isCurrentUser: false,
isPlanner: true,
onremove: vi.fn(),
onrolechange: vi.fn()
}
});
await userEvent.click(screen.getByTestId('kebab-btn'));
await userEvent.click(screen.getByText('Rolle ändern'));
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
});
});

View File

@@ -19,16 +19,16 @@
{#if show}
{#if isMobile()}
<BottomSheet open={show} onclose={oncancel}>
<div data-testid="remove-dialog" style="padding: 24px;">
<h2 style="margin: 0 0 12px;">Mitglied entfernen</h2>
<p>
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
</p>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
@@ -36,7 +36,7 @@
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>
@@ -47,7 +47,7 @@
<div
data-testid="dialog-backdrop"
role="presentation"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,0.4); display: flex; align-items: center; justify-content: center;"
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
>
<div
data-testid="remove-dialog"
@@ -55,19 +55,19 @@
aria-modal="true"
aria-labelledby="remove-dialog-title"
tabindex="-1"
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 24px; max-width: 400px; width: 90%;"
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<h2 id="remove-dialog-title" style="margin: 0 0 12px;">Mitglied entfernen</h2>
<p>
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
</p>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button
type="button"
onclick={oncancel}
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Abbrechen
</button>
@@ -75,7 +75,7 @@
type="button"
data-testid="confirm-remove-btn"
onclick={onconfirm}
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
>
Entfernen
</button>