feat: add WannPassts booking app with Go backend and Vue frontend
Initial project setup for a privacy-focused, self-hosted scheduling app: - Go backend with JWT auth, SQLite storage, and chi router - Calendar integrations via Google OAuth (FreeBusy), CalDAV, and ICS links - Public booking pages with configurable slots and booking requests - AES-256-GCM encryption for stored provider tokens - Vue 3 + TypeScript frontend with Vite, Pinia, and Vue Router - Docs, .gitignore, and .env.example for local development
This commit is contained in:
commit
cb30223fd4
47 changed files with 6599 additions and 0 deletions
34
frontend/src/App.vue
Normal file
34
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuth } from './stores/auth'
|
||||
import { useToast } from './stores/toast'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
onMounted(() => {
|
||||
auth.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-scene" aria-hidden="true">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
</div>
|
||||
|
||||
<router-view />
|
||||
|
||||
<transition-group name="fade" tag="div" class="toasts">
|
||||
<div
|
||||
v-for="t in toast.toasts"
|
||||
:key="t.id"
|
||||
class="toast glass"
|
||||
:class="{ 'toast-ok': t.kind === 'ok', 'toast-error': t.kind === 'error' }"
|
||||
role="status"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
36
frontend/src/components/GlassCard.vue
Normal file
36
frontend/src/components/GlassCard.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ title?: string; subtitle?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="glass card">
|
||||
<header v-if="title || subtitle || $slots.header" class="card-head">
|
||||
<div class="grow">
|
||||
<h2 v-if="title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="muted small" style="margin: 4px 0 0">{{ subtitle }}</p>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
</style>
|
||||
22
frontend/src/components/LogoMark.vue
Normal file
22
frontend/src/components/LogoMark.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="wp-lg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#7db0ff" />
|
||||
<stop offset="1" stop-color="#b78cff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4.75" y="6.75" width="38.5" height="36.5" rx="10.5" fill="rgba(255,255,255,0.06)" stroke="url(#wp-lg)" stroke-width="2.2" />
|
||||
<path d="M5 16h38" stroke="url(#wp-lg)" stroke-width="2.2" opacity="0.7" />
|
||||
<path d="M15 3.5v6M33 3.5v6" stroke="url(#wp-lg)" stroke-width="3" stroke-linecap="round" />
|
||||
<path d="M15 27l6.5 6.5L33.5 21" stroke="url(#wp-lg)" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
filter: drop-shadow(0 4px 14px rgba(125, 176, 255, 0.35));
|
||||
}
|
||||
</style>
|
||||
239
frontend/src/components/SectionConnections.vue
Normal file
239
frontend/src/components/SectionConnections.vue
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import type { Connection } from '../lib/types'
|
||||
import { useToast } from '../stores/toast'
|
||||
import GlassCard from './GlassCard.vue'
|
||||
|
||||
const toast = useToast()
|
||||
const connections = ref<Connection[] | null>(null)
|
||||
const syncing = ref(false)
|
||||
const busy = ref(false)
|
||||
|
||||
const showCalDAV = ref(false)
|
||||
const showICS = ref(false)
|
||||
|
||||
const caldav = ref({ server_url: 'https://caldav.icloud.com', username: '', password: '', calendar_path: '', display_name: '' })
|
||||
const ics = ref({ url: '', display_name: '' })
|
||||
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const providerLabel: Record<Connection['provider'], string> = {
|
||||
google: 'Google',
|
||||
caldav: 'CalDAV / iCloud',
|
||||
ics: 'ICS-Abo',
|
||||
}
|
||||
const providerIcon: Record<Connection['provider'], string> = {
|
||||
google: '🇬',
|
||||
caldav: '☁️',
|
||||
ics: '📡',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
connections.value = await api.connections()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePoll() {
|
||||
pollTimer = setTimeout(load, 3500)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
schedulePoll()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearTimeout(pollTimer)
|
||||
})
|
||||
|
||||
async function syncNow() {
|
||||
syncing.value = true
|
||||
try {
|
||||
connections.value = await api.syncNow()
|
||||
toast.ok('Kalender synchronisiert')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Sync fehlgeschlagen')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function connectGoogle() {
|
||||
try {
|
||||
const { url } = await api.googleStart()
|
||||
location.href = url
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Google-Start fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCalDAV() {
|
||||
busy.value = true
|
||||
try {
|
||||
await api.connectCalDAV({
|
||||
server_url: caldav.value.server_url,
|
||||
username: caldav.value.username,
|
||||
password: caldav.value.password,
|
||||
calendar_path: caldav.value.calendar_path || undefined,
|
||||
display_name: caldav.value.display_name || undefined,
|
||||
})
|
||||
toast.ok('Kalender verbunden – erste Synchronisierung läuft.')
|
||||
showCalDAV.value = false
|
||||
caldav.value.username = ''
|
||||
caldav.value.password = ''
|
||||
await load()
|
||||
schedulePoll()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitICS() {
|
||||
busy.value = true
|
||||
try {
|
||||
await api.connectICS({ url: ics.value.url, display_name: ics.value.display_name || undefined })
|
||||
toast.ok('ICS-Kalender verbunden.')
|
||||
showICS.value = false
|
||||
ics.value.url = ''
|
||||
ics.value.display_name = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Verbinden fehlgeschlagen')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(conn: Connection) {
|
||||
if (!confirm(`„${conn.display_name}“ wirklich entfernen?`)) return
|
||||
try {
|
||||
await api.deleteConnection(conn.id)
|
||||
connections.value = (connections.value ?? []).filter((c) => c.id !== conn.id)
|
||||
toast.ok('Verbindung entfernt')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function syncedAgo(ts: number | null): string {
|
||||
if (!ts) return 'wird synchronisiert…'
|
||||
const s = Math.max(0, Math.round(Date.now() / 1000 - ts))
|
||||
if (s < 60) return `vor ${s}s`
|
||||
if (s < 3600) return `vor ${Math.round(s / 60)} Min.`
|
||||
return `vor ${Math.round(s / 3600)} Std.`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="col">
|
||||
<GlassCard title="Kalender hinzufügen" subtitle="WannPassts liest nur frei/belegt – nie Termindetails.">
|
||||
<div class="row add-row">
|
||||
<button class="btn" @click="connectGoogle">🇬 Google Kalender</button>
|
||||
<button class="btn" @click="showCalDAV = !showCalDAV; showICS = false">☁️ iCloud / CalDAV</button>
|
||||
<button class="btn" @click="showICS = !showICS; showCalDAV = false">📡 ICS-Link</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<form v-if="showCalDAV" class="glass-soft form" @submit.prevent="submitCalDAV">
|
||||
<p class="muted small" style="margin: 0 0 4px">
|
||||
Für iCloud: <strong>Apple-ID</strong> als Benutzername und ein
|
||||
<a href="https://appleid.apple.com/account/manage" target="_blank" rel="noopener">App-spezifisches Passwort</a>
|
||||
(Konto → Anmeldung und Sicherheit). Auch Fastmail, Nextcloud & Co. funktionieren.
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Server</span>
|
||||
<input v-model="caldav.server_url" required class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Benutzername</span>
|
||||
<input v-model="caldav.username" required class="input" autocomplete="username" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (App-spezifisch)</span>
|
||||
<input v-model="caldav.password" type="password" required class="input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Kalender-Pfad (optional)</span>
|
||||
<input v-model="caldav.calendar_path" class="input" placeholder="wird automatisch erkannt" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzeigename (optional)</span>
|
||||
<input v-model="caldav.display_name" class="input" placeholder="z. B. Privatkalender" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy">
|
||||
{{ busy ? 'Verbinde…' : 'Verbinden' }}
|
||||
</button>
|
||||
</form>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<form v-if="showICS" class="glass-soft form" @submit.prevent="submitICS">
|
||||
<p class="muted small" style="margin: 0 0 4px">
|
||||
Funktioniert mit jedem öffentlichen ICS/webcal-Link, z. B. der „privaten Adresse im
|
||||
iCal-Format“ eines Google-Kalenders oder Ferien-/Schichtplänen.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>ICS-URL</span>
|
||||
<input v-model="ics.url" required class="input" placeholder="https://…/kalender.ics" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzeigename (optional)</span>
|
||||
<input v-model="ics.display_name" class="input" placeholder="z. B. Schichtplan" />
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy">
|
||||
{{ busy ? 'Prüfe…' : 'Hinzufügen' }}
|
||||
</button>
|
||||
</form>
|
||||
</transition>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Verbundene Kalender">
|
||||
<template #header>
|
||||
<button class="btn btn-sm" :disabled="syncing" @click="syncNow">
|
||||
{{ syncing ? 'Synchronisiere…' : '↻ Jetzt synchronisieren' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="connections === null" class="skeleton" style="height: 72px"></div>
|
||||
<p v-else-if="connections.length === 0" class="muted small" style="margin: 0">
|
||||
Noch kein Kalender verbunden. Oben einen hinzufügen – ohne Kalender sind alle Zeiten frei.
|
||||
</p>
|
||||
<div v-else class="col conn-list">
|
||||
<div v-for="c in connections" :key="c.id" class="glass-soft conn">
|
||||
<span class="conn-icon">{{ providerIcon[c.provider] }}</span>
|
||||
<div class="grow">
|
||||
<div class="row">
|
||||
<strong>{{ c.display_name }}</strong>
|
||||
<span class="badge">{{ providerLabel[c.provider] }}</span>
|
||||
</div>
|
||||
<div class="row small">
|
||||
<span :class="c.last_error ? 'err' : 'muted'">
|
||||
{{ c.last_error ? '⚠ ' + c.last_error : '✓ synchronisiert ' + syncedAgo(c.last_synced_ts) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" @click="remove(c)">Entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-row { flex-wrap: wrap; }
|
||||
.form { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
.form a { color: var(--accent); }
|
||||
.conn { display: flex; align-items: center; gap: 14px; padding: 14px 16px; }
|
||||
.conn-icon { font-size: 22px; }
|
||||
.err { color: var(--danger); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
104
frontend/src/components/SectionRequests.vue
Normal file
104
frontend/src/components/SectionRequests.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import type { Booking } from '../lib/types'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import { formatRange } from '../lib/tz'
|
||||
import GlassCard from './GlassCard.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const bookings = ref<Booking[] | null>(null)
|
||||
const busyId = ref<number | null>(null)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
bookings.value = await api.bookings()
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
auth.logout()
|
||||
return
|
||||
}
|
||||
toast.error(e instanceof ApiError ? e.message : 'Laden fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function accept(b: Booking) {
|
||||
busyId.value = b.id
|
||||
try {
|
||||
await api.acceptBooking(b.id)
|
||||
toast.ok('Anfrage angenommen – Zeitraum ist jetzt belegt.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Annehmen fehlgeschlagen')
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function decline(b: Booking) {
|
||||
busyId.value = b.id
|
||||
try {
|
||||
await api.declineBooking(b.id)
|
||||
toast.push('Anfrage abgelehnt.', 'info')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Ablehnen fehlgeschlagen')
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = { pending: 'Offen', accepted: 'Angenommen', declined: 'Abgelehnt' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlassCard title="Buchungsanfragen" :subtitle="`Zeitzone: ${auth.user?.timezone ?? ''} · Zeiten aus deinen Buchungsregeln`">
|
||||
<div v-if="bookings === null" class="skeleton" style="height: 90px"></div>
|
||||
<p v-else-if="bookings.length === 0" class="muted small" style="margin: 0">
|
||||
Noch keine Anfragen. Teile deinen Buchungslink, damit es losgehen kann ✨
|
||||
</p>
|
||||
<div v-else class="col">
|
||||
<article v-for="b in bookings" :key="b.id" class="glass-soft req" :class="{ dimmed: b.status !== 'pending' }">
|
||||
<div class="req-head">
|
||||
<div class="grow">
|
||||
<div class="row">
|
||||
<strong>{{ b.requester_name }}</strong>
|
||||
<span class="badge" :class="`badge-${b.status}`">{{ statusLabel[b.status] }}</span>
|
||||
</div>
|
||||
<span class="faint small">{{ b.requester_email }}</span>
|
||||
</div>
|
||||
<div class="req-actions">
|
||||
<template v-if="b.status === 'pending'">
|
||||
<button class="btn btn-sm btn-primary" :disabled="busyId === b.id" @click="accept(b)">Annehmen</button>
|
||||
<button class="btn btn-sm btn-danger" :disabled="busyId === b.id" @click="decline(b)">Ablehnen</button>
|
||||
</template>
|
||||
<button v-else-if="b.status === 'accepted'" class="btn btn-sm" :disabled="busyId === b.id" @click="decline(b)">
|
||||
Doch ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time">🕒 {{ formatRange(b.start, b.end, auth.user?.timezone ?? 'UTC') }}</div>
|
||||
<p v-if="b.message" class="msg">{{ b.message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.req { padding: 16px 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.req.dimmed { opacity: 0.6; }
|
||||
.req-head { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.req-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.time { font-size: 15px; font-weight: 600; color: var(--accent); }
|
||||
.msg {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-left: 2px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
155
frontend/src/components/SectionSettings.vue
Normal file
155
frontend/src/components/SectionSettings.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import GlassCard from './GlassCard.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const user = auth.user!
|
||||
|
||||
const weekdayNames = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
|
||||
|
||||
function minutesToHHMM(m: number): string {
|
||||
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
|
||||
}
|
||||
function hhmmToMinutes(s: string): number {
|
||||
const [h, m] = s.split(':').map((v) => parseInt(v, 10))
|
||||
return (isNaN(h) ? 0 : h) * 60 + (isNaN(m) ? 0 : m)
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: user.name,
|
||||
timezone: user.timezone,
|
||||
slot_minutes: user.slot_minutes,
|
||||
day_start: minutesToHHMM(user.day_start_min),
|
||||
day_end: minutesToHHMM(user.day_end_min),
|
||||
horizon_days: user.horizon_days,
|
||||
durations: user.durations.join(', '),
|
||||
weekdays: new Set(user.weekdays),
|
||||
})
|
||||
|
||||
let saving = false
|
||||
|
||||
async function save() {
|
||||
if (saving) return
|
||||
saving = true
|
||||
try {
|
||||
const durations = form.durations
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const res = await api.updateMe({
|
||||
name: form.name,
|
||||
timezone: form.timezone,
|
||||
slot_minutes: form.slot_minutes,
|
||||
day_start_min: hhmmToMinutes(form.day_start),
|
||||
day_end_min: hhmmToMinutes(form.day_end),
|
||||
horizon_days: form.horizon_days,
|
||||
durations,
|
||||
weekdays: [...form.weekdays].sort((a, b) => a - b),
|
||||
})
|
||||
auth.setUser(res.user)
|
||||
toast.ok('Einstellungen gespeichert')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function newSlug() {
|
||||
if (!confirm('Neuen Link erzeugen? Der alte Link funktioniert dann nicht mehr.')) return
|
||||
try {
|
||||
const res = await api.regenSlug()
|
||||
auth.setUser(res.user)
|
||||
toast.ok('Neuer Buchungslink erzeugt')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Fehler beim Erneuern')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWeekday(n: number) {
|
||||
if (form.weekdays.has(n)) form.weekdays.delete(n)
|
||||
else form.weekdays.add(n)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="col">
|
||||
<GlassCard title="Buchungsregeln" subtitle="Diese Regeln gelten für deine öffentliche Buchungsseite.">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Anzeigename</span>
|
||||
<input v-model="form.name" class="input" maxlength="80" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Zeitzone (IANA, z. B. Europe/Berlin)</span>
|
||||
<input v-model="form.timezone" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Buchbar ab / bis (Uhrzeit)</span>
|
||||
<div class="row">
|
||||
<input v-model="form.day_start" type="time" class="input" required />
|
||||
<span class="muted">–</span>
|
||||
<input v-model="form.day_end" type="time" class="input" required />
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slot-Raster (Minuten)</span>
|
||||
<select v-model.number="form.slot_minutes" class="input">
|
||||
<option :value="10">10</option>
|
||||
<option :value="15">15</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="30">30</option>
|
||||
<option :value="60">60</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Horizont (Tage im Voraus)</span>
|
||||
<input v-model.number="form.horizon_days" type="number" min="1" max="120" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Mögliche Dauern (Minuten, kommagetrennt)</span>
|
||||
<input v-model="form.durations" class="input" placeholder="15, 30, 60, 120" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="small muted" style="font-weight: 500">Buchbare Wochentage</span>
|
||||
<div class="row weekdays">
|
||||
<button
|
||||
v-for="(name, i) in weekdayNames"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="btn btn-sm weekday"
|
||||
:class="{ on: form.weekdays.has(i + 1) }"
|
||||
@click="toggleWeekday(i + 1)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button class="btn btn-primary" @click="save">Speichern</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard title="Buchungslink erneuern" subtitle="Erzeugt einen neuen Slug – der alte Link verfällt sofort.">
|
||||
<button class="btn btn-danger" @click="newSlug">Neuen Link generieren</button>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; }
|
||||
.weekdays { flex-wrap: wrap; }
|
||||
.weekday { opacity: 0.55; }
|
||||
.weekday.on {
|
||||
opacity: 1;
|
||||
border-color: rgba(125, 176, 255, 0.6);
|
||||
background: rgba(125, 176, 255, 0.12);
|
||||
}
|
||||
</style>
|
||||
46
frontend/src/components/ShareLinkCard.vue
Normal file
46
frontend/src/components/ShareLinkCard.vue
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import GlassCard from './GlassCard.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const copied = ref(false)
|
||||
|
||||
const link = computed(() => `${location.origin}/b/${auth.user?.slug ?? ''}`)
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value)
|
||||
copied.value = true
|
||||
toast.ok('Link kopiert!')
|
||||
setTimeout(() => (copied.value = false), 2000)
|
||||
} catch {
|
||||
toast.error('Kopieren nicht möglich – bitte manuell markieren.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlassCard title="🔗 Dein Buchungslink" subtitle="Teile diesen Link – andere sehen nur frei/belegt und können anfragen.">
|
||||
<div class="row link-row">
|
||||
<code class="link glass-soft grow">{{ link }}</code>
|
||||
<button class="btn btn-primary" @click="copy">{{ copied ? 'Kopiert ✓' : 'Kopieren' }}</button>
|
||||
</div>
|
||||
<p class="faint small" style="margin: 0">
|
||||
Tipp: QR-Code oder Link in Signatur, Website oder Nachrichtenaustausch einbauen.
|
||||
</p>
|
||||
</GlassCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.link-row { flex-wrap: wrap; }
|
||||
.link {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/env.d.ts
vendored
Normal file
7
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
109
frontend/src/lib/api.ts
Normal file
109
frontend/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { Booking, Connection, PublicInfo, User } from './types'
|
||||
|
||||
const TOKEN_KEY = 'wannpassts_token'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api' + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError('Server nicht erreichbar – läuft das Backend?', 0)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
let data: any = null
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch {
|
||||
/* leere Antwort */
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiError(data?.error ?? `Fehler ${res.status}`, res.status)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
register(payload: { email: string; password: string; name: string; timezone?: string }) {
|
||||
return request<{ token: string; user: User }>('POST', '/auth/register', payload)
|
||||
},
|
||||
login(payload: { email: string; password: string }) {
|
||||
return request<{ token: string; user: User }>('POST', '/auth/login', payload)
|
||||
},
|
||||
me() {
|
||||
return request<{ user: User }>('GET', '/me')
|
||||
},
|
||||
updateMe(payload: Partial<Pick<User, 'name' | 'timezone' | 'slot_minutes' | 'day_start_min' | 'day_end_min' | 'horizon_days'>> & { durations?: number[]; weekdays?: number[] }) {
|
||||
return request<{ user: User }>('PATCH', '/me', payload)
|
||||
},
|
||||
regenSlug() {
|
||||
return request<{ user: User }>('POST', '/me/slug')
|
||||
},
|
||||
|
||||
// Kalender
|
||||
connections() {
|
||||
return request<Connection[]>('GET', '/calendars')
|
||||
},
|
||||
deleteConnection(id: number) {
|
||||
return request<void>('DELETE', `/calendars/${id}`)
|
||||
},
|
||||
connectCalDAV(payload: { server_url: string; username: string; password: string; calendar_path?: string; display_name?: string }) {
|
||||
return request<Connection>('POST', '/calendars/caldav', payload)
|
||||
},
|
||||
connectICS(payload: { url: string; display_name?: string }) {
|
||||
return request<Connection>('POST', '/calendars/ics', payload)
|
||||
},
|
||||
googleStart() {
|
||||
return request<{ url: string }>('GET', '/calendars/google/start')
|
||||
},
|
||||
syncNow() {
|
||||
return request<Connection[]>('POST', '/calendars/sync')
|
||||
},
|
||||
|
||||
// Anfragen
|
||||
bookings() {
|
||||
return request<Booking[]>('GET', '/bookings')
|
||||
},
|
||||
acceptBooking(id: number) {
|
||||
return request<Booking>('POST', `/bookings/${id}/accept`)
|
||||
},
|
||||
declineBooking(id: number) {
|
||||
return request<Booking>('POST', `/bookings/${id}/decline`)
|
||||
},
|
||||
|
||||
// Öffentliche Buchungsseite
|
||||
publicInfo(slug: string) {
|
||||
return request<PublicInfo>('GET', `/public/${slug}`)
|
||||
},
|
||||
createBooking(slug: string, payload: { start: string; duration_minutes: number; name: string; email: string; message?: string }) {
|
||||
return request<{ id: number; status: string }>('POST', `/public/${slug}/bookings`, payload)
|
||||
},
|
||||
}
|
||||
46
frontend/src/lib/types.ts
Normal file
46
frontend/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
slug: string
|
||||
timezone: string
|
||||
slot_minutes: number
|
||||
day_start_min: number
|
||||
day_end_min: number
|
||||
horizon_days: number
|
||||
durations: number[]
|
||||
weekdays: number[]
|
||||
created_ts: number
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number
|
||||
provider: 'google' | 'caldav' | 'ics'
|
||||
display_name: string
|
||||
last_synced_ts: number | null
|
||||
last_error: string | null
|
||||
created_ts: number
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: number
|
||||
start: string
|
||||
end: string
|
||||
requester_name: string
|
||||
requester_email: string
|
||||
message: string
|
||||
status: 'pending' | 'accepted' | 'declined'
|
||||
created_ts: number
|
||||
}
|
||||
|
||||
export interface PublicInfo {
|
||||
name: string
|
||||
timezone: string
|
||||
slot_minutes: number
|
||||
day_start_min: number
|
||||
day_end_min: number
|
||||
horizon_days: number
|
||||
durations: number[]
|
||||
weekdays: number[]
|
||||
busy: { start: string; end: string }[]
|
||||
}
|
||||
90
frontend/src/lib/tz.ts
Normal file
90
frontend/src/lib/tz.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Zeitzonen-Helfer: Wanduhrzeiten in einer Zielzone ↔ UTC-Epoch-Millisekunden.
|
||||
|
||||
const partsCache = new Map<string, Intl.DateTimeFormat>()
|
||||
|
||||
function tzPartsFmt(tz: string): Intl.DateTimeFormat {
|
||||
let f = partsCache.get(tz)
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
weekday: 'short',
|
||||
})
|
||||
partsCache.set(tz, f)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
export interface ZonedParts {
|
||||
year: number
|
||||
month: number // 1–12
|
||||
day: number
|
||||
hour: number
|
||||
minute: number
|
||||
weekday: number // ISO: Mo=1 … So=7
|
||||
minuteOfDay: number
|
||||
}
|
||||
|
||||
const weekdayMap: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }
|
||||
|
||||
export function partsInTz(date: Date, tz: string): ZonedParts {
|
||||
const parts = tzPartsFmt(tz).formatToParts(date)
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0'
|
||||
const hour = +get('hour') % 24
|
||||
const minute = +get('minute')
|
||||
return {
|
||||
year: +get('year'),
|
||||
month: +get('month'),
|
||||
day: +get('day'),
|
||||
hour,
|
||||
minute,
|
||||
weekday: weekdayMap[get('weekday')] ?? 1,
|
||||
minuteOfDay: hour * 60 + minute,
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset der Zone zum Zeitpunkt (ms), z. B. +2h für MESZ. */
|
||||
export function tzOffsetMs(date: Date, tz: string): number {
|
||||
const p = partsInTz(date, tz)
|
||||
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, date.getUTCSeconds())
|
||||
return asUtc - date.getTime()
|
||||
}
|
||||
|
||||
/** Wanduhrzeit (y, mo 1–12, d, h, min) in Zone tz → UTC-Epoch-Millisekunden. */
|
||||
export function zonedToUtc(year: number, month: number, day: number, hour: number, minute: number, tz: string): number {
|
||||
const naive = Date.UTC(year, month - 1, day, hour, minute, 0)
|
||||
let ts = naive
|
||||
for (let i = 0; i < 2; i++) {
|
||||
ts = naive - tzOffsetMs(new Date(ts), tz)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
export function formatTime(tsMs: number, tz: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', { timeZone: tz, hour: '2-digit', minute: '2-digit' }).format(new Date(tsMs))
|
||||
}
|
||||
|
||||
export function formatDayShort(tsMs: number, tz: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', { timeZone: tz, weekday: 'short', day: '2-digit', month: 'short' }).format(new Date(tsMs))
|
||||
}
|
||||
|
||||
export function formatRange(startISO: string, endISO: string, tz: string): string {
|
||||
const day = formatDayShort(new Date(startISO).getTime(), tz)
|
||||
const t1 = formatTime(new Date(startISO).getTime(), tz)
|
||||
const t2 = formatTime(new Date(endISO).getTime(), tz)
|
||||
return `${day} · ${t1}–${t2} Uhr`
|
||||
}
|
||||
|
||||
export function guessTimezone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'
|
||||
} catch {
|
||||
return 'Europe/Berlin'
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.ts
Normal file
10
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
30
frontend/src/router/index.ts
Normal file
30
frontend/src/router/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken } from '../lib/api'
|
||||
import LandingView from '../views/LandingView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import RegisterView from '../views/RegisterView.vue'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import BookingView from '../views/BookingView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'landing', component: LandingView },
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/register', name: 'register', component: RegisterView },
|
||||
{ path: '/app', name: 'dashboard', component: DashboardView, meta: { requiresAuth: true } },
|
||||
{ path: '/b/:slug', name: 'booking', component: BookingView },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth && !getToken()) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if ((to.name === 'login' || to.name === 'register' || to.name === 'landing') && getToken()) {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
47
frontend/src/stores/auth.ts
Normal file
47
frontend/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { api, clearToken, getToken, setToken } from '../lib/api'
|
||||
import type { User } from '../lib/types'
|
||||
|
||||
export const useAuth = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const ready = ref(false)
|
||||
|
||||
async function init() {
|
||||
if (!getToken()) {
|
||||
ready.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.me()
|
||||
user.value = res.user
|
||||
} catch {
|
||||
clearToken()
|
||||
} finally {
|
||||
ready.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const res = await api.login({ email, password })
|
||||
setToken(res.token)
|
||||
user.value = res.user
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, name: string, timezone: string) {
|
||||
const res = await api.register({ email, password, name, timezone })
|
||||
setToken(res.token)
|
||||
user.value = res.user
|
||||
}
|
||||
|
||||
function setUser(u: User) {
|
||||
user.value = u
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, ready, init, login, register, setUser, logout }
|
||||
})
|
||||
27
frontend/src/stores/toast.ts
Normal file
27
frontend/src/stores/toast.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
kind: 'ok' | 'error' | 'info'
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export const useToast = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
|
||||
function push(message: string, kind: Toast['kind'] = 'info') {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, kind })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
const ok = (m: string) => push(m, 'ok')
|
||||
const error = (m: string) => push(m, 'error')
|
||||
|
||||
return { toasts, push, ok, error }
|
||||
})
|
||||
324
frontend/src/styles/main.css
Normal file
324
frontend/src/styles/main.css
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
/* ─── WannPassts · Liquid Glass Dark ──────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--bg-0: #05070c;
|
||||
--bg-1: #0a0e18;
|
||||
--text: #eef1f8;
|
||||
--text-muted: rgba(238, 241, 248, 0.58);
|
||||
--text-faint: rgba(238, 241, 248, 0.34);
|
||||
--border: rgba(255, 255, 255, 0.13);
|
||||
--border-soft: rgba(255, 255, 255, 0.08);
|
||||
--accent: #7db0ff;
|
||||
--accent-2: #b78cff;
|
||||
--accent-3: #6fe3c4;
|
||||
--danger: #ff7a8a;
|
||||
--ok: #46e0a5;
|
||||
--radius-lg: 24px;
|
||||
--radius-md: 16px;
|
||||
--radius-sm: 12px;
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(1200px 800px at 85% -10%, rgba(125, 176, 255, 0.10), transparent 60%),
|
||||
radial-gradient(1000px 700px at -10% 30%, rgba(183, 140, 255, 0.09), transparent 55%),
|
||||
radial-gradient(900px 600px at 50% 110%, rgba(111, 227, 196, 0.06), transparent 60%),
|
||||
linear-gradient(180deg, var(--bg-1), var(--bg-0));
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#app { min-height: 100vh; }
|
||||
|
||||
/* Farbliche "Orbs" hinter allem – geben dem Glass etwas zum Brechen */
|
||||
.bg-scene { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; }
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(110px);
|
||||
opacity: 0.45;
|
||||
animation: orb-drift 26s ease-in-out infinite alternate;
|
||||
}
|
||||
.orb-1 { width: 46vw; height: 46vw; left: -10vw; top: -14vh; background: radial-gradient(circle, #2b5cc4, transparent 70%); }
|
||||
.orb-2 { width: 40vw; height: 40vw; right: -8vw; top: 22vh; background: radial-gradient(circle, #6d3fb8, transparent 70%); animation-delay: -8s; }
|
||||
.orb-3 { width: 34vw; height: 34vw; left: 28vw; bottom: -18vh; background: radial-gradient(circle, #1a7f68, transparent 70%); animation-delay: -16s; }
|
||||
|
||||
@keyframes orb-drift {
|
||||
from { transform: translate3d(0, 0, 0) scale(1); }
|
||||
to { transform: translate3d(6vw, 5vh, 0) scale(1.12); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ─── Glass-Bausteine ─────────────────────────────────────────────────────── */
|
||||
|
||||
.glass {
|
||||
background: linear-gradient(150deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.028));
|
||||
backdrop-filter: blur(26px) saturate(170%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(170%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
.glass-soft {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* ─── Layout ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
max-width: 1060px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 80px;
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 12px; align-items: center; }
|
||||
.col { display: flex; flex-direction: column; gap: 12px; }
|
||||
.grow { flex: 1; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.faint { color: var(--text-faint); }
|
||||
.small { font-size: 13px; }
|
||||
|
||||
/* ─── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(150deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.04));
|
||||
color: var(--text);
|
||||
font: 600 14px/1 var(--font);
|
||||
padding: 11px 20px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: rgba(255, 255, 255, 0.24); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
border: none;
|
||||
color: #071018;
|
||||
box-shadow: 0 10px 34px rgba(125, 176, 255, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.btn-primary:hover { box-shadow: 0 14px 40px rgba(125, 176, 255, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.35); }
|
||||
|
||||
.btn-danger {
|
||||
border-color: rgba(255, 122, 138, 0.4);
|
||||
color: var(--danger);
|
||||
background: rgba(255, 122, 138, 0.08);
|
||||
}
|
||||
.btn-danger:hover { border-color: rgba(255, 122, 138, 0.7); box-shadow: 0 8px 24px rgba(255, 122, 138, 0.18); }
|
||||
|
||||
.btn-sm { padding: 8px 14px; font-size: 13px; }
|
||||
|
||||
/* ─── Formulare ───────────────────────────────────────────────────────────── */
|
||||
|
||||
label.field { display: flex; flex-direction: column; gap: 7px; }
|
||||
label.field > span { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
||||
|
||||
.input, select.input, textarea.input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font: 500 14px/1.4 var(--font);
|
||||
padding: 11px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: rgba(125, 176, 255, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(125, 176, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.input::placeholder { color: var(--text-faint); }
|
||||
select.input option { background: #10141f; color: var(--text); }
|
||||
textarea.input { resize: vertical; min-height: 84px; }
|
||||
|
||||
/* ─── Badges & Chips ──────────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.badge-pending { border-color: rgba(125, 176, 255, 0.45); color: var(--accent); background: rgba(125, 176, 255, 0.10); }
|
||||
.badge-accepted { border-color: rgba(70, 224, 165, 0.45); color: var(--ok); background: rgba(70, 224, 165, 0.10); }
|
||||
.badge-declined { border-color: rgba(255, 122, 138, 0.4); color: var(--danger); background: rgba(255, 122, 138, 0.08); }
|
||||
|
||||
/* ─── Tabs ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: 600 14px/1 var(--font);
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
background: linear-gradient(150deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.06));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 6px 18px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ─── Toasts ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 100;
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
}
|
||||
.toast {
|
||||
padding: 13px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
animation: toast-in 0.25s ease;
|
||||
}
|
||||
.toast-ok { border-color: rgba(70, 224, 165, 0.5); }
|
||||
.toast-error { border-color: rgba(255, 122, 138, 0.5); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
/* ─── Slot-Chips (Buchungsseite) ──────────────────────────────────────────── */
|
||||
|
||||
.slot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.slot-chip {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
font: 600 14px/1 var(--font);
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.13s ease;
|
||||
}
|
||||
.slot-chip:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(125, 176, 255, 0.5);
|
||||
background: rgba(125, 176, 255, 0.10);
|
||||
}
|
||||
.slot-chip.selected {
|
||||
color: #071018;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 8px 26px rgba(125, 176, 255, 0.4);
|
||||
}
|
||||
.slot-chip:disabled { opacity: 0.28; cursor: not-allowed; text-decoration: line-through; }
|
||||
|
||||
/* ─── Day-Picker ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.day-strip {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.day-chip {
|
||||
appearance: none;
|
||||
flex: 0 0 auto;
|
||||
min-width: 74px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font);
|
||||
padding: 10px 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
transition: all 0.13s ease;
|
||||
}
|
||||
.day-chip:hover { border-color: var(--border); color: var(--text); }
|
||||
.day-chip.selected {
|
||||
color: var(--text);
|
||||
border-color: rgba(125, 176, 255, 0.55);
|
||||
background: linear-gradient(150deg, rgba(125, 176, 255, 0.16), rgba(183, 140, 255, 0.10));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.day-chip .wd { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.day-chip .dom { font-size: 20px; font-weight: 700; color: inherit; }
|
||||
.day-chip .mon { font-size: 11px; }
|
||||
|
||||
/* ─── Verschiedenes ───────────────────────────────────────────────────────── */
|
||||
|
||||
.divider { height: 1px; background: var(--border-soft); border: none; margin: 4px 0; }
|
||||
|
||||
.skeleton {
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(100deg, rgba(255,255,255,0.04) 40%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 60%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(6px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 16px 14px 64px; }
|
||||
}
|
||||
309
frontend/src/views/BookingView.vue
Normal file
309
frontend/src/views/BookingView.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import type { PublicInfo } from '../lib/types'
|
||||
import { useToast } from '../stores/toast'
|
||||
import { formatTime, partsInTz, zonedToUtc } from '../lib/tz'
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const info = ref<PublicInfo | null>(null)
|
||||
const notFound = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
interface Day {
|
||||
y: number
|
||||
m: number
|
||||
d: number
|
||||
weekday: number
|
||||
key: string
|
||||
weekdayLabel: string
|
||||
monthLabel: string
|
||||
}
|
||||
|
||||
const selectedDay = ref<number>(0)
|
||||
const duration = ref(30)
|
||||
const selectedSlot = ref<number | null>(null)
|
||||
const form = ref({ name: '', email: '', message: '' })
|
||||
const sending = ref(false)
|
||||
const done = ref(false)
|
||||
|
||||
const days = computed<Day[]>(() => {
|
||||
if (!info.value) return []
|
||||
const tz = info.value.timezone
|
||||
const out: Day[] = []
|
||||
const now = new Date()
|
||||
const today = partsInTz(now, tz)
|
||||
for (let i = 0; i < info.value.horizon_days && out.length < 60; i++) {
|
||||
// Datum in der Zielzone um i Tage verschieben: über UTC-Mitternacht laufen
|
||||
const ts = zonedToUtc(today.year, today.month, today.day + i, 12, 0, tz)
|
||||
const p = partsInTz(new Date(ts), tz)
|
||||
if (!info.value.weekdays.includes(p.weekday)) continue
|
||||
out.push({
|
||||
y: p.year,
|
||||
m: p.month,
|
||||
d: p.day,
|
||||
weekday: p.weekday,
|
||||
key: `${p.year}-${p.month}-${p.day}`,
|
||||
weekdayLabel: new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(new Date(ts)),
|
||||
monthLabel: new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(new Date(ts)),
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const busyMs = computed<[number, number][]>(() =>
|
||||
(info.value?.busy ?? []).map((b) => [new Date(b.start).getTime(), new Date(b.end).getTime()]),
|
||||
)
|
||||
|
||||
interface Slot {
|
||||
ts: number
|
||||
label: string
|
||||
free: boolean
|
||||
}
|
||||
|
||||
const LEAD_MS = 10 * 60 * 1000 // min. 10 Minuten Vorlauf
|
||||
|
||||
const slots = computed<Slot[]>(() => {
|
||||
const i = info.value
|
||||
const day = days.value[selectedDay.value]
|
||||
if (!i || !day) return []
|
||||
const tz = i.timezone
|
||||
const out: Slot[] = []
|
||||
for (let m = i.day_start_min; m + duration.value <= i.day_end_min; m += i.slot_minutes) {
|
||||
const ts = zonedToUtc(day.y, day.m, day.d, Math.floor(m / 60), m % 60, tz)
|
||||
const end = ts + duration.value * 60_000
|
||||
const free =
|
||||
ts > Date.now() + LEAD_MS && !busyMs.value.some(([bStart, bEnd]) => ts < bEnd && bStart < end)
|
||||
out.push({ ts, label: formatTime(ts, tz), free })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const selectedDayLabel = computed(() => {
|
||||
const day = days.value[selectedDay.value]
|
||||
if (!day) return ''
|
||||
const date = new Intl.DateTimeFormat('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }).format(
|
||||
new Date(day.y, day.m - 1, day.d),
|
||||
)
|
||||
return date
|
||||
})
|
||||
|
||||
watch(duration, () => (selectedSlot.value = null))
|
||||
watch(selectedDay, () => (selectedSlot.value = null))
|
||||
|
||||
const selectedSlotLabel = computed(() => {
|
||||
if (selectedSlot.value == null || !info.value) return ''
|
||||
const tz = info.value.timezone
|
||||
return `${formatTime(selectedSlot.value, tz)} – ${formatTime(selectedSlot.value + duration.value * 60_000, tz)} Uhr`
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (selectedSlot.value == null) return
|
||||
sending.value = true
|
||||
try {
|
||||
await api.createBooking(slug, {
|
||||
start: new Date(selectedSlot.value).toISOString().replace('.000', ''),
|
||||
duration_minutes: duration.value,
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
message: form.value.message || undefined,
|
||||
})
|
||||
done.value = true
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Senden fehlgeschlagen')
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
const res = await api.publicInfo(slug).catch(() => null)
|
||||
if (res) info.value = res
|
||||
selectedSlot.value = null
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
info.value = await api.publicInfo(slug)
|
||||
if (info.value.durations.length > 0) {
|
||||
duration.value = info.value.durations[Math.floor(info.value.durations.length / 2)]
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound.value = true
|
||||
else loadError.value = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page booking">
|
||||
<!-- Fehlerzustände -->
|
||||
<div v-if="notFound" class="glass state">
|
||||
<LogoMark />
|
||||
<h1>Seite nicht gefunden</h1>
|
||||
<p class="muted">Dieser Buchungslink existiert nicht (mehr).</p>
|
||||
</div>
|
||||
<div v-else-if="loadError" class="glass state">
|
||||
<h1>Hoppla</h1>
|
||||
<p class="muted">{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Erfolg -->
|
||||
<div v-else-if="done" class="glass state">
|
||||
<div class="big-check">✓</div>
|
||||
<h1>Anfrage gesendet!</h1>
|
||||
<p class="muted">
|
||||
{{ info?.name }} erhält deine Anfrage für
|
||||
<strong>{{ selectedDayLabel }}</strong> um <strong>{{ selectedSlotLabel }}</strong>
|
||||
und kann sie bestätigen.
|
||||
</p>
|
||||
<p class="faint small">Du hörst dann per E-Mail oder persönlich von {{ info?.name }}.</p>
|
||||
</div>
|
||||
|
||||
<!-- Laden -->
|
||||
<div v-else-if="!info" class="glass state">
|
||||
<div class="skeleton" style="width: 260px; height: 40px"></div>
|
||||
<div class="skeleton" style="width: 320px; height: 120px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Buchung -->
|
||||
<template v-else>
|
||||
<header class="glass head">
|
||||
<div class="row">
|
||||
<LogoMark />
|
||||
<div>
|
||||
<h1>Buchung bei {{ info.name }}</h1>
|
||||
<p class="muted small" style="margin: 2px 0 0">
|
||||
Wähle einen freien Zeitraum. Siehst nur <em>frei</em> oder <em>belegt</em> –
|
||||
niemals die konkreten Termine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend small">
|
||||
<span class="dot ok"></span> frei & buchbar
|
||||
<span class="dot busy"></span> belegt
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="glass block">
|
||||
<h2>1 · Tag wählen</h2>
|
||||
<div class="day-strip">
|
||||
<button
|
||||
v-for="(d, i) in days"
|
||||
:key="d.key"
|
||||
class="day-chip"
|
||||
:class="{ selected: selectedDay === i }"
|
||||
@click="selectedDay = i"
|
||||
>
|
||||
<span class="wd">{{ d.weekdayLabel }}</span>
|
||||
<span class="dom">{{ d.d }}</span>
|
||||
<span class="mon">{{ d.monthLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass block">
|
||||
<div class="row block-head">
|
||||
<h2 class="grow">2 · Freien Zeitraum wählen</h2>
|
||||
<label class="row small" style="gap: 8px">
|
||||
<span class="muted">Dauer</span>
|
||||
<select v-model.number="duration" class="input" style="width: auto">
|
||||
<option v-for="d in info.durations" :key="d" :value="d">{{ d }} Min.</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="muted small" style="margin: -6px 0 0">{{ selectedDayLabel }} · Zeitzone {{ info.timezone }}</p>
|
||||
|
||||
<div v-if="slots.length === 0" class="muted small">An diesem Tag sind keine Zeiten buchbar.</div>
|
||||
<div v-else-if="slots.every((s) => !s.free)" class="muted small">
|
||||
Für {{ duration }} Min. ist an diesem Tag leider nichts mehr frei – anderer Tag oder andere Dauer?
|
||||
</div>
|
||||
<div v-else class="slot-grid">
|
||||
<button
|
||||
v-for="s in slots"
|
||||
:key="s.ts"
|
||||
class="slot-chip"
|
||||
:class="{ selected: selectedSlot === s.ts }"
|
||||
:disabled="!s.free"
|
||||
@click="selectedSlot = s.ts"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass block">
|
||||
<h2>3 · Anfrage senden</h2>
|
||||
<p v-if="selectedSlot != null" class="chosen">
|
||||
🗓 {{ selectedDayLabel }} · <strong>{{ selectedSlotLabel }}</strong> ({{ duration }} Min.)
|
||||
</p>
|
||||
<p v-else class="muted small" style="margin: 0">Zuerst oben einen freien Zeitraum auswählen.</p>
|
||||
|
||||
<form v-if="selectedSlot != null" class="form" @submit.prevent="submit">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Dein Name</span>
|
||||
<input v-model="form.name" required maxlength="100" class="input" placeholder="Erika Musterfrau" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Deine E-Mail</span>
|
||||
<input v-model="form.email" type="email" required maxlength="254" class="input" placeholder="du@beispiel.de" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Nachricht (optional)</span>
|
||||
<textarea v-model="form.message" maxlength="2000" class="input" placeholder="Worum geht's?"></textarea>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit" :disabled="sending">
|
||||
{{ sending ? 'Senden…' : 'Buchungsanfrage senden' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer class="row center faint small" style="justify-content: center">
|
||||
<LogoMark /> WannPassts
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking { display: flex; flex-direction: column; gap: 16px; max-width: 780px; }
|
||||
.state {
|
||||
margin-top: 18vh;
|
||||
padding: 48px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.state h1 { margin: 0; font-size: 26px; }
|
||||
.big-check {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 34px;
|
||||
color: #071018;
|
||||
background: linear-gradient(135deg, var(--ok), var(--accent-3));
|
||||
box-shadow: 0 14px 44px rgba(70, 224, 165, 0.4);
|
||||
}
|
||||
.head { padding: 22px 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.head h1 { margin: 0; font-size: 22px; }
|
||||
.legend { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
||||
.dot.ok { background: var(--ok); box-shadow: 0 0 10px rgba(70, 224, 165, 0.7); }
|
||||
.dot.busy { background: var(--danger); box-shadow: 0 0 10px rgba(255, 122, 138, 0.6); }
|
||||
.block { padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.block h2 { margin: 0; font-size: 15px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); }
|
||||
.block-head { align-items: baseline; }
|
||||
.chosen { margin: 0; font-size: 15px; }
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
||||
</style>
|
||||
149
frontend/src/views/DashboardView.vue
Normal file
149
frontend/src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
import ShareLinkCard from '../components/ShareLinkCard.vue'
|
||||
import SectionConnections from '../components/SectionConnections.vue'
|
||||
import SectionRequests from '../components/SectionRequests.vue'
|
||||
import SectionSettings from '../components/SectionSettings.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tab = ref<'overview' | 'requests' | 'calendars' | 'settings'>('overview')
|
||||
const loading = ref(true)
|
||||
|
||||
const tabs = [
|
||||
{ value: 'overview', label: 'Übersicht' },
|
||||
{ value: 'requests', label: 'Anfragen' },
|
||||
{ value: 'calendars', label: 'Kalender' },
|
||||
{ value: 'settings', label: 'Einstellungen' },
|
||||
] as const
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.init()
|
||||
if (!auth.user) {
|
||||
auth.logout()
|
||||
router.push({ name: 'login' })
|
||||
return
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
if (route.query.google === 'ok') {
|
||||
toast.ok('Google Kalender verbunden 🎉')
|
||||
tab.value = 'calendars'
|
||||
} else if (route.query.google === 'error') {
|
||||
toast.error('Google-Verbindung fehlgeschlagen: ' + (route.query.reason || 'unbekannter Fehler'))
|
||||
tab.value = 'calendars'
|
||||
} else if (typeof route.query.tab === 'string') {
|
||||
tab.value = route.query.tab as typeof tab.value
|
||||
}
|
||||
if (Object.keys(route.query).length > 0) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page dash" v-if="!loading && auth.user">
|
||||
<header class="glass dash-header">
|
||||
<div class="row">
|
||||
<LogoMark />
|
||||
<strong class="brand">WannPassts</strong>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="muted small hide-sm">{{ auth.user.email }}</span>
|
||||
<button class="btn btn-sm" @click="logout">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
:key="t.value"
|
||||
class="tab"
|
||||
:class="{ active: tab === t.value }"
|
||||
@click="tab = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div v-if="tab === 'overview'" class="col overview">
|
||||
<ShareLinkCard />
|
||||
<div class="overview-grid">
|
||||
<router-link :to="{ query: { tab: 'calendars' } }" class="glass tile" @click="tab = 'calendars'">
|
||||
<span class="tile-icon">📆</span>
|
||||
<strong>Kalender verbinden</strong>
|
||||
<span class="muted small">Google, iCloud/CalDAV oder ICS-Link hinzufügen</span>
|
||||
</router-link>
|
||||
<router-link :to="{ query: { tab: 'requests' } }" class="glass tile" @click="tab = 'requests'">
|
||||
<span class="tile-icon">📨</span>
|
||||
<strong>Anfragen prüfen</strong>
|
||||
<span class="muted small">Buchungsanfragen annehmen oder ablehnen</span>
|
||||
</router-link>
|
||||
<router-link :to="{ query: { tab: 'settings' } }" class="glass tile" @click="tab = 'settings'">
|
||||
<span class="tile-icon">⚙️</span>
|
||||
<strong>Buchungsregeln</strong>
|
||||
<span class="muted small">Zeitfenster, Dauern, Slot-Größe festlegen</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<section class="glass privacy">
|
||||
<strong>🔒 Datenschutz by Design</strong>
|
||||
<p class="muted small" style="margin: 6px 0 0">
|
||||
Besucher deiner Buchungsseite sehen ausschließlich <em>frei</em> oder <em>belegt</em> –
|
||||
niemals Titel, Ort oder Beschreibung deiner Termine. Google-Anbindungen nutzen die
|
||||
FreeBusy-API, die strukturell keine Termindetails liefert.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SectionRequests v-else-if="tab === 'requests'" />
|
||||
<SectionConnections v-else-if="tab === 'calendars'" />
|
||||
<SectionSettings v-else />
|
||||
</main>
|
||||
|
||||
<main v-else class="page" style="display: flex; justify-content: center; padding-top: 20vh">
|
||||
<div class="skeleton" style="width: 320px; height: 120px"></div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash { display: flex; flex-direction: column; gap: 18px; }
|
||||
.dash-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
.brand { font-size: 17px; letter-spacing: -0.01em; }
|
||||
.overview { gap: 18px; }
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 22px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tile:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.24); }
|
||||
.tile-icon { font-size: 26px; }
|
||||
.privacy { padding: 20px 24px; }
|
||||
@media (max-width: 520px) { .hide-sm { display: none; } }
|
||||
</style>
|
||||
102
frontend/src/views/LandingView.vue
Normal file
102
frontend/src/views/LandingView.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
import GlassCard from '../components/GlassCard.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page landing">
|
||||
<section class="hero glass">
|
||||
<div class="hero-glow" aria-hidden="true"></div>
|
||||
<LogoMark />
|
||||
<h1>Wann<span class="grad">Passts</span></h1>
|
||||
<p class="tagline">
|
||||
Deine freien Zeiten – geteilt per Link.<br />
|
||||
Verbinde deinen Kalender, lass andere buchen und gib dabei <strong>keine Termindetails</strong> preis.
|
||||
</p>
|
||||
<div class="row center">
|
||||
<router-link to="/register" class="btn btn-primary">Kostenlos starten</router-link>
|
||||
<router-link to="/login" class="btn">Anmelden</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<GlassCard title="🔐 Kalender verbinden">
|
||||
<p class="muted small">
|
||||
Google Kalender, iCloud (CalDAV) oder jeder ICS-Link. WannPassts liest ausschließlich
|
||||
<em>beschäftigt / frei</em> – Titel, Ort und Notizen bleiben auf deinem Gerät bzw. beim Anbieter.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard title="🔗 Link teilen">
|
||||
<p class="muted small">
|
||||
Deine persönliche Buchungsseite zeigt nur freie und belegte Zeitfenster. Freunde, Kunden
|
||||
und Kollegen wählen einen freien Slot und stellen eine Anfrage.
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard title="✅ Anfragen entscheiden">
|
||||
<p class="muted small">
|
||||
Anfragen landen in deinem Dashboard: annehmen oder ablehnen. Angenommene Zeiten werden
|
||||
automatisch als belegt markiert – Doppelbuchungen werden verhindert.
|
||||
</p>
|
||||
</GlassCard>
|
||||
</section>
|
||||
|
||||
<p class="faint small foot">Liquid Glass · Vue 3 + Go · Deine Termine bleiben deine Termine.</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding-top: 6vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 56px 32px 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: radial-gradient(600px 300px at 50% 0%, rgba(125, 176, 255, 0.16), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
h1 {
|
||||
margin: 6px 0 0;
|
||||
font-size: clamp(40px, 7vw, 64px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.grad {
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.tagline {
|
||||
margin: 0;
|
||||
max-width: 460px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
font-size: 16px;
|
||||
}
|
||||
.row.center { justify-content: center; }
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.foot { text-align: center; }
|
||||
|
||||
a.btn { text-decoration: none; display: inline-block; }
|
||||
</style>
|
||||
82
frontend/src/views/LoginView.vue
Normal file
82
frontend/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
try {
|
||||
await auth.login(email.value, password.value)
|
||||
toast.ok('Willkommen zurück!')
|
||||
router.push((route.query.redirect as string) || '/app')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Anmeldung fehlgeschlagen')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page auth">
|
||||
<form class="glass card" @submit.prevent="submit">
|
||||
<div class="row" style="justify-content: center">
|
||||
<LogoMark />
|
||||
</div>
|
||||
<h1>Willkommen zurück</h1>
|
||||
<p class="muted small center">Melde dich an, um deine Buchungsseite zu verwalten.</p>
|
||||
|
||||
<label class="field">
|
||||
<span>E-Mail</span>
|
||||
<input v-model="email" type="email" required autocomplete="email" class="input" placeholder="du@beispiel.de" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort</span>
|
||||
<input v-model="password" type="password" required autocomplete="current-password" class="input" placeholder="••••••••" />
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy || !email || !password">
|
||||
{{ busy ? 'Anmelden…' : 'Anmelden' }}
|
||||
</button>
|
||||
|
||||
<p class="muted small center">
|
||||
Noch kein Konto?
|
||||
<router-link to="/register">Jetzt registrieren</router-link>
|
||||
</p>
|
||||
<p class="small center"><router-link to="/">← Zur Startseite</router-link></p>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: min(420px, 100%);
|
||||
padding: 34px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.card p { margin: 0; }
|
||||
h1 { margin: 4px 0 0; font-size: 24px; text-align: center; }
|
||||
.center { text-align: center; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
90
frontend/src/views/RegisterView.vue
Normal file
90
frontend/src/views/RegisterView.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useAuth } from '../stores/auth'
|
||||
import { useToast } from '../stores/toast'
|
||||
import { guessTimezone } from '../lib/tz'
|
||||
import LogoMark from '../components/LogoMark.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (password.value.length < 8) {
|
||||
toast.error('Das Passwort braucht mindestens 8 Zeichen.')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await auth.register(email.value, password.value, name.value, guessTimezone())
|
||||
toast.ok('Konto erstellt – willkommen bei WannPassts!')
|
||||
router.push('/app')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Registrierung fehlgeschlagen')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page auth">
|
||||
<form class="glass card" @submit.prevent="submit">
|
||||
<div class="row" style="justify-content: center">
|
||||
<LogoMark />
|
||||
</div>
|
||||
<h1>Konto erstellen</h1>
|
||||
<p class="muted small center">In zwei Minuten buchbar – ganz ohne Termine preiszugeben.</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Name (sehen Besucher deiner Buchungsseite)</span>
|
||||
<input v-model="name" required maxlength="80" class="input" placeholder="Max Mustermann" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>E-Mail</span>
|
||||
<input v-model="email" type="email" required autocomplete="email" class="input" placeholder="du@beispiel.de" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Passwort (min. 8 Zeichen)</span>
|
||||
<input v-model="password" type="password" required autocomplete="new-password" class="input" placeholder="••••••••" />
|
||||
</label>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="busy || !name || !email || !password">
|
||||
{{ busy ? 'Wird erstellt…' : 'Konto erstellen' }}
|
||||
</button>
|
||||
|
||||
<p class="muted small center">
|
||||
Schon dabei? <router-link to="/login">Anmelden</router-link>
|
||||
</p>
|
||||
<p class="small center"><router-link to="/">← Zur Startseite</router-link></p>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
width: min(420px, 100%);
|
||||
padding: 34px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.card p { margin: 0; }
|
||||
h1 { margin: 4px 0 0; font-size: 24px; text-align: center; }
|
||||
.center { text-align: center; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue