Replace Firebase Storage with MinIO and add user account features

- Storage Integration:
  * Remove Firebase Storage dependency and useFirebaseStorage composable
  * Implement direct MinIO uploads via POST /storage/upload with multipart/form-data
  * Upload canvas JSON, preview PNG, and production PNG as separate objects
  * Store public URLs and metadata in design records

- Authentication & Registration:
  * Add email/password registration page with validation
  * Integrate backend user session via /auth/login endpoint
  * Store backendUser.id as ownerId in design records
  * Auto-sync backend session on Firebase auth state changes

- User Account Pages:
  * Create profile page showing user details and backend session info
  * Create orders page with transaction history filtered by customerEmail
  * Add server proxy /api/orders to forward GET /transactions queries

- Navigation Improvements:
  * Replace inline auth buttons with avatar dropdown menu
  * Add Profile, Orders, and Logout options to dropdown
  * Implement outside-click and route-change handlers for dropdown
  * Display user initials in avatar badge

- API Updates:
  * Update transactions endpoint to accept amount as string
  * Format amount with .toFixed(2) in checkout success flow
  * Query orders by customerEmail instead of ownerId for consistency
This commit is contained in:
Frank John Begornia
2025-11-16 01:19:35 +08:00
parent 0ff41822af
commit bf701f8342
19 changed files with 1807 additions and 223 deletions

View File

@@ -1,41 +1,136 @@
<script setup lang="ts">
const { user, signOut, initAuth, isLoading } = useAuth()
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
const loginModal = useLoginModal()
const route = useRoute()
const dropdownRef = ref<HTMLElement | null>(null)
const showMenu = ref(false)
const displayName = computed(() => backendUser.value?.name || user.value?.displayName || backendUser.value?.email || user.value?.email || "Account")
const avatarInitials = computed(() => {
const source = displayName.value
if (!source) {
return "S"
}
const parts = source.trim().split(/\s+/)
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase()
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
})
const openLoginModal = () => {
loginModal.value = true
}
// Initialize auth on component mount
onMounted(() => {
initAuth()
})
const handleSignOut = async () => {
try {
await signOut()
showMenu.value = false
} catch (error) {
console.error('Sign out failed:', error)
}
}
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
const closeMenu = () => {
if (showMenu.value) {
showMenu.value = false
}
}
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.value) {
return
}
if (!dropdownRef.value.contains(event.target as Node)) {
closeMenu()
}
}
onMounted(() => {
initAuth()
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
watch(
() => route.fullPath,
() => {
closeMenu()
}
)
</script>
<template>
<nav class="sticky top-0 z-30 border-b border-slate-800/60 bg-slate-950/80 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between gap-6">
<p class="text-lg font-semibold text-white sm:text-xl">Slipmatz</p>
<NuxtLink to="/" class="text-lg font-semibold text-white transition hover:text-sky-300 sm:text-xl">
Slipmatz
</NuxtLink>
<div class="flex items-center gap-3">
<!-- Show user info and logout when authenticated -->
<div v-if="user && !isLoading" class="flex items-center gap-3">
<span class="text-sm text-slate-300">{{ user.email }}</span>
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
<span class="hidden text-sm text-slate-300 sm:inline">{{ backendUser?.email || user.email }}</span>
<button
type="button"
@click="handleSignOut"
class="rounded-full border border-slate-700/80 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-rose-500 hover:text-rose-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-sm font-semibold uppercase text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
@click.stop="toggleMenu"
aria-haspopup="menu"
:aria-expanded="showMenu"
>
Logout
{{ avatarInitials }}
</button>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="showMenu"
class="absolute right-0 top-12 w-56 rounded-2xl border border-slate-800/70 bg-slate-900/95 p-3 shadow-xl shadow-slate-950/50 backdrop-blur"
role="menu"
>
<p class="px-3 text-xs uppercase tracking-[0.25em] text-slate-500">Signed in as</p>
<p class="px-3 text-sm font-medium text-slate-200">{{ displayName }}</p>
<p class="px-3 text-xs text-slate-500">{{ backendUser?.email || user.email }}</p>
<div class="my-3 h-px bg-slate-800"></div>
<NuxtLink
to="/profile"
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
role="menuitem"
>
Profile
</NuxtLink>
<NuxtLink
to="/orders"
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-200 transition hover:bg-slate-800"
role="menuitem"
>
Orders
</NuxtLink>
<button
type="button"
class="mt-2 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/10"
@click="handleSignOut"
role="menuitem"
>
Logout
</button>
</div>
</transition>
</div>
<!-- Show login button when not authenticated -->