Files
dttmr-fe/src/components/InvitesPanel.vue
T

466 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites'
import type { Invite } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired'
const expanded = ref(false)
const invites = ref<Invite[]>([])
const isLoading = ref(false)
const isCreating = ref(false)
const error = ref('')
const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null)
const sharedId = ref<string | null>(null)
let hasLoaded = false
let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed(
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length,
)
onBeforeUnmount(() => {
clearTimeout(sharedTimeout)
})
function isOffline(): boolean {
return typeof navigator !== 'undefined' && !navigator.onLine
}
async function toggleExpanded() {
expanded.value = !expanded.value
if (expanded.value && !hasLoaded) {
await loadInvites()
}
}
async function loadInvites() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to manage invites.'
return
}
isLoading.value = true
try {
invites.value = await getInvitesApi()
hasLoaded = true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load invites'
} finally {
isLoading.value = false
}
}
async function handleCreate() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to create an invite.'
return
}
isCreating.value = true
try {
const invite = await createInviteApi()
invites.value = [invite, ...invites.value]
hasLoaded = true
// Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards.
await shareInvite(invite)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create invite'
} finally {
isCreating.value = false
}
}
function requestDelete(id: string) {
error.value = ''
pendingDeleteId.value = id
}
function cancelDelete() {
pendingDeleteId.value = null
}
async function confirmDelete(id: string) {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to delete an invite.'
pendingDeleteId.value = null
return
}
deletingId.value = id
try {
await deleteInviteApi(id)
invites.value = invites.value.filter((invite) => invite.id !== id)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally {
deletingId.value = null
pendingDeleteId.value = null
}
}
// The link that lands someone on the (otherwise unlinked) register page —
// see router.beforeEach, which redirects any URL carrying ?invite=... there.
function getInviteUrl(code: string): string {
const base = `${window.location.origin}${import.meta.env.BASE_URL}`
return `${base}?invite=${encodeURIComponent(code)}`
}
async function shareInvite(invite: Invite) {
const url = getInviteUrl(invite.code)
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: 'Join dttmr', text: 'Use this link to create your account', url })
return
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
// Web Share unsupported in this context; fall through to clipboard copy.
}
}
try {
await navigator.clipboard.writeText(url)
sharedId.value = invite.id
clearTimeout(sharedTimeout)
sharedTimeout = setTimeout(() => {
sharedId.value = null
}, 1500)
} catch {
// Clipboard access denied or unavailable; nothing sensible to do.
}
}
function inviteStatus(invite: Invite): InviteStatus {
if (invite.consumed_at) return 'used'
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) return 'expired'
return 'active'
}
function statusLabel(invite: Invite): string {
const status = inviteStatus(invite)
return status === 'used' ? 'Used' : status === 'expired' ? 'Expired' : 'Active'
}
// Standard "time ago"/"time until" formatter: walk unit divisions until the
// duration fits in one, so both past and future dates read naturally.
function formatRelative(dateStr: string): string {
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
[60, 'seconds'],
[60, 'minutes'],
[24, 'hours'],
[7, 'days'],
[4.34524, 'weeks'],
[12, 'months'],
[Number.POSITIVE_INFINITY, 'years'],
]
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
let duration = (new Date(dateStr).getTime() - Date.now()) / 1000
for (const [amount, unit] of divisions) {
if (Math.abs(duration) < amount) {
return rtf.format(Math.round(duration), unit)
}
duration /= amount
}
return rtf.format(Math.round(duration), 'years')
}
function inviteDetail(invite: Invite): string {
const status = inviteStatus(invite)
if (status === 'used') {
return invite.consumed_at ? `Used ${formatRelative(invite.consumed_at)}` : 'Used'
}
if (!invite.expires_at) {
return 'No expiry'
}
return `${status === 'expired' ? 'Expired' : 'Expires'} ${formatRelative(invite.expires_at)}`
}
</script>
<template>
<section class="card info-card invites-card">
<button
type="button"
class="invites-toggle"
:aria-expanded="expanded"
aria-controls="invites-panel"
@click="toggleExpanded"
>
<span class="invites-toggle-label">
<h4>Invites</h4>
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span>
</span>
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span>
</button>
<div v-if="expanded" id="invites-panel" class="invites-body">
<p class="invites-hint">
Invite codes let someone new create an account. Creating, listing, and deleting invites
requires an internet connection.
</p>
<p v-if="isLoading" class="invites-loading">Loading invites</p>
<ul v-else-if="invites.length > 0" class="invite-list">
<li
v-for="invite in invites"
:key="invite.id"
class="invite-ticket"
:class="`is-${inviteStatus(invite)}`"
>
<div class="invite-ticket-main">
<code class="invite-code">{{ invite.code }}</code>
<span class="invite-status-pill" :class="`pill-${inviteStatus(invite)}`">{{
statusLabel(invite)
}}</span>
</div>
<div class="invite-ticket-meta">
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
<button type="button" class="ticket-btn" @click="shareInvite(invite)">
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="inviteStatus(invite) === 'used'"
:title="inviteStatus(invite) === 'used' ? 'Used invites cannot be deleted' : ''"
@click="requestDelete(invite.id)"
>
Delete
</button>
</div>
<div v-else class="invite-actions">
<span class="confirm-label">Delete this invite?</span>
<button type="button" class="ticket-btn" @click="cancelDelete">No</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="deletingId === invite.id"
@click="confirmDelete(invite.id)"
>
{{ deletingId === invite.id ? 'Deleting…' : 'Yes' }}
</button>
</div>
</div>
</li>
</ul>
<p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<button
type="button"
class="btn btn-primary generate-btn"
:disabled="isCreating"
@click="handleCreate"
>
{{ isCreating ? 'Generating…' : '+ Generate invite' }}
</button>
</div>
</section>
</template>
<style scoped>
.invites-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.invites-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.invites-toggle-label h4 {
font-size: 0.85rem;
margin: 0;
}
.invites-count-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background-color: var(--c-accent-bg);
color: var(--c-accent-strong);
}
.chevron {
color: var(--c-text-soft);
transition: transform 0.15s ease-in-out;
}
.chevron.is-open {
transform: rotate(180deg);
}
.invites-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.85rem;
}
.invites-hint {
font-size: 0.8rem;
color: var(--c-text-soft);
margin: 0;
}
.invites-loading,
.invites-empty {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
.invite-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.invite-ticket {
border: 1px solid var(--c-border);
border-left: 3px solid var(--c-text-soft);
border-radius: var(--radius-md);
background-color: var(--c-bg-mute);
padding: 0.65rem 0.85rem;
}
.invite-ticket.is-active {
border-left-color: var(--c-success);
}
.invite-ticket.is-expired {
border-left-color: var(--c-danger);
}
.invite-ticket.is-used {
border-left-color: var(--c-text-soft);
opacity: 0.75;
}
.invite-ticket-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.invite-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--c-heading);
}
.invite-status-pill {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.pill-active {
background-color: rgba(52, 211, 153, 0.14);
color: var(--c-success);
}
.pill-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.pill-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invite-ticket-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-top: 1px dashed var(--c-border);
margin-top: 0.55rem;
padding-top: 0.5rem;
}
.invite-detail {
font-size: 0.75rem;
color: var(--c-text-soft);
}
.invite-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.confirm-label {
font-size: 0.75rem;
color: var(--c-text-soft);
}
.ticket-btn {
background: none;
border: 1px solid var(--c-border);
color: var(--c-text);
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.ticket-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.ticket-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ticket-btn-danger {
color: var(--c-danger);
border-color: rgba(248, 113, 113, 0.35);
}
.ticket-btn-danger:hover:not(:disabled) {
background-color: var(--c-danger-bg);
}
.generate-btn {
width: auto;
align-self: flex-start;
padding: 0.55rem 1.1rem;
}
</style>