Files
crewli/apps/portal/src/stores/usePortalStore.ts
bert.hausmans 59ad09fad2 feat(portal): auth persistence, shift visibility, profile page, and UI polish
- 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>
2026-04-13 10:19:14 +02:00

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,
}
})