Add iCal download for calendar integration #43

Merged
nitrix merged 7 commits from 019-ical-download into master 2026-03-14 11:40:43 +01:00
2 changed files with 172 additions and 33 deletions
Showing only changes of commit 92372b6a59 - Show all commits

View File

@@ -10,6 +10,33 @@
<div class="detail__hero-overlay" />
</div>
<!-- Kebab menu (teleported into app header) -->
<Teleport to="#header-actions">
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__kebab-wrapper">
<button
class="detail__kebab-btn"
type="button"
aria-label="Event actions"
:aria-expanded="kebabOpen"
@click="kebabOpen = !kebabOpen"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
</button>
<Transition name="kebab-menu">
<div v-if="kebabOpen" class="detail__kebab-menu" role="menu">
<button
class="detail__kebab-item detail__kebab-item--danger"
type="button"
role="menuitem"
@click="kebabOpen = false; cancelSheetOpen = true"
>
Cancel event
</button>
</div>
</Transition>
</div>
</Teleport>
<div class="detail__body">
<!-- Loading state -->
<div v-if="state === 'loading'" class="detail__content" aria-busy="true" aria-label="Loading event details">
@@ -72,12 +99,26 @@
</div>
</div>
<!-- Cancel event button (organizer only, not already cancelled) -->
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__cancel-event">
<button class="detail__cancel-event-btn" type="button" @click="cancelSheetOpen = true">
Cancel event
<!-- Organizer bottom bar (not cancelled) -->
<div v-if="state === 'loaded' && event && isOrganizer && !event.cancelled" class="detail__organizer-bar">
<div class="detail__organizer-bar-inner">
<div class="bar-cta glow-border glow-border--animated">
<button class="bar-cta-btn glass-inner" type="button">
Post an update
</button>
</div>
<div class="bar-icon glow-border glow-border--animated">
<button
class="bar-icon-btn glass-inner"
type="button"
aria-label="Add to calendar"
@click="handleCalendarDownload"
>
<svg width="20" height="20" 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>
</button>
</div>
</div>
</div>
<!-- Cancel event bottom sheet -->
<BottomSheet :open="cancelSheetOpen" label="Cancel event" @close="cancelSheetOpen = false">
@@ -120,6 +161,7 @@
@open="sheetOpen = true"
@cancel="confirmCancelOpen = true"
@bookmark="handleBookmarkClick"
@calendar="handleCalendarDownload"
/>
<!-- Cancel confirmation dialog -->
@@ -163,10 +205,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '@/api/client'
import { useEventStorage } from '@/composables/useEventStorage'
import { useIcalDownload } from '@/composables/useIcalDownload'
import AttendeeList from '@/components/AttendeeList.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
@@ -178,6 +221,7 @@ type State = 'loading' | 'loaded' | 'not-found' | 'error'
const route = useRoute()
const { saveRsvp, getRsvp, removeRsvp, getOrganizerToken, saveWatch, isStored, removeEvent } = useEventStorage()
const { download: downloadIcal } = useIcalDownload()
const state = ref<State>('loading')
const event = ref<GetEventResponse | null>(null)
@@ -194,6 +238,24 @@ const cancelError = ref('')
const isOrganizer = ref(false)
const attendeeNames = ref<string[] | null>(null)
// Kebab menu state
const kebabOpen = ref(false)
function onKebabClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.detail__kebab-wrapper')) {
kebabOpen.value = false
}
}
watch(kebabOpen, (isOpen) => {
if (isOpen) {
document.addEventListener('click', onKebabClickOutside, { capture: true })
} else {
document.removeEventListener('click', onKebabClickOutside, { capture: true })
}
})
// Cancel event state
const cancelSheetOpen = ref(false)
const cancelReasonInput = ref('')
@@ -204,6 +266,17 @@ const eventToken = computed(() => route.params.eventToken as string)
const eventIsStored = computed(() => isStored(eventToken.value))
function handleCalendarDownload() {
if (!event.value) return
downloadIcal({
eventToken: event.value.eventToken,
title: event.value.title,
dateTime: event.value.dateTime,
location: event.value.location,
description: event.value.description,
})
}
function handleBookmarkClick() {
if (!event.value) return
if (isOrganizer.value || rsvpName.value) return
@@ -620,37 +693,97 @@ onMounted(fetchEvent)
word-break: break-word;
}
/* Cancel event button */
.detail__cancel-event {
/* Kebab menu (teleported into app header) */
.detail__kebab-wrapper {
position: relative;
}
.detail__kebab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: none;
border: none;
color: var(--color-text-on-gradient);
cursor: pointer;
transition: background 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.detail__kebab-btn:hover {
background: var(--color-glass-hover);
}
.detail__kebab-menu {
position: absolute;
top: calc(100% + var(--spacing-xs));
right: 0;
min-width: 180px;
padding: var(--spacing-xs) 0;
border-radius: var(--radius-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);
box-shadow: var(--shadow-card);
}
.detail__kebab-item {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-lg);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-on-gradient);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.detail__kebab-item:hover {
background: var(--color-glass-hover);
}
.detail__kebab-item--danger {
color: var(--color-danger);
}
.kebab-menu-enter-active,
.kebab-menu-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.kebab-menu-enter-from,
.kebab-menu-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* Organizer bottom bar — mirrors RsvpBar layout */
.detail__organizer-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: var(--spacing-md) var(--content-padding);
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
display: flex;
justify-content: center;
z-index: 10;
padding: var(--spacing-md) var(--content-padding);
padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom, 0px));
}
.detail__cancel-event-btn {
.detail__organizer-bar-inner {
width: 100%;
max-width: var(--content-max-width);
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-button);
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-danger);
background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border);
cursor: pointer;
transition: background 0.15s ease;
display: flex;
gap: var(--spacing-sm);
}
.detail__cancel-event-btn:hover {
background: var(--color-danger-bg-hover);
}
/* Cancel event form (inside bottom sheet) */
.cancel-form__textarea {

View File

@@ -77,6 +77,13 @@ beforeEach(() => {
mockIsStored.mockReturnValue(false)
mockSaveWatch.mockClear()
mockRemoveEvent.mockClear()
// Provide Teleport target for kebab menu
if (!document.getElementById('header-actions')) {
const target = document.createElement('div')
target.id = 'header-actions'
document.body.appendChild(target)
}
})
describe('EventDetailView', () => {
@@ -197,8 +204,8 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken()
await flushPromises()
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__cta').text()).toBe("I'm attending!")
expect(wrapper.find('.bar-cta').exists()).toBe(true)
expect(wrapper.find('.bar-cta').text()).toBe("I'm attending!")
wrapper.unmount()
})
@@ -210,7 +217,6 @@ describe('EventDetailView', () => {
await flushPromises()
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
wrapper.unmount()
})
@@ -223,7 +229,7 @@ describe('EventDetailView', () => {
expect(wrapper.find('.rsvp-bar__status').exists()).toBe(true)
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
expect(wrapper.find('.bar-cta').exists()).toBe(false)
wrapper.unmount()
})
@@ -236,7 +242,7 @@ describe('EventDetailView', () => {
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await wrapper.find('.bar-cta-btn').trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
@@ -249,7 +255,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await wrapper.find('.bar-cta-btn').trigger('click')
await flushPromises()
// Form is inside Teleport — find via document.body
@@ -274,7 +280,7 @@ describe('EventDetailView', () => {
await flushPromises()
// Open sheet
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await wrapper.find('.bar-cta-btn').trigger('click')
await flushPromises()
// Fill name via Teleported input
@@ -305,7 +311,7 @@ describe('EventDetailView', () => {
// Verify UI switched to status
expect(wrapper.find('.rsvp-bar__text').text()).toBe("You're attending!")
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
expect(wrapper.find('.bar-cta').exists()).toBe(false)
// Verify attendee count incremented
expect(wrapper.text()).toContain('13')
@@ -360,7 +366,7 @@ describe('EventDetailView', () => {
const wrapper = await mountWithToken()
await flushPromises()
await wrapper.find('.rsvp-bar__cta-inner').trigger('click')
await wrapper.find('.bar-cta-btn').trigger('click')
await flushPromises()
const input = document.body.querySelector('#rsvp-name')! as HTMLInputElement