Add event stub page with clipboard sharing
Post-creation confirmation page showing shareable event URL with copy-to-clipboard and fallback feedback on failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
132
frontend/src/views/EventStubView.vue
Normal file
132
frontend/src/views/EventStubView.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<main class="stub">
|
||||||
|
<header class="stub__header">
|
||||||
|
<RouterLink to="/" class="stub__back" aria-label="Back to home">←</RouterLink>
|
||||||
|
<span class="stub__brand">fete</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stub__content">
|
||||||
|
<p class="stub__check">✓ Event created!</p>
|
||||||
|
|
||||||
|
<p class="stub__share-label">Share this link:</p>
|
||||||
|
<div class="stub__link-box">
|
||||||
|
<span class="stub__link">{{ eventUrl }}</span>
|
||||||
|
<button class="stub__copy" type="button" @click="copyLink" :aria-label="copyLabel">
|
||||||
|
{{ copyState === 'copied' ? 'Copied!' : copyState === 'failed' ? 'Failed' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const copyState = ref<'idle' | 'copied' | 'failed'>('idle')
|
||||||
|
|
||||||
|
const eventUrl = computed(() => {
|
||||||
|
return window.location.origin + '/events/' + route.params.token
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyLabel = computed(() => {
|
||||||
|
if (copyState.value === 'copied') return 'Link copied to clipboard'
|
||||||
|
if (copyState.value === 'failed') return 'Copy failed — select the link to copy manually'
|
||||||
|
return 'Copy event link to clipboard'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(eventUrl.value)
|
||||||
|
copyState.value = 'copied'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyState.value = 'idle'
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
copyState.value = 'failed'
|
||||||
|
setTimeout(() => {
|
||||||
|
copyState.value = 'idle'
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stub {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xl);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__back {
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__check {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__share-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-on-gradient);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__link-box {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__link {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub__copy {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-button);
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
87
frontend/src/views/__tests__/EventStubView.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import EventStubView from '../EventStubView.vue'
|
||||||
|
|
||||||
|
function createTestRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: { template: '<div />' } },
|
||||||
|
{ path: '/events/:token', name: 'event', component: EventStubView },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountWithToken(token = 'test-token-123') {
|
||||||
|
const router = createTestRouter()
|
||||||
|
await router.push(`/events/${token}`)
|
||||||
|
await router.isReady()
|
||||||
|
return mount(EventStubView, {
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EventStubView', () => {
|
||||||
|
it('renders the event URL based on route param', async () => {
|
||||||
|
const wrapper = await mountWithToken('abc-def')
|
||||||
|
|
||||||
|
const linkText = wrapper.find('.stub__link').text()
|
||||||
|
expect(linkText).toContain('/events/abc-def')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the correct share URL with origin', async () => {
|
||||||
|
const wrapper = await mountWithToken('my-event-token')
|
||||||
|
|
||||||
|
const linkText = wrapper.find('.stub__link').text()
|
||||||
|
expect(linkText).toBe(`${window.location.origin}/events/my-event-token`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a copy button', async () => {
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
|
||||||
|
const copyBtn = wrapper.find('.stub__copy')
|
||||||
|
expect(copyBtn.exists()).toBe(true)
|
||||||
|
expect(copyBtn.text()).toBe('Copy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies link to clipboard and shows confirmation', async () => {
|
||||||
|
const writeTextMock = vi.fn().mockResolvedValue(undefined)
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: writeTextMock },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken('copy-test')
|
||||||
|
|
||||||
|
await wrapper.find('.stub__copy').trigger('click')
|
||||||
|
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith(
|
||||||
|
`${window.location.origin}/events/copy-test`,
|
||||||
|
)
|
||||||
|
expect(wrapper.find('.stub__copy').text()).toBe('Copied!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failure message when clipboard is unavailable', async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Not allowed')) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = await mountWithToken('fail-test')
|
||||||
|
|
||||||
|
await wrapper.find('.stub__copy').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.find('.stub__copy').text()).toBe('Failed')
|
||||||
|
expect(wrapper.find('.stub__copy').attributes('aria-label')).toBe(
|
||||||
|
'Copy failed — select the link to copy manually',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a back link to home', async () => {
|
||||||
|
const wrapper = await mountWithToken()
|
||||||
|
|
||||||
|
const backLink = wrapper.find('.stub__back')
|
||||||
|
expect(backLink.exists()).toBe(true)
|
||||||
|
expect(backLink.attributes('aria-label')).toBe('Back to home')
|
||||||
|
expect(backLink.attributes('href')).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user