- Fix session persistence: add loading state to App.vue, hydrate portal store in router guards so page refresh preserves auth + event context - Fix shift visibility for festivals: query child event time slots so shifts on sub-events appear in the portal - Add profile page with editable personal info and password change - Add backend endpoints: PUT /portal/profile and PUT /portal/password - Fix registration form: make first_name/last_name editable for logged-in users - Restyle login page: remove Vuexy illustration, center form with Crewli branding - Improve dashboard StatusCard with action cards, icons, and upcoming shift count - Enhance shift cards with status border colors and availability progress bars Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
224 lines
6.4 KiB
TypeScript
224 lines
6.4 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
import { apiClient } from '@/lib/axios'
|
|
import type { AuthMeUser, PortalEvent, PortalPersonPayload } from '@/types/portal'
|
|
|
|
const STORAGE_EVENTS = 'crewli_portal_user_events_v1'
|
|
const STORAGE_ACTIVE_EVENT = 'crewli_portal_active_event_id_v1'
|
|
|
|
function readStoredEvents(): PortalEvent[] {
|
|
if (typeof localStorage === 'undefined') return []
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_EVENTS)
|
|
if (!raw) return []
|
|
|
|
const parsed = JSON.parse(raw) as unknown
|
|
if (!Array.isArray(parsed)) return []
|
|
|
|
return parsed.filter(
|
|
(e): e is PortalEvent =>
|
|
typeof e === 'object'
|
|
&& e !== null
|
|
&& 'event_id' in e
|
|
&& 'event_name' in e
|
|
&& 'person_status' in e,
|
|
)
|
|
}
|
|
catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function writeStoredEvents(events: PortalEvent[]): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
localStorage.setItem(STORAGE_EVENTS, JSON.stringify(events))
|
|
}
|
|
|
|
function readStoredActiveEventId(): string | null {
|
|
if (typeof localStorage === 'undefined') return null
|
|
|
|
return localStorage.getItem(STORAGE_ACTIVE_EVENT)
|
|
}
|
|
|
|
function writeStoredActiveEventId(id: string | null): void {
|
|
if (typeof localStorage === 'undefined') return
|
|
if (id) localStorage.setItem(STORAGE_ACTIVE_EVENT, id)
|
|
else localStorage.removeItem(STORAGE_ACTIVE_EVENT)
|
|
}
|
|
|
|
function mergeEvents(apiEvents: PortalEvent[], stored: PortalEvent[]): PortalEvent[] {
|
|
const map = new Map<string, PortalEvent>()
|
|
for (const e of stored) map.set(e.event_id, { ...e })
|
|
for (const e of apiEvents) {
|
|
const prev = map.get(e.event_id)
|
|
map.set(e.event_id, {
|
|
...prev,
|
|
...e,
|
|
organisation_name: e.organisation_name || prev?.organisation_name || '',
|
|
})
|
|
}
|
|
|
|
return Array.from(map.values()).sort((a, b) => b.start_date.localeCompare(a.start_date))
|
|
}
|
|
|
|
export const usePortalStore = defineStore('portal', () => {
|
|
const activeEventId = ref<string | null>(readStoredActiveEventId())
|
|
const userEvents = ref<PortalEvent[]>([])
|
|
const currentPerson = ref<PortalPersonPayload | null>(null)
|
|
const isLoadingEvents = ref(false)
|
|
const isLoadingPerson = ref(false)
|
|
const loadError = ref<string | null>(null)
|
|
|
|
const activeEvent = computed(() => userEvents.value.find(e => e.event_id === activeEventId.value) ?? null)
|
|
|
|
function persistActiveEvent(): void {
|
|
writeStoredActiveEventId(activeEventId.value)
|
|
}
|
|
|
|
function persistEvents(): void {
|
|
writeStoredEvents(userEvents.value)
|
|
}
|
|
|
|
/**
|
|
* Call after successful public registration so the volunteer sees the event on the dashboard.
|
|
* TODO: replace with `portal_events` from GET /auth/me when the API exposes it.
|
|
*/
|
|
function savePendingEventFromRegistration(event: PortalEvent): void {
|
|
const merged = mergeEvents([], [...readStoredEvents(), ...userEvents.value, event])
|
|
userEvents.value = merged
|
|
persistEvents()
|
|
if (!activeEventId.value || !merged.some(e => e.event_id === activeEventId.value)) {
|
|
activeEventId.value = event.event_id
|
|
persistActiveEvent()
|
|
}
|
|
}
|
|
|
|
async function loadUserEventsFromApiAndStorage(): Promise<void> {
|
|
isLoadingEvents.value = true
|
|
loadError.value = null
|
|
try {
|
|
const stored = readStoredEvents()
|
|
let apiEvents: PortalEvent[] = []
|
|
try {
|
|
const { data } = await apiClient.get<{ success: boolean; data: AuthMeUser }>('/auth/me')
|
|
apiEvents = data.data.portal_events ?? []
|
|
}
|
|
catch {
|
|
// /auth/me failed — still show locally stored registrations
|
|
}
|
|
userEvents.value = mergeEvents(apiEvents, stored)
|
|
persistEvents()
|
|
}
|
|
catch (e) {
|
|
loadError.value = 'Kon je evenementen niet laden.'
|
|
userEvents.value = readStoredEvents()
|
|
}
|
|
finally {
|
|
isLoadingEvents.value = false
|
|
}
|
|
}
|
|
|
|
function resolveActiveEventId(): void {
|
|
if (userEvents.value.length === 0) {
|
|
activeEventId.value = null
|
|
persistActiveEvent()
|
|
|
|
return
|
|
}
|
|
const current = activeEventId.value
|
|
if (current && userEvents.value.some(e => e.event_id === current)) {
|
|
persistActiveEvent()
|
|
|
|
return
|
|
}
|
|
activeEventId.value = userEvents.value[0]!.event_id
|
|
persistActiveEvent()
|
|
}
|
|
|
|
async function fetchCurrentPerson(): Promise<void> {
|
|
currentPerson.value = null
|
|
const eid = activeEventId.value
|
|
if (!eid) return
|
|
|
|
isLoadingPerson.value = true
|
|
try {
|
|
const { data } = await apiClient.get<{ success: boolean; data: PortalPersonPayload }>(
|
|
'/portal/me',
|
|
{ params: { event_id: eid } },
|
|
)
|
|
currentPerson.value = data.data
|
|
const status = data.data.status
|
|
const pid = data.data.id
|
|
userEvents.value = userEvents.value.map(row =>
|
|
row.event_id === eid ? { ...row, person_id: pid, person_status: status } : row,
|
|
)
|
|
persistEvents()
|
|
}
|
|
catch {
|
|
currentPerson.value = null
|
|
}
|
|
finally {
|
|
isLoadingPerson.value = false
|
|
}
|
|
}
|
|
|
|
const isHydrated = ref(false)
|
|
let hydratePromise: Promise<void> | null = null
|
|
|
|
async function hydrateAfterAuth(): Promise<void> {
|
|
await loadUserEventsFromApiAndStorage()
|
|
resolveActiveEventId()
|
|
await fetchCurrentPerson()
|
|
isHydrated.value = true
|
|
}
|
|
|
|
/**
|
|
* Hydrate portal data if not already done. Safe to call multiple times —
|
|
* only the first call triggers the actual hydration.
|
|
*/
|
|
function hydrateIfNeeded(): Promise<void> {
|
|
if (isHydrated.value) return Promise.resolve()
|
|
if (!hydratePromise)
|
|
hydratePromise = hydrateAfterAuth().finally(() => { hydratePromise = null })
|
|
|
|
return hydratePromise
|
|
}
|
|
|
|
function setActiveEvent(eventId: string): void {
|
|
if (!userEvents.value.some(e => e.event_id === eventId)) return
|
|
activeEventId.value = eventId
|
|
persistActiveEvent()
|
|
void fetchCurrentPerson()
|
|
}
|
|
|
|
function reset(): void {
|
|
activeEventId.value = null
|
|
userEvents.value = []
|
|
currentPerson.value = null
|
|
loadError.value = null
|
|
isHydrated.value = false
|
|
hydratePromise = null
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.removeItem(STORAGE_EVENTS)
|
|
localStorage.removeItem(STORAGE_ACTIVE_EVENT)
|
|
}
|
|
}
|
|
|
|
return {
|
|
activeEventId,
|
|
userEvents,
|
|
currentPerson,
|
|
activeEvent,
|
|
isLoadingEvents,
|
|
isLoadingPerson,
|
|
isHydrated,
|
|
loadError,
|
|
savePendingEventFromRegistration,
|
|
hydrateAfterAuth,
|
|
hydrateIfNeeded,
|
|
setActiveEvent,
|
|
fetchCurrentPerson,
|
|
reset,
|
|
}
|
|
})
|