Allows guests to cancel their RSVP via a DELETE endpoint using their guestToken. Frontend shows cancel button in RsvpBar and clears local storage on success. Includes unit tests, integration tests, and E2E spec. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
210 lines
4.9 KiB
Vue
210 lines
4.9 KiB
Vue
<template>
|
||
<div class="rsvp-bar">
|
||
<div class="rsvp-bar__inner">
|
||
<!-- Status state: already RSVPed -->
|
||
<div v-if="hasRsvp" class="rsvp-bar__status-wrapper">
|
||
<div
|
||
class="rsvp-bar__status"
|
||
role="button"
|
||
tabindex="0"
|
||
:aria-expanded="expanded"
|
||
aria-label="You're attending. Tap to show cancel option."
|
||
@click="expanded = !expanded"
|
||
@keydown.enter.prevent="expanded = !expanded"
|
||
@keydown.space.prevent="expanded = !expanded"
|
||
@keydown.escape="expanded = false"
|
||
>
|
||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||
<span class="rsvp-bar__text">You're attending!</span>
|
||
<span class="rsvp-bar__chevron" :class="{ 'rsvp-bar__chevron--open': expanded }" aria-hidden="true">›</span>
|
||
</div>
|
||
<Transition name="rsvp-bar-cancel">
|
||
<button
|
||
v-if="expanded"
|
||
class="rsvp-bar__cancel"
|
||
type="button"
|
||
@click="$emit('cancel')"
|
||
>
|
||
Cancel attendance
|
||
</button>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- CTA state: no RSVP yet -->
|
||
<div v-else class="rsvp-bar__cta glow-border glow-border--animated">
|
||
<button class="rsvp-bar__cta-inner glass-inner" type="button" @click="$emit('open')">
|
||
I'm attending
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch } from 'vue'
|
||
|
||
const props = defineProps<{
|
||
hasRsvp?: boolean
|
||
}>()
|
||
|
||
defineEmits<{
|
||
open: []
|
||
cancel: []
|
||
}>()
|
||
|
||
const expanded = ref(false)
|
||
|
||
watch(() => props.hasRsvp, () => {
|
||
expanded.value = false
|
||
})
|
||
|
||
function onClickOutside(e: MouseEvent) {
|
||
const target = e.target as HTMLElement
|
||
if (!target.closest('.rsvp-bar__status-wrapper')) {
|
||
expanded.value = false
|
||
}
|
||
}
|
||
|
||
watch(expanded, (isExpanded) => {
|
||
if (isExpanded) {
|
||
document.addEventListener('click', onClickOutside, { capture: true })
|
||
} else {
|
||
document.removeEventListener('click', onClickOutside, { capture: true })
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.rsvp-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
z-index: 50;
|
||
padding: var(--spacing-md) var(--content-padding);
|
||
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
.rsvp-bar__inner {
|
||
width: 100%;
|
||
max-width: var(--content-max-width);
|
||
}
|
||
|
||
.rsvp-bar__cta {
|
||
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-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.rsvp-bar__status {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-xs);
|
||
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);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
box-shadow: var(--shadow-card);
|
||
font-weight: 600;
|
||
font-size: 0.95rem;
|
||
color: var(--color-text-on-gradient);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.rsvp-bar__status:hover {
|
||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||
}
|
||
|
||
.rsvp-bar__check {
|
||
color: #4caf50;
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.rsvp-bar__text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.rsvp-bar__chevron {
|
||
font-size: 1.2rem;
|
||
font-weight: 700;
|
||
transition: transform 0.2s ease;
|
||
transform: rotate(0deg);
|
||
margin-left: auto;
|
||
}
|
||
|
||
.rsvp-bar__chevron--open {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.rsvp-bar__cancel {
|
||
display: block;
|
||
width: 100%;
|
||
padding: var(--spacing-sm) var(--spacing-lg);
|
||
border-radius: var(--radius-card);
|
||
font-family: inherit;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: #ef5350;
|
||
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);
|
||
cursor: pointer;
|
||
text-align: center;
|
||
transition: background 0.15s ease;
|
||
}
|
||
|
||
.rsvp-bar__cancel:hover {
|
||
background: linear-gradient(135deg, var(--color-glass-hover) 0%, var(--color-glass-strong) 100%);
|
||
}
|
||
|
||
.rsvp-bar-cancel-enter-active,
|
||
.rsvp-bar-cancel-leave-active {
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
}
|
||
|
||
.rsvp-bar-cancel-enter-from,
|
||
.rsvp-bar-cancel-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
</style>
|