17 Commits
0.5.0 ... 0.7.1

Author SHA1 Message Date
fa34223c10 Add tada emoji as SVG favicon
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:26:38 +01:00
e6ea9405a6 Merge pull request 'Apply glassmorphism design system across all UI surfaces' (#23) from glassmorphism-event-cards into master
All checks were successful
CI / backend-test (push) Successful in 58s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Successful in 1m9s
2026-03-09 19:11:52 +01:00
32f96e4c6f Replace hardcoded color values with glass design tokens
All checks were successful
CI / backend-test (push) Successful in 1m0s
CI / frontend-test (push) Successful in 25s
CI / frontend-e2e (push) Successful in 1m13s
CI / build-and-publish (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:07:43 +01:00
e6c4a21f65 Apply glassmorphism to ConfirmDialog overlay and surface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:00:39 +01:00
831ffc071a Apply glassmorphism to BottomSheet and RSVP bar status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:57:30 +01:00
5dd7cb3fb8 Add animated glow border to RSVP CTA button
Wrap the "I'm attending" button with animated glow-border and
glass-inner styling. Update test selectors for new structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:51:21 +01:00
64816558c1 Apply glass utility class to form fields and buttons
Use .glass class on form fields and buttons on gradient backgrounds.
Buttons get gradient glow border via background-clip trick. Solid
white fallback preserved for BottomSheet context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:47:57 +01:00
019ead7be3 Extract glass system into shared CSS utilities and design tokens
Centralize all hardcoded rgba color values into CSS custom properties
and extract glass/glow styles into reusable utility classes (.glass,
.glass-inner, .glow-border, .glow-border--animated) in main.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:35:36 +01:00
29974704d0 Apply glassmorphism to meta icon boxes on event detail view
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:22:39 +01:00
877c869a22 Restyle FAB with glass effect and static glow border
Replace solid orange FAB with glassmorphism inner and a conic
gradient border (pink-purple-indigo) with subtle glow halo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:20:50 +01:00
a9743025a7 Fix hero image transition on event detail page
Replace hard-edged color overlay with CSS mask-image fade-out and
increase hero height to 420px for a seamless blend into the aurora
mesh background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:13:57 +01:00
9f82275c63 Replace linear gradient background with aurora mesh gradient
Use layered radial gradients on a dark base (#1B1730) with
backdrop blur for an organic, aurora-like background effect
that better complements the glassmorphism event cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:01:46 +01:00
e203ecf687 Apply glassmorphism styling to event cards on list view
Replace solid white event cards with glass-effect cards featuring
backdrop blur, semi-transparent gradient backgrounds, and light
borders that blend with the Electric Dusk gradient background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:50:20 +01:00
aa3ea04bfc Merge pull request 'Update dependency vue to v3.5.30' (#21) from renovate/vue-monorepo into master
All checks were successful
CI / backend-test (push) Successful in 57s
CI / frontend-test (push) Successful in 24s
CI / frontend-e2e (push) Successful in 1m13s
CI / build-and-publish (push) Has been skipped
Reviewed-on: #21
2026-03-09 17:25:42 +01:00
Renovate Bot
27ca8ab4b8 Update dependency vue to v3.5.30
All checks were successful
CI / backend-test (push) Successful in 1m2s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Has been skipped
2026-03-09 11:17:01 +00:00
752d153cd4 Merge pull request 'Add organizer-only attendee list (011)' (#20) from 011-view-attendee-list into master
All checks were successful
CI / backend-test (push) Successful in 2m5s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m10s
CI / build-and-publish (push) Successful in 1m2s
2026-03-08 18:37:47 +01:00
763811fce6 Add organizer-only attendee list to event detail view (011)
All checks were successful
CI / backend-test (push) Successful in 59s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m11s
CI / build-and-publish (push) Has been skipped
New GET /events/{token}/attendees endpoint returns attendee names when
a valid organizer token is provided (403 otherwise). The frontend
conditionally renders the list below the attendee count for organizers,
silently degrading for visitors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:34:27 +01:00
39 changed files with 1706 additions and 136 deletions

View File

@@ -0,0 +1,37 @@
# Modern UI Effects Research (2025-2026)
## Liquid Glass (Apple WWDC 2025)
Evolved glassmorphism with directional lighting. Three-layer approach: highlight, shadow, illumination.
- `backdrop-filter: blur(20px) saturate(1.5)` — higher saturation than basic glass
- `inset 0 1px 0 rgba(255,255,255,0.15)` — top highlight (light direction)
- `inset 0 -1px 0 rgba(0,0,0,0.1)` — bottom shadow
- Outer drop shadow for depth: `0 8px 32px rgba(0,0,0,0.3)`
- Advanced: SVG `feTurbulence` + `feSpecularLighting` for refraction (Chromium only)
- Browser support: `backdrop-filter` ~88%, Firefox since v103
## Aurora / Gradient Mesh Backgrounds
Stacked animated radial gradients simulating northern lights. Pairs well with glass cards on dark backgrounds.
- Multiple `radial-gradient(ellipse ...)` layers with partial opacity
- Animated via `background-position` shift (GPU-friendly)
- `@property` rule enables direct gradient color animation (broad support since 2024)
- Best for ambient background movement, not for content areas
## Animated Glow Borders
Rotating `conic-gradient` borders with blur halo. Striking on dark backgrounds.
- Outer wrapper with `conic-gradient(from var(--angle), color1, color2, color3, color1)`
- `::before` pseudo with `filter: blur(12px)` and `opacity: 0.5` for glow halo
- `@property --angle` trick to animate custom property inside `conic-gradient`
- Use sparingly — best for single highlight elements (FAB, CTA), not all cards
## Modern Neumorphism (2025-2026 revision)
Subtler than the original trend. Higher contrast, less extreme extrusion, combined with accent colors.
- Light and dark shadow pair: `6px 6px 12px rgba(0,0,0,0.5)` + `-6px -6px 12px rgba(60,50,80,0.15)`
- `border: 1px solid rgba(255,255,255,0.05)` for definition
- Works on dark backgrounds with slightly lighter "uplift" shadow direction
- Better suited for interactive elements (buttons, toggles) than content cards
## Sources
- Apple Liquid Glass CSS: dev.to/gruszdev, dev.to/kevinbism, css-tricks.com, kube.io
- Aurora: dev.to/oobleck, daltonwalsh.com, github.com/mattnewdavid
- Glow borders: frontendmasters.com (Kevin Powell), docode.co.in
- Trends overview: medium.com/design-bootcamp, index.dev, bighuman.com

View File

@@ -1,25 +1,30 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.Attendee;
import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse; import de.fete.adapter.in.web.model.CreateEventResponse;
import de.fete.adapter.in.web.model.CreateRsvpRequest; import de.fete.adapter.in.web.model.CreateRsvpRequest;
import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.in.web.model.CreateRsvpResponse;
import de.fete.adapter.in.web.model.GetAttendeesResponse;
import de.fete.adapter.in.web.model.GetEventResponse; import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException; import de.fete.application.service.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock; import java.time.Clock;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -33,6 +38,7 @@ public class EventController implements EventsApi {
private final GetEventUseCase getEventUseCase; private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase; private final CreateRsvpUseCase createRsvpUseCase;
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
private final GetAttendeesUseCase getAttendeesUseCase;
private final Clock clock; private final Clock clock;
/** Creates a new controller with the given use cases and clock. */ /** Creates a new controller with the given use cases and clock. */
@@ -41,11 +47,13 @@ public class EventController implements EventsApi {
GetEventUseCase getEventUseCase, GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase, CreateRsvpUseCase createRsvpUseCase,
CountAttendeesByEventUseCase countAttendeesByEventUseCase, CountAttendeesByEventUseCase countAttendeesByEventUseCase,
GetAttendeesUseCase getAttendeesUseCase,
Clock clock) { Clock clock) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase; this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase; this.createRsvpUseCase = createRsvpUseCase;
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
this.getAttendeesUseCase = getAttendeesUseCase;
this.clock = clock; this.clock = clock;
} }
@@ -97,6 +105,25 @@ public class EventController implements EventsApi {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Override
public ResponseEntity<GetAttendeesResponse> getAttendees(
UUID token, UUID organizerToken) {
var eventToken = new EventToken(token);
var orgToken = new OrganizerToken(organizerToken);
List<String> names = getAttendeesUseCase
.getAttendeeNames(eventToken, orgToken);
var attendees = names.stream()
.map(name -> new Attendee().name(name))
.toList();
var response = new GetAttendeesResponse();
response.setAttendees(attendees);
return ResponseEntity.ok(response);
}
@Override @Override
public ResponseEntity<CreateRsvpResponse> createRsvp( public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) { UUID token, CreateRsvpRequest createRsvpRequest) {

View File

@@ -4,6 +4,7 @@ import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateBeforeEventException; import de.fete.application.service.ExpiryDateBeforeEventException;
import de.fete.application.service.ExpiryDateInPastException; import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidOrganizerTokenException;
import de.fete.application.service.InvalidTimezoneException; import de.fete.application.service.InvalidTimezoneException;
import java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
@@ -87,6 +88,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail); .body(problemDetail);
} }
/** Handles invalid organizer token. */
@ExceptionHandler(InvalidOrganizerTokenException.class)
public ResponseEntity<ProblemDetail> handleInvalidOrganizerToken(
InvalidOrganizerTokenException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, ex.getMessage());
problemDetail.setTitle("Forbidden");
problemDetail.setType(URI.create("urn:problem-type:invalid-organizer-token"));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles event not found. */ /** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class) @ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound( public ResponseEntity<ProblemDetail> handleEventNotFound(

View File

@@ -11,4 +11,7 @@ public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Counts RSVPs for the given event. */ /** Counts RSVPs for the given event. */
long countByEventId(Long eventId); long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
java.util.List<RsvpJpaEntity> findAllByEventIdOrderByIdAsc(Long eventId);
} }

View File

@@ -3,6 +3,7 @@ package de.fete.adapter.out.persistence;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken; import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.RsvpRepository; import de.fete.domain.port.out.RsvpRepository;
import java.util.List;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the RsvpRepository outbound port. */ /** Persistence adapter implementing the RsvpRepository outbound port. */
@@ -28,6 +29,13 @@ public class RsvpPersistenceAdapter implements RsvpRepository {
return jpaRepository.countByEventId(eventId); return jpaRepository.countByEventId(eventId);
} }
@Override
public List<Rsvp> findByEventId(Long eventId) {
return jpaRepository.findAllByEventIdOrderByIdAsc(eventId).stream()
.map(this::toDomain)
.toList();
}
private RsvpJpaEntity toEntity(Rsvp rsvp) { private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity(); var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId()); entity.setId(rsvp.getId());

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid organizer token is provided. */
public class InvalidOrganizerTokenException extends RuntimeException {
/** Creates a new exception for an invalid organizer token. */
public InvalidOrganizerTokenException() {
super("Invalid organizer token.");
}
}

