fix(frontend): Svelte 5 reactivity, each block keys, and path resolution

Add proper keys to #each blocks across 15+ components to fix reordering bugs. Replace new Date() with SvelteDate in reactive contexts. Use $derived for computed values (totalPages). Use resolve() from $app/paths for all internal navigation hrefs. Add ARIA labels and keyboard accessibility to NavBar mobile menu. Remove unused handleRetry from UserPaymentModal.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-04 01:08:36 +01:00
co-authored by Sisyphus
parent c76c3f52f7
commit 6c16d65476
33 changed files with 417 additions and 300 deletions
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { slide, fly } from 'svelte/transition';
import { navigating, page } from '$app/stores';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { authStore } from '$lib/stores/auth.svelte';
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
@@ -126,12 +126,12 @@
<!-- Center: Desktop Links -->
<div class="hidden space-x-8 md:flex">
{#each links as link}
{#each links as link (link.href)}
{#if canShow(link)}
{#if isLoading}
<Skeleton class={`h-4 ${link.width} rounded`} />
{:else}
<a href={link.href} class="font-medium text-gray-800 hover:text-primary"
<a href={resolve(link.href)} class="font-medium text-gray-800 hover:text-primary"
>{link.label}</a
>
{/if}
@@ -142,7 +142,7 @@
<div class="hidden items-center gap-4 md:flex">
{#if isAuthenticated}
<a
href="/notifications"
href={resolve('/notifications')}
class="relative text-gray-600 hover:text-primary"
aria-label="Notifications"
>
@@ -170,7 +170,7 @@
</a>
{/if}
{#if !isLoading && !isAuthenticated && $page.url.pathname !== '/login'}
<Button href="/login">Login</Button>
<Button href={resolve('/login')}>Login</Button>
{/if}
{#if isLoading}
<Skeleton class="h-8 w-16 rounded" />
@@ -179,18 +179,19 @@
<!-- Mobile: Burger -->
<div class="flex items-center md:hidden">
<button onclick={toggleMenu} class="relative focus:outline-none" aria-label="Toggle menu">
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{#if unreadCount > 0 && !mobileMenuOpen}
<button
onclick={toggleMenu}
class="relative h-6 w-6 focus:outline-none"
aria-label="Toggle menu"
class:open={mobileMenuOpen}
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
{#if unreadCount > 0}
<span
class="absolute -top-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
class="notification-badge absolute -top-1 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
@@ -203,7 +204,11 @@
{#if mobileMenuOpen}
<div
class="fixed inset-0 top-16 bg-black/30 backdrop-blur-[1px] md:hidden"
role="button"
tabindex="0"
aria-label="Close menu"
onclick={toggleMenu}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleMenu(); } }}
></div>
{/if}
@@ -214,13 +219,13 @@
class="relative z-50 border-b border-gray-200 bg-background md:hidden"
>
<div class="space-y-1 px-2 pt-2 pb-3">
{#each links as link}
{#each links as link (link.href)}
{#if canShow(link)}
{#if isLoading}
<Skeleton class="h-4 w-full rounded" />
{:else}
<a
href={link.href}
href={resolve(link.href)}
class="block rounded px-3 py-2 text-center text-primary hover:text-gray-800"
>
{link.label}
@@ -231,12 +236,13 @@
{#if isAuthenticated}
<a
href="/notifications"
href={resolve('/notifications')}
class="flex items-center justify-center gap-2 rounded px-3 py-2 text-primary hover:text-gray-800"
>
<span>Notifications</span>
{#if unreadCount > 0}
<span
in:fly={{ y: -8, duration: 200, delay: 50 }}
class="flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
>
{unreadCount > 9 ? '9+' : unreadCount}
@@ -248,7 +254,7 @@
{#if isLoading}
<Skeleton class="mt-2 h-8 w-full rounded" />
{:else if !isAuthenticated}
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
<Button href={resolve('/login')} class="mt-2 w-full text-center">Login</Button>
{/if}
</div>
</div>
@@ -259,4 +265,70 @@
.frosty-nav {
backdrop-filter: saturate(180%) blur(10px);
}
/* Hamburger animation - 4 lines, middle two overlap when closed */
.hamburger-line {
display: block;
position: absolute;
height: 2px;
width: 100%;
background: #374151;
border-radius: 2px;
opacity: 1;
left: 0;
transform: rotate(0deg);
transition: 0.125s ease-in-out;
}
.hamburger-line:nth-child(1) {
top: 3px;
}
/* Spans 2 and 3 overlap at same position, span 3 hidden when closed */
.hamburger-line:nth-child(2) {
top: 10px;
}
.hamburger-line:nth-child(3) {
top: 10px;
opacity: 0;
}
.hamburger-line:nth-child(4) {
top: 17px;
}
/* Open state - transform to X */
.open .hamburger-line:nth-child(1) {
top: 10px;
width: 0%;
left: 50%;
}
.open .hamburger-line:nth-child(2) {
transform: rotate(45deg);
}
.open .hamburger-line:nth-child(3) {
opacity: 1;
transform: rotate(-45deg);
}
.open .hamburger-line:nth-child(4) {
top: 10px;
width: 0%;
left: 50%;
}
/* Badge animations */
.notification-badge {
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.open .notification-badge {
opacity: 0;
transform: translateY(8px);
}
</style>