Add cancel RSVP feature (backend DELETE endpoint + frontend UI)
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>
This commit is contained in:
@@ -2,9 +2,32 @@
|
||||
<div class="rsvp-bar">
|
||||
<div class="rsvp-bar__inner">
|
||||
<!-- Status state: already RSVPed -->
|
||||
<div v-if="hasRsvp" class="rsvp-bar__status">
|
||||
<span class="rsvp-bar__check" aria-hidden="true">✓</span>
|
||||
<span class="rsvp-bar__text">You're attending!</span>
|
||||
<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 -->
|
||||
@@ -18,13 +41,37 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
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>
|
||||
@@ -73,6 +120,12 @@ defineEmits<{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rsvp-bar__status-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rsvp-bar__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -88,6 +141,13 @@ defineEmits<{
|
||||
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 {
|
||||
@@ -101,4 +161,49 @@ defineEmits<{
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user