View File

@@ -2,19 +2,23 @@ package de.fete.application.service;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken; import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.in.CountAttendeesByEventUseCase; import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetAttendeesUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository; import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */ /** Application service implementing RSVP operations. */
@Service @Service
public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseCase { public class RsvpService
implements CreateRsvpUseCase, CountAttendeesByEventUseCase, GetAttendeesUseCase {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository; private final RsvpRepository rsvpRepository;
@@ -53,4 +57,18 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
.orElseThrow(() -> new EventNotFoundException(eventToken.value())); .orElseThrow(() -> new EventNotFoundException(eventToken.value()));
return rsvpRepository.countByEventId(event.getId()); return rsvpRepository.countByEventId(event.getId());
} }
@Override
public List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getOrganizerToken().equals(organizerToken)) {
throw new InvalidOrganizerTokenException();
}
return rsvpRepository.findByEventId(event.getId()).stream()
.map(Rsvp::getName)
.toList();
}
} }

View File

@@ -0,0 +1,12 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import java.util.List;
/** Inbound port for retrieving attendee names of an event. */
public interface GetAttendeesUseCase {
/** Returns attendee names ordered by RSVP submission time. */
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
}

View File

@@ -1,6 +1,7 @@
package de.fete.domain.port.out; package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import java.util.List;
/** Outbound port for persisting and querying RSVPs. */ /** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository { public interface RsvpRepository {
@@ -10,4 +11,7 @@ public interface RsvpRepository {
/** Counts the number of RSVPs for the given event. */ /** Counts the number of RSVPs for the given event. */
long countByEventId(Long eventId); long countByEventId(Long eventId);
/** Finds all RSVPs for the given event, ordered by ID ascending. */
List<Rsvp> findByEventId(Long eventId);
} }

View File

@@ -83,6 +83,47 @@ paths:
schema: schema:
$ref: "#/components/schemas/ProblemDetail" $ref: "#/components/schemas/ProblemDetail"
/events/{token}/attendees:
get:
operationId: getAttendees
summary: Get attendee list for an event (organizer only)
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
- name: organizerToken
in: query
required: true
schema:
type: string
format: uuid
description: Organizer token for authorization
responses:
"200":
description: Attendee list
content:
application/json:
schema:
$ref: "#/components/schemas/GetAttendeesResponse"
"403":
description: Invalid organizer token
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}: /events/{token}:
get: get:
operationId: getEvent operationId: getEvent
@@ -256,6 +297,30 @@ components:
description: Guest's display name as stored description: Guest's display name as stored
example: "Max Mustermann" example: "Max Mustermann"
GetAttendeesResponse:
type: object
required:
- attendees
properties:
attendees:
type: array
items:
$ref: "#/components/schemas/Attendee"
example:
- name: "Alice"
- name: "Bob"
Attendee:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
example: "Alice"
ProblemDetail: ProblemDetail:
type: object type: object
properties: properties:

View File

@@ -439,6 +439,68 @@ class EventControllerIntegrationTest {
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
} }
// --- GET /events/{token}/attendees tests ---
@Test
void getAttendeesReturnsNamesForOrganizer() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
seedRsvp(event, "Alice");
seedRsvp(event, "Bob");
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(2))
.andExpect(jsonPath("$.attendees[0].name").value("Alice"))
.andExpect(jsonPath("$.attendees[1].name").value("Bob"));
}
@Test
void getAttendeesReturnsEmptyListWhenNoRsvps() throws Exception {
EventJpaEntity event = seedEvent(
"Empty Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + event.getOrganizerToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attendees").isArray())
.andExpect(jsonPath("$.attendees.length()").value(0));
}
@Test
void getAttendeesReturns403ForInvalidOrganizerToken() throws Exception {
EventJpaEntity event = seedEvent(
"Secret Party", null, "Europe/Berlin", null,
LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + event.getEventToken()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isForbidden())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
@Test
void getAttendeesReturns404ForUnknownEvent() throws Exception {
mockMvc.perform(get("/api/events/" + UUID.randomUUID()
+ "/attendees?organizerToken=" + UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
"application/problem+json"));
}
private void seedRsvp(EventJpaEntity event, String name) {
var rsvp = new RsvpJpaEntity();
rsvp.setRsvpToken(UUID.randomUUID());
rsvp.setEventId(event.getId());
rsvp.setName(name);
rsvpJpaRepository.save(rsvp);
}
private EventJpaEntity seedEvent( private EventJpaEntity seedEvent(
String title, String description, String timezone, String title, String description, String timezone,
String location, LocalDate expiryDate) { String location, LocalDate expiryDate) {

View File

@@ -10,6 +10,7 @@ import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken; import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken; import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp; import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository; import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock; import java.time.Clock;
@@ -18,6 +19,7 @@ import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -122,6 +124,73 @@ class RsvpServiceTest {
.isInstanceOf(EventExpiredException.class); .isInstanceOf(EventExpiredException.class);
} }
@Test
void getAttendeeNamesReturnsNamesInOrder() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of(
buildRsvp(1L, "Alice"),
buildRsvp(2L, "Bob"),
buildRsvp(3L, "Charlie")));
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
assertThat(names).containsExactly("Alice", "Bob", "Charlie");
}
@Test
void getAttendeeNamesReturnsEmptyListWhenNoRsvps() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken orgToken = event.getOrganizerToken();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
when(rsvpRepository.findByEventId(event.getId()))
.thenReturn(List.of());
List<String> names = rsvpService.getAttendeeNames(token, orgToken);
assertThat(names).isEmpty();
}
@Test
void getAttendeeNamesThrowsWhenEventNotFound() {
EventToken token = EventToken.generate();
OrganizerToken orgToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty());
assertThatThrownBy(
() -> rsvpService.getAttendeeNames(token, orgToken))
.isInstanceOf(EventNotFoundException.class);
}
@Test
void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() {
Event event = buildActiveEvent();
EventToken token = event.getEventToken();
OrganizerToken wrongToken = OrganizerToken.generate();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
assertThatThrownBy(
() -> rsvpService.getAttendeeNames(token, wrongToken))
.isInstanceOf(InvalidOrganizerTokenException.class);
}
private Rsvp buildRsvp(Long id, String name) {
var rsvp = new Rsvp();
rsvp.setId(id);
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(1L);
rsvp.setName(name);
return rsvp;
}
private Event buildActiveEvent() { private Event buildActiveEvent() {
var event = new Event(); var event = new Event();
event.setId(1L); event.setId(1L);

View File

@@ -0,0 +1,99 @@
import { http, HttpResponse } from 'msw'
import { test, expect } from './msw-setup'
const eventToken = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const organizerToken = 'f9e8d7c6-b5a4-3210-fedc-ba9876543210'
const fullEvent = {
eventToken,
title: 'Summer BBQ',
description: 'Bring your own drinks!',
dateTime: '2026-03-15T20:00:00+01:00',
timezone: 'Europe/Berlin',
location: 'Central Park, NYC',
attendeeCount: 3,
expired: false,
}
const attendeesResponse = {
attendees: [
{ name: 'Alice' },
{ name: 'Bob' },
{ name: 'Charlie' },
],
}
test.describe('US-1: View attendee list as organizer', () => {
test('organizer sees attendee names', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
http.get('*/api/events/:token/attendees', () => {
return HttpResponse.json(attendeesResponse)
}),
)
// Set organizer token in localStorage before navigating
await page.goto('/')
await page.evaluate(
([et, ot]) => {
localStorage.setItem(
'fete:events',
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
)
},
[eventToken, organizerToken],
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('3 Attendees')).toBeVisible()
await expect(page.getByText('Alice')).toBeVisible()
await expect(page.getByText('Bob')).toBeVisible()
await expect(page.getByText('Charlie')).toBeVisible()
})
test('visitor does not see attendee list', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json(fullEvent)
}),
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('3 going')).toBeVisible()
await expect(page.locator('.attendee-list')).not.toBeVisible()
})
test('organizer sees empty state when no attendees', async ({ page, network }) => {
network.use(
http.get('*/api/events/:token', () => {
return HttpResponse.json({ ...fullEvent, attendeeCount: 0 })
}),
http.get('*/api/events/:token/attendees', () => {
return HttpResponse.json({ attendees: [] })
}),
)
await page.goto('/')
await page.evaluate(
([et, ot]) => {
localStorage.setItem(
'fete:events',
JSON.stringify([{ eventToken: et, organizerToken: ot, title: 'Summer BBQ', dateTime: '2026-03-15T20:00:00+01:00', expiryDate: '' }]),
)
},
[eventToken, organizerToken],
)
await page.goto(`/events/${eventToken}`)
await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible()
await expect(page.getByText('0 Attendees')).toBeVisible()
await expect(page.getByText('No attendees yet.')).toBeVisible()
})
})

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<title>fete</title> <title>fete</title>
</head> </head>
<body> <body>

