Add iCal download for calendar integration #43
@@ -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,11 +99,25 @@
|
||||
</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
|
||||
</button>
|
||||
<!-- 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 -->
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user