View File

@@ -3204,13 +3204,13 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
@@ -3229,40 +3229,40 @@
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.29", "@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29", "@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.29", "@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.6", "postcss": "^8.5.8",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@@ -3362,53 +3362,53 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.29", "@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.29", "@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.29", "@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.29", "@vue/shared": "3.5.30",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.29", "@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.29" "vue": "3.5.30"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/test-utils": { "node_modules/@vue/test-utils": {
@@ -7319,16 +7319,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.29", "version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.29", "@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.29", "@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.29" "@vue/shared": "3.5.30"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text y="0.9em" font-size="80" x="50%" text-anchor="middle">🎉</text>
</svg>

After

Width:  |  Height:  |  Size: 144 B

View File

@@ -16,6 +16,26 @@
--color-text-on-gradient: #ffffff; --color-text-on-gradient: #ffffff;
--color-surface: #fff5f8; --color-surface: #fff5f8;
--color-card: #ffffff; --color-card: #ffffff;
--color-dark-base: #1B1730;
/* Glass system */
--color-glass: rgba(255, 255, 255, 0.1);
--color-glass-strong: rgba(255, 255, 255, 0.15);
--color-glass-subtle: rgba(255, 255, 255, 0.05);
--color-glass-border: rgba(255, 255, 255, 0.18);
--color-glass-border-hover: rgba(255, 255, 255, 0.3);
--color-glass-hover: rgba(255, 255, 255, 0.18);
--color-glass-inner: rgba(27, 23, 48, 0.55);
--color-glass-overlay: rgba(27, 23, 48, 0.4);
/* Text on gradient (opacity variants) */
--color-text-muted: rgba(255, 255, 255, 0.5);
--color-text-secondary: rgba(255, 255, 255, 0.7);
--color-text-soft: rgba(255, 255, 255, 0.85);
--color-text-bright: rgba(255, 255, 255, 0.9);
/* Glow border */
--gradient-glow: conic-gradient(from 135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
/* Gradient */ /* Gradient */
--gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%); --gradient-primary: linear-gradient(135deg, #f06292 0%, #ab47bc 50%, #5c6bc0 100%);
@@ -33,7 +53,7 @@
--radius-button: 14px; --radius-button: 14px;
/* Shadows */ /* Shadows */
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.12);
--shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15); --shadow-button: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Layout */ /* Layout */
@@ -60,7 +80,22 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
background: var(--gradient-primary); background-color: var(--color-dark-base);
position: relative;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-color: var(--color-dark-base);
background-image:
radial-gradient(at 70% 20%, rgba(240, 98, 146, 0.55) 0px, transparent 50%),
radial-gradient(at 25% 50%, rgba(171, 71, 188, 0.5) 0px, transparent 55%),
radial-gradient(at 80% 70%, rgba(92, 107, 192, 0.55) 0px, transparent 50%),
radial-gradient(at 35% 85%, rgba(255, 112, 67, 0.3) 0px, transparent 40%);
filter: blur(80px);
z-index: -1;
} }
#app { #app {
@@ -82,28 +117,35 @@ body {
/* Card-style form fields */ /* Card-style form fields */
.form-field { .form-field {
background: var(--color-card); background: var(--color-card);
border: none; border: 1px solid #e0e0e0;
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-md); padding: var(--spacing-md) var(--spacing-md);
box-shadow: var(--shadow-card);
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 400; font-weight: 400;
color: var(--color-text); color: var(--color-text);
outline: none; outline: none;
transition: box-shadow 0.2s ease; transition: border-color 0.2s ease;
}
.form-field.glass {
color: var(--color-text-on-gradient);
} }
.form-field:focus { .form-field:focus {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); border-color: var(--color-glass-border-hover);
} }
.form-field::placeholder { .form-field::placeholder {
color: #999; color: var(--color-text-muted);
font-weight: 400; font-weight: 400;
} }
.form-field.glass::placeholder {
color: var(--color-text-muted);
}
textarea.form-field { textarea.form-field {
resize: vertical; resize: vertical;
min-height: 5rem; min-height: 5rem;
@@ -128,22 +170,29 @@ textarea.form-field {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-accent); background: var(--color-card);
color: var(--color-text); color: var(--color-text);
border: none; border: 1px solid #e0e0e0;
border-radius: var(--radius-button); border-radius: var(--radius-button);
font-family: inherit; font-family: inherit;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
box-shadow: var(--shadow-button); transition: border-color 0.2s ease, transform 0.1s ease;
transition: opacity 0.2s ease, transform 0.1s ease;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
.btn-primary.glass {
color: var(--color-text-on-gradient);
border: 2px solid transparent;
background:
linear-gradient(var(--color-glass-inner), var(--color-glass-inner)) padding-box,
var(--gradient-glow) border-box;
}
.btn-primary:hover { .btn-primary:hover {
opacity: 0.92; border-color: var(--color-glass-border-hover);
} }
.btn-primary:active { .btn-primary:active {
@@ -176,6 +225,68 @@ textarea.form-field {
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }
/* ── Glass System ── */
/* Glass surface: passive containers on gradient (cards, icon boxes) */
.glass {
background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glass:hover:not(input):not(textarea):not(.btn-primary) {
background: var(--color-glass-hover);
border-color: var(--color-glass-border-hover);
}
/* Glass interactive inner: dark translucent fill for interactive elements (FAB, CTA) */
.glass-inner {
background: var(--color-glass-inner);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Glow border: conic gradient wrapper with halo (static) */
.glow-border {
background: var(--gradient-glow);
padding: 2px;
position: relative;
}
.glow-border::before {
content: '';
position: absolute;
inset: -4px;
border-radius: inherit;
background: var(--gradient-glow);
filter: blur(8px);
opacity: 0.3;
z-index: -1;
}
/* Glow border animated variant */
@property --glow-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.glow-border--animated {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
.glow-border--animated::before {
background: conic-gradient(from var(--glow-angle), var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end), var(--color-gradient-start));
animation: glow-rotate 4s linear infinite;
}
@keyframes glow-rotate {
to { --glow-angle: 360deg; }
}
/* Utility */ /* Utility */
.text-center { .text-center {
text-align: center; text-align: center;
@@ -197,7 +308,7 @@ textarea.form-field {
.sheet-title { .sheet-title {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text-on-gradient);
} }
.rsvp-form { .rsvp-form {
@@ -209,7 +320,7 @@ textarea.form-field {
.rsvp-form__label { .rsvp-form__label {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text-on-gradient);
padding-left: 0.25rem; padding-left: 0.25rem;
} }

View File

@@ -0,0 +1,59 @@
<template>
<section class="attendee-list">
<h3 class="attendee-list__heading">
{{ attendees.length === 1 ? '1 Attendee' : `${attendees.length} Attendees` }}
</h3>
<ul v-if="attendees.length > 0" class="attendee-list__items">
<li v-for="(name, index) in attendees" :key="index" class="attendee-list__item">
{{ name }}
</li>
</ul>
<p v-else class="attendee-list__empty">No attendees yet.</p>
</section>
</template>
<script setup lang="ts">
defineProps<{
attendees: string[]
}>()
</script>
<style scoped>
.attendee-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.attendee-list__heading {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.attendee-list__items {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.attendee-list__item {
font-size: 0.95rem;
color: var(--color-text-soft);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attendee-list__empty {
font-size: 0.9rem;
color: var(--color-text-muted);
font-style: italic;
}
</style>

View File

@@ -45,7 +45,7 @@ watch(
.sheet-backdrop { .sheet-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.4); background: var(--color-glass-overlay);
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
@@ -53,7 +53,11 @@ watch(
} }
.sheet { .sheet {
background: var(--color-card); background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
border-bottom: none;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl); padding: var(--spacing-lg) var(--spacing-xl) var(--spacing-2xl);
width: 100%; width: 100%;
@@ -67,7 +71,7 @@ watch(
.sheet__handle { .sheet__handle {
width: 36px; width: 36px;
height: 4px; height: 4px;
background: #ccc; background: var(--color-glass-border-hover);
border-radius: 2px; border-radius: 2px;
align-self: center; align-self: center;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -75,7 +75,7 @@ watch(
.confirm-dialog__overlay { .confirm-dialog__overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.4); background: var(--color-glass-overlay);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -84,9 +84,12 @@ watch(
} }
.confirm-dialog { .confirm-dialog {
background: var(--color-card); background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
padding: var(--spacing-xl); padding: var(--spacing-xl);
max-width: 320px; max-width: 320px;
width: 100%; width: 100%;
@@ -98,13 +101,13 @@ watch(
.confirm-dialog__title { .confirm-dialog__title {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text-on-gradient);
} }
.confirm-dialog__message { .confirm-dialog__message {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400; font-weight: 400;
color: #666; color: var(--color-text-soft);
} }
.confirm-dialog__actions { .confirm-dialog__actions {
@@ -130,8 +133,9 @@ watch(
} }
.confirm-dialog__btn--cancel { .confirm-dialog__btn--cancel {
background: #e8e8e8; background: var(--color-glass);
color: #555; border: 1px solid var(--color-glass-border);
color: var(--color-text-on-gradient);
} }
.confirm-dialog__btn--confirm { .confirm-dialog__btn--confirm {

View File

@@ -1,6 +1,8 @@
<template> <template>
<RouterLink to="/create" class="fab" aria-label="Create event"> <RouterLink to="/create" class="fab glow-border" aria-label="Create event">
<span class="fab__icon" aria-hidden="true">+</span> <span class="fab__inner glass-inner">
<span class="fab__icon" aria-hidden="true">+</span>
</span>
</RouterLink> </RouterLink>
</template> </template>
@@ -16,20 +18,26 @@ import { RouterLink } from 'vue-router'
width: 56px; width: 56px;
height: 56px; height: 56px;
border-radius: 50%; border-radius: 50%;
background: var(--color-accent); color: var(--color-text-on-gradient);
color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
text-decoration: none; text-decoration: none;
z-index: 100; z-index: 100;
transition: transform 0.15s ease, box-shadow 0.15s ease; transition: transform 0.15s ease;
}
.fab__inner {
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
} }
.fab:hover { .fab:hover {
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
} }
.fab:active { .fab:active {
@@ -41,6 +49,7 @@ import { RouterLink } from 'vue-router'
outline-offset: 3px; outline-offset: 3px;
} }
.fab__icon { .fab__icon {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 300; font-weight: 300;

View File

@@ -12,7 +12,7 @@ defineProps<{
.date-subheader { .date-subheader {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 500; font-weight: 500;
color: rgba(255, 255, 255, 0.85); color: var(--color-text-soft);
margin: 0; margin: 0;
padding: var(--spacing-xs) 0; padding: var(--spacing-xs) 0;
} }

View File

@@ -1,7 +1,9 @@
<template> <template>
<div class="empty-state"> <div class="empty-state">
<p class="empty-state__message">No events yet.<br />Create your first one!</p> <p class="empty-state__message">No events yet.<br />Create your first one!</p>
<RouterLink to="/create" class="btn-primary empty-state__cta">+ Create Event</RouterLink> <RouterLink to="/create" class="empty-state__cta glow-border glow-border--animated">
<span class="empty-state__cta-inner glass-inner">Create Event</span>
</RouterLink>
</div> </div>
</template> </template>
@@ -27,5 +29,34 @@ import { RouterLink } from 'vue-router'
.empty-state__cta { .empty-state__cta {
max-width: 280px; max-width: 280px;
width: 100%;
border-radius: var(--radius-button);
text-decoration: none;
transition: transform 0.1s ease;
}
.empty-state__cta-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
}
.empty-state__cta:hover {
transform: scale(1.02);
}
.empty-state__cta:active {
transform: scale(0.98);
}
.empty-state__cta:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="event-card" class="event-card glass"
:class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }" :class="{ 'event-card--past': isPast, 'event-card--swiping': isSwiping }"
:style="swipeStyle" :style="swipeStyle"
@touchstart="onTouchStart" @touchstart="onTouchStart"
@@ -93,11 +93,10 @@ function onTouchEnd() {
.event-card { .event-card {
display: flex; display: flex;
align-items: center; align-items: center;
background: var(--color-card);
border-radius: var(--radius-card); border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
gap: var(--spacing-sm); gap: var(--spacing-sm);
transition: background 0.2s ease, border-color 0.2s ease;
} }
.event-card--past { .event-card--past {
@@ -122,7 +121,7 @@ function onTouchEnd() {
.event-card__title { .event-card__title {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text-on-gradient);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -131,7 +130,7 @@ function onTouchEnd() {
.event-card__time { .event-card__time {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 400; font-weight: 400;
color: #888; color: var(--color-text-secondary);
} }
.event-card__badge { .event-card__badge {
@@ -145,12 +144,12 @@ function onTouchEnd() {
.event-card__badge--organizer { .event-card__badge--organizer {
background: var(--color-accent); background: var(--color-accent);
color: #fff; color: var(--color-text-on-gradient);
} }
.event-card__badge--attendee { .event-card__badge--attendee {
background: #e0e0e0; background: var(--color-glass-strong);
color: #555; color: var(--color-text-bright);
} }
.event-card__delete { .event-card__delete {
@@ -163,7 +162,7 @@ function onTouchEnd() {
background: none; background: none;
border: none; border: none;
font-size: 1.2rem; font-size: 1.2rem;
color: #bbb; color: var(--color-text-muted);
cursor: pointer; cursor: pointer;
border-radius: 50%; border-radius: 50%;
transition: color 0.15s ease, background 0.15s ease; transition: color 0.15s ease, background 0.15s ease;

View File

@@ -8,9 +8,11 @@
</div> </div>
<!-- CTA state: no RSVP yet --> <!-- CTA state: no RSVP yet -->
<button v-else class="btn-primary rsvp-bar__cta" type="button" @click="$emit('open')"> <div v-else class="rsvp-bar__cta glow-border glow-border--animated">
I'm attending <button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
</button> I'm attending
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -45,6 +47,30 @@ defineEmits<{
.rsvp-bar__cta { .rsvp-bar__cta {
width: 100%; width: 100%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-bar__cta:hover {
transform: scale(1.02);
}
.rsvp-bar__cta:active {
transform: scale(0.98);
}
.rsvp-bar__cta-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
} }
.rsvp-bar__status { .rsvp-bar__status {
@@ -52,13 +78,16 @@ defineEmits<{
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
background: var(--color-card); background: linear-gradient(135deg, var(--color-glass-strong) 0%, var(--color-glass-subtle) 100%);
border: 1px solid var(--color-glass-border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--spacing-md) var(--spacing-lg); padding: var(--spacing-md) var(--spacing-lg);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text); color: var(--color-text-on-gradient);
} }
.rsvp-bar__check { .rsvp-bar__check {

View File

@@ -15,7 +15,7 @@ defineProps<{
.section-header { .section-header {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text-on-gradient);
margin: 0; margin: 0;
padding: var(--spacing-sm) 0; padding: var(--spacing-sm) 0;
} }

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AttendeeList from '../AttendeeList.vue'
describe('AttendeeList', () => {
it('renders attendee names as list items', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice', 'Bob', 'Charlie'] },
})
const items = wrapper.findAll('.attendee-list__item')
expect(items).toHaveLength(3)
expect(items[0]!.text()).toBe('Alice')
expect(items[1]!.text()).toBe('Bob')
expect(items[2]!.text()).toBe('Charlie')
})
it('shows empty state message when no attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: [] },
})
expect(wrapper.find('.attendee-list__empty').text()).toBe('No attendees yet.')
expect(wrapper.find('.attendee-list__items').exists()).toBe(false)
})
it('shows plural count heading for multiple attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('5 Attendees')
})
it('shows singular count heading for one attendee', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: ['Alice'] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('1 Attendee')
})
it('shows zero count heading for no attendees', () => {
const wrapper = mount(AttendeeList, {
props: { attendees: [] },
})
expect(wrapper.find('.attendee-list__heading').text()).toBe('0 Attendees')
})
})

View File

@@ -6,7 +6,7 @@ describe('RsvpBar', () => {
it('renders CTA button when hasRsvp is false', () => { it('renders CTA button when hasRsvp is false', () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true) expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending") expect(wrapper.find('.rsvp-bar__cta-inner').text()).toBe("I'm attending")
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false) expect(wrapper.find('.rsvp-bar__status').exists()).toBe(false)
}) })
@@ -19,7 +19,7 @@ describe('RsvpBar', () => {
it('emits open when CTA button is clicked', async () => { it('emits open when CTA button is clicked', async () => {
const wrapper = mount(RsvpBar) const wrapper = mount(RsvpBar)
await wrapper.find('.rsvp-bar__cta').trigger('click') await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
expect(wrapper.emitted('open')).toHaveLength(1) expect(wrapper.emitted('open')).toHaveLength(1)
}) })

View File

@@ -12,7 +12,7 @@
id="title" id="title"
v-model="form.title" v-model="form.title"
type="text" type="text"
class="form-field" class="form-field glass"
required required
maxlength="200" maxlength="200"
placeholder="What's the event?" placeholder="What's the event?"
@@ -27,7 +27,7 @@
<textarea <textarea
id="description" id="description"
v-model="form.description" v-model="form.description"
class="form-field" class="form-field glass"
maxlength="2000" maxlength="2000"
placeholder="Tell people more about it…" placeholder="Tell people more about it…"
:aria-invalid="!!errors.description" :aria-invalid="!!errors.description"
@@ -42,7 +42,7 @@
id="dateTime" id="dateTime"
v-model="form.dateTime" v-model="form.dateTime"
type="datetime-local" type="datetime-local"
class="form-field" class="form-field glass"
required required
:aria-invalid="!!errors.dateTime" :aria-invalid="!!errors.dateTime"
:aria-describedby="errors.dateTime ? 'dateTime-error' : undefined" :aria-describedby="errors.dateTime ? 'dateTime-error' : undefined"
@@ -56,7 +56,7 @@
id="location" id="location"
v-model="form.location" v-model="form.location"
type="text" type="text"
class="form-field" class="form-field glass"
maxlength="500" maxlength="500"
placeholder="Where is it?" placeholder="Where is it?"
:aria-invalid="!!errors.location" :aria-invalid="!!errors.location"
@@ -71,7 +71,7 @@
id="expiryDate" id="expiryDate"
v-model="form.expiryDate" v-model="form.expiryDate"
type="date" type="date"
class="form-field" class="form-field glass"
required required
:min="tomorrow" :min="tomorrow"
:aria-invalid="!!errors.expiryDate" :aria-invalid="!!errors.expiryDate"
@@ -80,7 +80,7 @@
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span> <span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
</div> </div>
<button type="submit" class="btn-primary" :disabled="submitting"> <button type="submit" class="btn-primary glass" :disabled="submitting">
{{ submitting ? 'Creating…' : 'Create Event' }} {{ submitting ? 'Creating…' : 'Create Event' }}
</button> </button>

View File

@@ -33,27 +33,29 @@
<dl class="detail__meta"> <dl class="detail__meta">
<div class="detail__meta-item"> <div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Date and time"> <dt class="detail__meta-icon glass" aria-label="Date and time">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</dt> </dt>
<dd class="detail__meta-text">{{ formattedDateTime }}</dd> <dd class="detail__meta-text">{{ formattedDateTime }}</dd>
</div> </div>
<div v-if="event.location" class="detail__meta-item"> <div v-if="event.location" class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Location"> <dt class="detail__meta-icon glass" aria-label="Location">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</dt> </dt>
<dd class="detail__meta-text">{{ event.location }}</dd> <dd class="detail__meta-text">{{ event.location }}</dd>
</div> </div>
<div class="detail__meta-item"> <div class="detail__meta-item">
<dt class="detail__meta-icon" aria-label="Attendees"> <dt class="detail__meta-icon glass" aria-label="Attendees">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</dt> </dt>
<dd class="detail__meta-text">{{ event.attendeeCount }} going</dd> <dd class="detail__meta-text">{{ event.attendeeCount }} going</dd>
</div> </div>
</dl> </dl>
<AttendeeList v-if="isOrganizer && attendeeNames !== null" :attendees="attendeeNames" />
<div v-if="event.description" class="detail__section"> <div v-if="event.description" class="detail__section">
<h2 class="detail__section-title">About</h2> <h2 class="detail__section-title">About</h2>
<p class="detail__description">{{ event.description }}</p> <p class="detail__description">{{ event.description }}</p>
@@ -68,7 +70,7 @@
<!-- Error state --> <!-- Error state -->
<div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert"> <div v-else-if="state === 'error'" class="detail__content detail__content--center" role="alert">
<p class="detail__message">Something went wrong.</p> <p class="detail__message">Something went wrong.</p>
<button class="btn-primary" type="button" @click="fetchEvent">Retry</button> <button class="btn-primary glass" type="button" @click="fetchEvent">Retry</button>
</div> </div>
</div> </div>
@@ -88,7 +90,7 @@
<input <input
id="rsvp-name" id="rsvp-name"
v-model.trim="nameInput" v-model.trim="nameInput"
class="form-field" class="form-field glass"
type="text" type="text"
placeholder="e.g. Max Mustermann" placeholder="e.g. Max Mustermann"
maxlength="100" maxlength="100"
@@ -97,9 +99,11 @@
/> />
<span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span> <span v-if="nameError" class="rsvp-form__field-error" role="alert">{{ nameError }}</span>
</div> </div>
<button class="btn-primary" type="submit" :disabled="submitting"> <div class="rsvp-form__submit glow-border glow-border--animated">
{{ submitting ? 'Sending…' : "Count me in" }} <button class="rsvp-form__submit-inner glass-inner" type="submit" :disabled="submitting">
</button> {{ submitting ? 'Sending…' : "Count me in" }}
</button>
</div>
<p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p> <p v-if="submitError" class="rsvp-form__field-error rsvp-form__error" role="alert">{{ submitError }}</p>
</form> </form>
</BottomSheet> </BottomSheet>
@@ -111,6 +115,7 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage' import { useEventStorage } from '@/composables/useEventStorage'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue' import BottomSheet from '@/components/BottomSheet.vue'
import RsvpBar from '@/components/RsvpBar.vue' import RsvpBar from '@/components/RsvpBar.vue'
import type { components } from '@/api/schema' import type { components } from '@/api/schema'
@@ -132,6 +137,7 @@ const submitError = ref('')
const submitting = ref(false) const submitting = ref(false)
const rsvpName = ref<string | undefined>(undefined) const rsvpName = ref<string | undefined>(undefined)
const isOrganizer = ref(false) const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null)
const formattedDateTime = computed(() => { const formattedDateTime = computed(() => {
if (!event.value) return '' if (!event.value) return ''
@@ -160,7 +166,13 @@ async function fetchEvent() {
state.value = 'loaded' state.value = 'loaded'
// Check if current user is the organizer // Check if current user is the organizer
isOrganizer.value = !!getOrganizerToken(event.value.eventToken) const orgToken = getOrganizerToken(event.value.eventToken)
isOrganizer.value = !!orgToken
// Fetch attendee list for organizer
if (orgToken) {
fetchAttendees(event.value.eventToken, orgToken)
}
// Restore RSVP status from localStorage // Restore RSVP status from localStorage
const stored = getRsvp(event.value.eventToken) const stored = getRsvp(event.value.eventToken)
@@ -220,6 +232,23 @@ async function submitRsvp() {
} }
} }
async function fetchAttendees(eventToken: string, organizerToken: string) {
try {
const { data, error } = await api.GET('/events/{token}/attendees', {
params: {
path: { token: eventToken },
query: { organizerToken },
},
})
if (!error) {
attendeeNames.value = data!.attendees.map((a) => a.name)
}
} catch {
// Silently degrade — don't show attendee list
}
}
onMounted(fetchEvent) onMounted(fetchEvent)
</script> </script>
@@ -241,15 +270,19 @@ onMounted(fetchEvent)
.detail__hero { .detail__hero {
position: relative; position: relative;
width: 100%; width: 100%;
height: 260px; height: 420px;
overflow: hidden; overflow: visible;
flex-shrink: 0; flex-shrink: 0;
} }
.detail__hero-img { .detail__hero-img {
position: absolute;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
} }
.detail__hero-overlay { .detail__hero-overlay {
@@ -257,9 +290,8 @@ onMounted(fetchEvent)
inset: 0; inset: 0;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(0, 0, 0, 0.4) 0%, var(--color-glass-overlay) 0%,
transparent 50%, transparent 50%
var(--color-gradient-start) 100%
); );
} }
@@ -339,7 +371,6 @@ onMounted(fetchEvent)
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px; border-radius: 10px;
color: var(--color-text-on-gradient); color: var(--color-text-on-gradient);
} }
@@ -360,14 +391,14 @@ onMounted(fetchEvent)
.detail__section-title { .detail__section-title {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
color: rgba(255, 255, 255, 0.5); color: var(--color-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.detail__description { .detail__description {
font-size: 0.95rem; font-size: 0.95rem;
color: rgba(255, 255, 255, 0.85); color: var(--color-text-soft);
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
} }
@@ -382,8 +413,8 @@ onMounted(fetchEvent)
} }
.detail__banner--expired { .detail__banner--expired {
background: rgba(255, 255, 255, 0.12); background: var(--color-glass);
color: rgba(255, 255, 255, 0.8); color: var(--color-text-soft);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -396,7 +427,7 @@ onMounted(fetchEvent)
/* Skeleton shimmer on gradient */ /* Skeleton shimmer on gradient */
.skeleton { .skeleton {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.25) 50%, rgba(255, 255, 255, 0.1) 75%); background: linear-gradient(90deg, var(--color-glass) 25%, var(--color-glass-hover) 50%, var(--color-glass) 75%);
background-size: 200% 100%; background-size: 200% 100%;
} }
@@ -415,4 +446,38 @@ onMounted(fetchEvent)
.skeleton--short { .skeleton--short {
width: 45%; width: 45%;
} }
/* RSVP submit button (glow border wrapper) */
.rsvp-form__submit {
width: 100%;
border-radius: var(--radius-button);
transition: transform 0.1s ease;
}
.rsvp-form__submit:hover {
transform: scale(1.02);
}
.rsvp-form__submit:active {
transform: scale(0.98);
}
.rsvp-form__submit-inner {
display: block;
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border-radius: calc(var(--radius-button) - 2px);
font-family: inherit;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-on-gradient);
text-align: center;
border: none;
cursor: pointer;
}
.rsvp-form__submit-inner:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>

View File

@@ -262,7 +262,7 @@ describe('EventDetailView', () => {
expect(document.body.querySelector('[role="dialog"]')).toBeNull() expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await wrapper.find('.rsvp-bar__cta').trigger('click') await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await flushPromises() await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull() expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
@@ -275,7 +275,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click') await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await flushPromises() await flushPromises()
// Form is inside Teleport — find via document.body // Form is inside Teleport — find via document.body
@@ -300,7 +300,7 @@ describe('EventDetailView', () => {
await flushPromises() await flushPromises()
// Open sheet // Open sheet
await wrapper.find('.rsvp-bar__cta').trigger('click') await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await flushPromises() await flushPromises()
// Fill name via Teleported input // Fill name via Teleported input
@@ -339,6 +339,42 @@ describe('EventDetailView', () => {
wrapper.unmount() wrapper.unmount()
}) })
// Attendee list (organizer)
it('shows attendee list for organizer', async () => {
mockGetOrganizerToken.mockReturnValue('org-token-123')
mockLoadedEvent()
vi.mocked(api.GET)
.mockResolvedValueOnce({
data: fullEvent,
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
.mockResolvedValueOnce({
data: { attendees: [{ name: 'Alice' }, { name: 'Bob' }] },
error: undefined,
response: new Response(null, { status: 200 }),
} as never)
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.attendee-list').exists()).toBe(true)
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).toContain('Bob')
expect(wrapper.find('.attendee-list__heading').text()).toBe('2 Attendees')
wrapper.unmount()
})
it('does not show attendee list for visitor', async () => {
mockLoadedEvent()
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.attendee-list').exists()).toBe(false)
wrapper.unmount()
})
it('shows error when RSVP submission fails', async () => { it('shows error when RSVP submission fails', async () => {
mockLoadedEvent() mockLoadedEvent()
vi.mocked(api.POST).mockResolvedValue({ vi.mocked(api.POST).mockResolvedValue({
@@ -350,7 +386,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken() const wrapper = await mountWithToken()
await flushPromises() await flushPromises()
await wrapper.find('.rsvp-bar__cta').trigger('click') await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await flushPromises() await flushPromises()
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: View Attendee List
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-08
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.

View File

@@ -0,0 +1,136 @@
# API Contract: View Attendee List (011)
**Date**: 2026-03-08
## New Endpoint
### `GET /events/{token}/attendees`
Retrieves the list of attendees for an event. Restricted to the event organizer.
**Path Parameters**:
| Parameter | Type | Description |
|-----------|------|-------------|
| token | string (UUID) | Event token |
**Query Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| organizerToken | string (UUID) | Yes | Organizer token for authorization |
**Responses**:
#### 200 OK
Organizer token is valid. Returns the attendee list.
```json
{
"attendees": [
{ "name": "Alice" },
{ "name": "Bob" },
{ "name": "Charlie" }
]
}
```
#### 200 OK (empty list)
No RSVPs yet.
```json
{
"attendees": []
}
```
#### 403 Forbidden
Organizer token is missing, invalid, or does not match the event.
```json
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "Invalid organizer token."
}
```
#### 404 Not Found
Event token does not exist.
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Event not found."
}
```
## OpenAPI Schema Addition
```yaml
/events/{token}/attendees:
get:
operationId: getAttendees
summary: Get attendee list for an event (organizer only)
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
- name: organizerToken
in: query
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Attendee list
content:
application/json:
schema:
$ref: '#/components/schemas/GetAttendeesResponse'
'403':
description: Invalid organizer token
'404':
description: Event not found
GetAttendeesResponse:
type: object
required:
- attendees
properties:
attendees:
type: array
items:
$ref: '#/components/schemas/Attendee'
example:
- name: "Alice"
- name: "Bob"
Attendee:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
example: "Alice"
```
## Existing Endpoints (unchanged)
- `POST /events` — no changes
- `GET /events/{token}` — no changes (still returns `attendeeCount` publicly)
- `POST /events/{token}/rsvps` — no changes

View File

@@ -0,0 +1,72 @@
# Data Model: View Attendee List (011)
**Date**: 2026-03-08
## Entities
### Rsvp (existing — no schema changes)
The attendee list feature reads from the existing `rsvps` table. No new tables or columns are required.
| Field | Type | Constraints | Notes |
|-------|------|-------------|-------|
| id | BIGSERIAL | PK, auto-increment | Chronological order proxy |
| rsvp_token | UUID | UNIQUE, NOT NULL | Public identifier |
| event_id | BIGINT | FK → events.id, NOT NULL | CASCADE DELETE |
| name | VARCHAR(100) | NOT NULL | Display name shown to organizer |
**Existing indexes**: `idx_rsvps_event_id` (on `event_id`), `idx_rsvps_rsvp_token` (on `rsvp_token`).
### Event (existing — no schema changes)
The `organizer_token` column on the `events` table is used for authorization. The endpoint verifies that the provided organizer token matches the event's stored token.
| Field | Type | Notes |
|-------|------|-------|
| organizer_token | UUID | UNIQUE, NOT NULL — used for attendee list authorization |
## Query Patterns
### Get attendees by event token
```sql
SELECT r.name
FROM rsvps r
JOIN events e ON r.event_id = e.id
WHERE e.event_token = :eventToken
ORDER BY r.id ASC;
```
**Performance**: Uses existing `idx_rsvps_event_id` index. Expected result set is small (spec assumes small-to-medium events, no pagination needed).
### Organizer token verification
```sql
SELECT e.organizer_token
FROM events e
WHERE e.event_token = :eventToken;
```
Already implemented in `EventService.getByEventToken()` — the event entity includes the organizer token. The use case compares the provided token against the stored one.
## Domain Model Changes
### New Outbound Port Method
```java
// RsvpRepository (existing interface)
List<Rsvp> findByEventId(Long eventId); // NEW
```
### New Inbound Port
```java
// GetAttendeesUseCase (new interface)
List<String> getAttendeeNames(EventToken eventToken, OrganizerToken organizerToken);
```
Returns a list of attendee display names. Throws `EventNotFoundException` if event token is invalid. Throws `AccessDeniedException` (or similar) if organizer token does not match.
## No Migration Required
All required data structures already exist from changeset `003-create-rsvps-table.xml`. This feature only adds read access to existing data.

View File

@@ -0,0 +1,101 @@
# Implementation Plan: View Attendee List
**Branch**: `011-view-attendee-list` | **Date**: 2026-03-08 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/011-view-attendee-list/spec.md`
## Summary
Add an organizer-only attendee list to the event detail view. A new `GET /events/{token}/attendees?organizerToken=<uuid>` endpoint returns attendee names when the organizer token is valid (403 otherwise). The frontend conditionally renders the list below the attendee count when the viewer is identified as the organizer via localStorage.
## Technical Context
**Language/Version**: Java 25 (backend), TypeScript 5.9 (frontend)
**Primary Dependencies**: Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
**Storage**: PostgreSQL (JPA via Spring Data, Liquibase migrations)
**Testing**: JUnit + Testcontainers (backend integration), Vitest (frontend unit), Playwright + MSW (E2E)
**Target Platform**: Self-hosted web application (PWA)
**Project Type**: Web application (full-stack)
**Performance Goals**: Attendee list loads within 2 seconds (SC-001)
**Constraints**: Privacy by Design — attendee names only exposed to organizer; no PII logging
**Scale/Scope**: Small-to-medium events; no pagination required (spec assumption)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Privacy by Design | ✅ PASS | Attendee names only exposed via organizer token verification. Non-organizers see count only (FR-003). No analytics/tracking added. |
| II. Test-Driven Methodology | ✅ PASS | Plan follows Research → Spec → Test → Implement. TDD enforced. E2E tests mandatory for both user stories. |
| III. API-First Development | ✅ PASS | New endpoint defined in OpenAPI spec first. Types generated before implementation. Response schemas include `example:` fields. |
| IV. Simplicity & Quality | ✅ PASS | Minimal new code: one endpoint, one use case, one component section. No over-engineering. |
| V. Dependency Discipline | ✅ PASS | No new dependencies introduced. |
| VI. Accessibility | ✅ PASS | Semantic HTML list for attendees. WCAG AA contrast. Keyboard-navigable. |
**Gate result**: ALL PASS — proceed to Phase 0.
## Project Structure
### Documentation (this feature)
```text
specs/011-view-attendee-list/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── api.md # New endpoint contract
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/de/fete/
│ ├── domain/
│ │ ├── model/ # Existing: Event, Rsvp, tokens
│ │ └── port/
│ │ ├── in/
│ │ │ └── GetAttendeesUseCase.java # NEW: inbound port
│ │ └── out/
│ │ └── RsvpRepository.java # MODIFY: add findByEventId
│ ├── application/service/
│ │ └── RsvpService.java # MODIFY: implement GetAttendeesUseCase
│ ├── adapter/
│ │ ├── in/web/
│ │ │ └── EventController.java # MODIFY: add attendees endpoint
│ │ └── out/persistence/
│ │ ├── RsvpJpaRepository.java # MODIFY: add findByEventId query
│ │ └── RsvpPersistenceAdapter.java # MODIFY: implement findByEventId
│ └── src/main/resources/openapi/
│ └── api.yaml # MODIFY: add attendees endpoint + schema
├── src/test/java/de/fete/
│ ├── adapter/in/web/
│ │ └── EventControllerIntegrationTest.java # MODIFY: add attendees tests
│ └── application/service/
│ └── RsvpServiceTest.java # MODIFY: add getAttendees tests
frontend/
├── src/
│ ├── views/
│ │ └── EventDetailView.vue # MODIFY: add attendee list section
│ ├── components/
│ │ └── AttendeeList.vue # NEW: attendee list component
│ ├── api/
│ │ └── schema.d.ts # REGENERATED from OpenAPI
│ └── composables/
│ └── useEventStorage.ts # NO CHANGES (read-only usage)
├── src/views/__tests__/
│ └── EventDetailView.spec.ts # MODIFY: add attendee list tests
├── src/components/__tests__/
│ └── AttendeeList.spec.ts # NEW: unit tests
└── e2e/
└── view-attendee-list.spec.ts # NEW: E2E tests
```
**Structure Decision**: Extends the existing web application structure. Backend follows hexagonal architecture with new inbound port + implementation. Frontend adds one new component integrated into the existing EventDetailView.
## Complexity Tracking
> No constitution violations — section not applicable.

View File

@@ -0,0 +1,76 @@
# Quickstart: View Attendee List (011)
## Prerequisites
- Java 25 (SDKMAN)
- Node.js 20+ / npm
- PostgreSQL running (or Docker for Testcontainers)
## Development Flow
### 1. Update OpenAPI spec
Edit `backend/src/main/resources/openapi/api.yaml` to add the `GET /events/{token}/attendees` endpoint and response schemas (see `contracts/api.md`).
### 2. Generate types
```bash
# Backend: regenerate Spring interfaces
cd backend && ./mvnw compile
# Frontend: regenerate TypeScript types
cd frontend && npm run generate:api
```
### 3. Backend implementation (TDD)
```bash
# Write tests first
cd backend && ./mvnw test
# Run specific test class
cd backend && ./mvnw test -Dtest=EventControllerIntegrationTest
cd backend && ./mvnw test -Dtest=RsvpServiceTest
```
### 4. Frontend implementation (TDD)
```bash
# Unit tests
cd frontend && npm run test:unit
# E2E tests
cd frontend && npx playwright test e2e/view-attendee-list.spec.ts
```
### 5. Verify
```bash
# Backend full verify (includes checkstyle)
cd backend && ./mvnw verify
# Frontend build check
cd frontend && npm run build
```
## Key Files to Modify
| Layer | File | Change |
|-------|------|--------|
| API Spec | `backend/src/main/resources/openapi/api.yaml` | Add endpoint + schemas |
| Port (in) | `de.fete.domain.port.in.GetAttendeesUseCase` | New interface |
| Port (out) | `de.fete.domain.port.out.RsvpRepository` | Add `findByEventId` |
| Service | `de.fete.application.service.RsvpService` | Implement use case |
| Persistence | `de.fete.adapter.out.persistence.RsvpJpaRepository` | Add query method |
| Persistence | `de.fete.adapter.out.persistence.RsvpPersistenceAdapter` | Implement port method |
| Controller | `de.fete.adapter.in.web.EventController` | Add endpoint handler |
| Frontend | `src/views/EventDetailView.vue` | Integrate AttendeeList |
| Frontend | `src/components/AttendeeList.vue` | New component |
## Testing Checklist
- [ ] Backend unit test: `RsvpService.getAttendeeNames` — valid token, invalid token, no RSVPs
- [ ] Backend integration test: `GET /events/{token}/attendees` — 200, 403, 404
- [ ] Frontend unit test: `AttendeeList.vue` — renders names, empty state, loading
- [ ] Frontend unit test: `EventDetailView.vue` — shows list for organizer, hides for visitor
- [ ] E2E test: organizer sees attendee names, visitor sees count only

View File

@@ -0,0 +1,68 @@
# Research: View Attendee List (011)
**Date**: 2026-03-08 | **Status**: Complete
## 1. Organizer Token Verification Pattern
**Decision**: Query parameter `?organizerToken=<uuid>` on the new endpoint.
**Rationale**: The project uses token-based access control without persistent sessions. The organizer token is stored in localStorage on the client. Passing it as a query parameter is the simplest approach that fits the existing architecture. The `GET /events/{token}` endpoint already uses path-based token lookup; adding a query parameter for the organizer token keeps the two concerns separate.
**Alternatives considered**:
- Authorization header: More RESTful, but adds complexity without benefit — no auth framework in place, and query params are simpler for this single use case.
- Embed attendees in existing `GET /events/{token}` response: Rejected per spec clarification — separate endpoint keeps concerns clean and avoids exposing attendee data in the public response.
## 2. Endpoint Design
**Decision**: `GET /events/{token}/attendees?organizerToken=<uuid>` returns `{ attendees: [{ name: string }] }`.
**Rationale**:
- Nested under `/events/{token}` — resource hierarchy is clear.
- Returns an object with an `attendees` array (not a raw array) — allows future extension (e.g., adding metadata) without breaking the contract.
- Each attendee object contains only `name` — minimal data exposure per Privacy by Design.
- HTTP 403 for invalid/missing organizer token (not 401 — no authentication scheme exists).
- HTTP 404 if the event token is invalid (consistent with existing `GET /events/{token}`).
**Alternatives considered**:
- Return `{ attendees: [...], count: N }`: Rejected — count is derivable from array length, and already available on the existing event detail endpoint. Avoids redundancy.
- Include RSVP timestamp: Rejected — spec says chronological order but doesn't require displaying timestamps. Order is implicit in array position.
## 3. Backend Implementation Approach
**Decision**: New `GetAttendeesUseCase` inbound port, implemented by `RsvpService`. New `findByEventId` method on `RsvpRepository` outbound port.
**Rationale**: Follows the established hexagonal architecture pattern exactly. Each use case gets its own inbound port interface. The persistence layer already has `RsvpJpaRepository` with `countByEventId`; adding `findAllByEventIdOrderByIdAsc` is a natural extension (ID order = chronological insertion order).
**Alternatives considered**:
- Add to `GetEventUseCase`: Rejected — violates single responsibility. The event detail endpoint is public; attendee retrieval is organizer-only.
- Direct repository call in controller: Rejected — violates hexagonal architecture.
## 4. Frontend Integration Approach
**Decision**: New `AttendeeList.vue` component rendered conditionally in `EventDetailView.vue` when the viewer is the organizer. Fetches attendees via separate API call after event loads.
**Rationale**:
- Separate component keeps EventDetailView manageable (it's already ~300 lines).
- Separate API call (not bundled with event fetch) — the attendee list is organizer-only; non-organizers never trigger the request.
- Component placed below attendee count, before RSVP form — matches spec FR-004.
- Empty state handled within the component (FR-005).
**Alternatives considered**:
- Inline in EventDetailView without separate component: Rejected — view is already complex. A dedicated component improves readability and testability.
- Fetch attendees in the same call as event details: Not possible — separate endpoint by design.
## 5. Error Handling
**Decision**: Frontend silently degrades on 403 (does not render attendee list). No error toast or message shown.
**Rationale**: Per FR-007, the frontend "degrades gracefully by not rendering the list." If the organizer token is invalid (e.g., localStorage cleared on another device), the user sees the same view as a regular visitor. This is intentional — no confusing error states for edge cases that self-resolve.
**Alternatives considered**:
- Show error message on 403: Rejected — would confuse users who aren't expecting organizer features.
- Retry with different token: Not applicable — only one token per event in localStorage.
## 6. Accessibility Considerations
**Decision**: Attendee list rendered as semantic `<ul>` with `<li>` items. Section has a heading for screen readers. Count label uses singular/plural form.
**Rationale**: Constitution VI requires WCAG AA compliance, semantic HTML, and keyboard navigation. A list of names is naturally a `<ul>`. The heading provides structure for screen reader navigation.

View File

@@ -0,0 +1,87 @@
# Feature Specification: View Attendee List
**Feature Branch**: `011-view-attendee-list`
**Created**: 2026-03-08
**Status**: Draft
**Input**: User description: "der organisator soll die Teilnehmerliste einsehen können, wenn er sich die detail view eines eigenen events anschaut"
## Clarifications
### Session 2026-03-08
- Q: API-Design — separater Endpoint oder bestehenden erweitern? → A: Separater Endpoint `GET /events/{token}/attendees`.
- Q: Übermittlung des Organizer-Tokens? → A: Query-Parameter `?organizerToken=<uuid>`.
- Q: UI-Platzierung der Attendee-Liste auf der Detail-Seite? → A: Direkt unter dem bestehenden Attendee-Count (vor dem RSVP-Formular).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - View Attendee List as Organizer (Priority: P1)
As the organizer of an event, I want to see a list of all attendees (people who RSVPed) when I view my event's detail page, so that I know who is coming.
When the organizer opens the event detail view for an event they created, the page displays a list of attendee names directly below the existing attendee count (before the RSVP form). This list is only visible to the organizer — regular visitors only see the attendee count (existing behavior).
**Why this priority**: This is the core feature. Without it, organizers have no way to see who signed up for their event.
**Independent Test**: Can be fully tested by creating an event, submitting RSVPs from other browsers/sessions, then viewing the event detail page with the organizer token. The attendee names should be listed.
**Acceptance Scenarios**:
1. **Given** an organizer views their event with 3 RSVPs, **When** the detail page loads, **Then** the organizer sees a list showing all 3 attendee names.
2. **Given** an organizer views their event with 0 RSVPs, **When** the detail page loads, **Then** the organizer sees an empty state message indicating no one has RSVPed yet.
3. **Given** a regular visitor (non-organizer) views the same event, **When** the detail page loads, **Then** only the attendee count is shown — no individual names are visible.
---
### User Story 2 - Attendee Count Label (Priority: P2)
As the organizer, I want the attendee list to show the total count alongside the names, so I can quickly see how many people are attending at a glance.
**Why this priority**: Enhances the organizer experience but the count is already visible in the existing detail view, so this is supplementary.
**Independent Test**: Can be tested by verifying the attendee count displayed next to/above the list matches the number of entries in the list.
**Acceptance Scenarios**:
1. **Given** an organizer views their event with 5 RSVPs, **When** the attendee list is displayed, **Then** a heading or label shows "5 Attendees" (or equivalent) above the list.
2. **Given** an organizer views their event with 1 RSVP, **When** the attendee list is displayed, **Then** the label uses singular form ("1 Attendee").
---
### Edge Cases
- What happens when the organizer token stored locally is invalid or belongs to a different event? The system treats the viewer as a regular visitor and shows the count only — no error is displayed.
- What happens when an attendee name contains special characters or is very long? Names are displayed safely (escaped) and truncated visually if necessary.
- What happens if a large number of attendees (e.g. 100+) have RSVPed? The list remains scrollable and performs well without pagination (events are expected to be small-to-medium scale).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST provide a dedicated endpoint `GET /events/{token}/attendees?organizerToken=<uuid>` for organizers to retrieve the attendee list, separate from the public event detail endpoint.
- **FR-002**: System MUST return each attendee's display name in the attendee list response.
- **FR-003**: System MUST NOT expose individual attendee names to non-organizer visitors — only the aggregate count is shown (existing behavior preserved).
- **FR-004**: The attendee list MUST be displayed directly below the attendee count on the event detail view (before the RSVP form) when the viewer is identified as the organizer.
- **FR-005**: System MUST display an empty state message when no RSVPs exist for the event.
- **FR-006**: System MUST display the total attendee count as a label alongside the attendee list.
- **FR-007**: System MUST reject attendee list requests with an invalid or missing organizer token by returning HTTP 403 (no attendee data exposed; frontend degrades gracefully by not rendering the list).
### Key Entities
- **Attendee (RSVP)**: A person who has RSVPed to an event. The organizer sees their display name in a list; visitors see only the aggregate count.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Organizers can see the full attendee name list within 2 seconds of opening their event detail page.
- **SC-002**: Non-organizer visitors never see individual attendee names — only the count is visible.
- **SC-003**: The attendee list correctly reflects all RSVPs submitted for the event, with no missing or duplicate entries.
- **SC-004**: The feature works correctly on both mobile and desktop viewports.
## Assumptions
- The organizer is identified by having a valid organizer token stored on the client. No additional login or authentication mechanism is introduced.
- The attendee list is read-only — the organizer cannot remove or edit attendees from this view.
- Attendee names are displayed in the order they RSVPed (chronological).
- The existing event detail view layout is extended, not replaced, to accommodate the attendee list section.

View File

@@ -0,0 +1,167 @@
# Tasks: View Attendee List
**Input**: Design documents from `/specs/011-view-attendee-list/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api.md
**Tests**: Included — TDD enforced per constitution principle II. E2E tests mandatory per plan.
**Organization**: Tasks grouped by user story for independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2)
- Exact file paths included in all descriptions
---
## Phase 1: Setup (API Contract)
**Purpose**: Define the API contract in OpenAPI and generate types for both backend and frontend.
- [x] T001 Add `GET /events/{token}/attendees` endpoint, `GetAttendeesResponse`, and `Attendee` schemas to `backend/src/main/resources/openapi/api.yaml` per `contracts/api.md`
- [x] T002 Regenerate backend Spring interfaces (`cd backend && ./mvnw compile`)
- [x] T003 Regenerate frontend TypeScript types (`cd frontend && npm run generate:api`)
**Checkpoint**: OpenAPI spec updated, generated types available in both backend and frontend.
---
## Phase 2: Foundational (Backend Domain Ports)
**Purpose**: Create the inbound port and extend the outbound port that all backend implementation depends on.
- [x] T004 [P] Create `GetAttendeesUseCase` inbound port interface in `backend/src/main/java/de/fete/domain/port/in/GetAttendeesUseCase.java` with method `List<String> getAttendeeNames(UUID eventToken, UUID organizerToken)`
- [x] T005 [P] Add `List<Rsvp> findByEventId(Long eventId)` method to `backend/src/main/java/de/fete/domain/port/out/RsvpRepository.java`
**Checkpoint**: Domain ports defined — service and adapter implementation can begin.
---
## Phase 3: User Story 1 — View Attendee List as Organizer (Priority: P1) 🎯 MVP
**Goal**: Organizer sees a list of attendee names on the event detail page. Non-organizers see only the count (existing behavior).
**Independent Test**: Create an event, submit RSVPs, then view the detail page with the organizer token. Attendee names should be listed.
### Tests for User Story 1 ⚠️
> **Write these tests FIRST — ensure they FAIL before implementation.**
- [x] T006 [P] [US1] Write unit tests for `RsvpService.getAttendeeNames` (valid token, invalid token, event not found, no RSVPs) in `backend/src/test/java/de/fete/application/service/RsvpServiceTest.java`
- [x] T007 [P] [US1] Write integration tests for `GET /events/{token}/attendees` (200 with attendees, 200 empty, 403 invalid token, 404 event not found) in `backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java`
- [x] T008 [P] [US1] Write unit tests for `AttendeeList.vue` (renders attendee names, empty state message, loading state) in `frontend/src/components/__tests__/AttendeeList.spec.ts`
- [x] T009 [P] [US1] Write unit tests for organizer-conditional rendering in `EventDetailView.vue` (shows list for organizer, hides for visitor) in `frontend/src/views/__tests__/EventDetailView.spec.ts`
- [x] T010 [P] [US1] Write E2E test: organizer sees attendee names, visitor sees count only, in `frontend/e2e/view-attendee-list.spec.ts`
### Backend Implementation for User Story 1
- [x] T011 [P] [US1] Add `findAllByEventIdOrderByIdAsc` query method to `backend/src/main/java/de/fete/adapter/out/persistence/RsvpJpaRepository.java`
- [x] T012 [P] [US1] Implement `findByEventId` in `backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java`
- [x] T013 [US1] Implement `GetAttendeesUseCase` in `backend/src/main/java/de/fete/application/service/RsvpService.java` — look up event by token, verify organizer token, return attendee names ordered by ID
- [x] T014 [US1] Add `getAttendees` endpoint handler to `backend/src/main/java/de/fete/adapter/in/web/EventController.java` — map to `GetAttendeesUseCase`, return 200/403/404
### Frontend Implementation for User Story 1
- [x] T015 [US1] Create `AttendeeList.vue` component in `frontend/src/components/AttendeeList.vue` — accepts attendee names array as prop, renders semantic `<ul>/<li>` list, shows empty state message when no attendees
- [x] T016 [US1] Integrate `AttendeeList.vue` into `frontend/src/views/EventDetailView.vue` — fetch `GET /events/{token}/attendees` with organizer token from localStorage, render below attendee count (before RSVP form), silently degrade on 403
**Checkpoint**: User Story 1 fully functional. Organizer sees attendee names; visitor sees count only. All tests pass.
---
## Phase 4: User Story 2 — Attendee Count Label (Priority: P2)
**Goal**: The attendee list section shows a count label ("5 Attendees" / "1 Attendee") alongside the names.
**Independent Test**: Verify the count label above the list matches the number of entries, and uses singular/plural correctly.
### Tests for User Story 2 ⚠️
> **Write these tests FIRST — ensure they FAIL before implementation.**
- [x] T017 [P] [US2] Write unit tests for count label in `AttendeeList.vue` (plural "5 Attendees", singular "1 Attendee", zero "0 Attendees") in `frontend/src/components/__tests__/AttendeeList.spec.ts`
### Implementation for User Story 2
- [x] T018 [US2] Add count heading to `AttendeeList.vue` in `frontend/src/components/AttendeeList.vue` — render `<h3>` with singular/plural label based on attendee array length
**Checkpoint**: User Story 2 complete. Count label renders correctly with singular/plural form. All tests pass.
---
## Phase 5: Polish & Cross-Cutting Concerns
**Purpose**: Verification across both stories, accessibility, edge cases.
- [x] T019 Run full backend verification (`cd backend && ./mvnw verify`) — checkstyle, all tests green
- [x] T020 Run full frontend build and tests (`cd frontend && npm run build && npm run test:unit`)
- [x] T021 Run E2E tests (`cd frontend && npx playwright test e2e/view-attendee-list.spec.ts`)
- [x] T022 Verify WCAG AA contrast and semantic HTML (attendee list uses `<ul>/<li>`, section has heading for screen readers)
- [x] T023 Verify edge cases: long names truncated visually, special characters escaped, large attendee list scrollable
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on T002 (generated backend interfaces)
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion
- **User Story 2 (Phase 4)**: Depends on T015 (AttendeeList component exists)
- **Polish (Phase 5)**: Depends on Phase 3 + Phase 4 completion
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
- **User Story 2 (P2)**: Depends on US1's `AttendeeList.vue` component (T015) existing
### Within Each User Story
- Tests MUST be written and FAIL before implementation
- Ports/models before services
- Services before endpoints/controllers
- Backend before frontend (API must exist for frontend integration)
### Parallel Opportunities
- T004 + T005 can run in parallel (different files)
- T006 + T007 + T008 + T009 + T010 can all run in parallel (different test files)
- T011 + T012 can run in parallel (different persistence files)
---
## Parallel Example: User Story 1
```bash
# Launch all tests in parallel (TDD — write first):
Task: T006 "Unit tests for RsvpService.getAttendeeNames"
Task: T007 "Integration tests for GET /events/{token}/attendees"
Task: T008 "Unit tests for AttendeeList.vue"
Task: T009 "Unit tests for EventDetailView.vue organizer rendering"
Task: T010 "E2E test for attendee list"
# Launch persistence layer in parallel:
Task: T011 "Add findAllByEventIdOrderByIdAsc to RsvpJpaRepository"
Task: T012 "Implement findByEventId in RsvpPersistenceAdapter"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (OpenAPI + type generation)
2. Complete Phase 2: Foundational (domain ports)
3. Complete Phase 3: User Story 1 (backend + frontend)
4. **STOP and VALIDATE**: Test independently — organizer sees names, visitor sees count only
5. Deploy/demo if ready
### Incremental Delivery
1. Setup + Foundational → API contract and ports ready
2. Add User Story 1 → Test independently → Deploy (MVP!)
3. Add User Story 2 → Test independently → Deploy (count label enhancement)
4. Polish phase → Full verification, accessibility, edge cases