add: local wishlists stored in local storage for anonymous users

This commit is contained in:
rasmusq
2025-11-27 21:35:28 +01:00
parent 8dcf26b1d3
commit 85f8671c72
9 changed files with 264 additions and 13 deletions

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists';
import { languageStore } from '$lib/stores/language.svelte';
import { Star } from 'lucide-svelte';
import { onMount } from 'svelte';
let {
isAuthenticated = false
}: {
isAuthenticated?: boolean;
} = $props();
const t = $derived(languageStore.t);
let localWishlists = $state<LocalWishlist[]>([]);
// Load local wishlists on mount (client-side only)
onMount(() => {
localWishlists = getLocalWishlists();
});
function handleForget(ownerToken: string) {
forgetLocalWishlist(ownerToken);
localWishlists = getLocalWishlists();
}
function handleToggleFavorite(ownerToken: string) {
toggleLocalFavorite(ownerToken);
localWishlists = getLocalWishlists();
}
// Transform LocalWishlist to match the format expected by WishlistSection
const transformedWishlists = $derived(() => {
return localWishlists.map(w => ({
id: w.ownerToken,
title: w.title,
ownerToken: w.ownerToken,
publicToken: w.publicToken,
createdAt: w.createdAt,
isFavorite: w.isFavorite || false,
items: [] // We don't have item data in localStorage
}));
});
</script>
{#if localWishlists.length > 0}
<WishlistSection
title={t.dashboard.localWishlists || "Local Wishlists"}
description={t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently."}
items={transformedWishlists()}
emptyMessage=""
>
{#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onclick={() => handleToggleFavorite(wishlist.ownerToken)}
>
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button>
<Button
size="sm"
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
>
{t.dashboard.manage}
</Button>
<Button
size="sm"
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
`${window.location.origin}/wishlist/${wishlist.publicToken}`
);
}}
>
{t.dashboard.copyLink}
</Button>
{#if unlocked}
<Button
size="sm"
variant="destructive"
onclick={() => handleForget(wishlist.ownerToken)}
>
{t.dashboard.forget || "Forget"}
</Button>
{/if}
</div>
{/snippet}
</WishlistSection>
{/if}

View File

@@ -8,16 +8,25 @@
let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props(); let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const isAuthenticated = $derived(!!userName || !!userEmail);
</script> </script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1> <h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
<p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p> {#if isAuthenticated}
<p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p>
{:else}
<p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p>
{/if}
</div> </div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button> {#if isAuthenticated}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
{:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button>
{/if}
</div> </div>
</div> </div>

View File

@@ -2,18 +2,24 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { isLocalWishlist } from '$lib/utils/localWishlists';
let { let {
isAuthenticated, isAuthenticated,
isOwner, isOwner,
hasClaimed hasClaimed,
ownerToken
}: { }: {
isAuthenticated: boolean; isAuthenticated: boolean;
isOwner: boolean; isOwner: boolean;
hasClaimed: boolean; hasClaimed: boolean;
ownerToken: string;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
// Check if this wishlist is in localStorage
const isLocal = $derived(isLocalWishlist(ownerToken));
</script> </script>
{#if isAuthenticated} {#if isAuthenticated}
@@ -33,7 +39,11 @@
<form <form
method="POST" method="POST"
action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"} action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"}
use:enhance use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
> >
<Button <Button
type="submit" type="submit"
@@ -48,6 +58,10 @@
You have claimed this wishlist. It will appear in your dashboard. You have claimed this wishlist. It will appear in your dashboard.
{:else} {:else}
Claim this wishlist to add it to your dashboard for easy access. Claim this wishlist to add it to your dashboard for easy access.
{#if isLocal}
<br />
<span class="text-xs">It will remain in your local wishlists and also appear in your claimed wishlists.</span>
{/if}
{/if} {/if}
</p> </p>
{/if} {/if}

View File

@@ -0,0 +1,103 @@
/**
* Utility functions for managing anonymous user's wishlists in localStorage
*/
const LOCAL_WISHLISTS_KEY = 'local_wishlists';
export interface LocalWishlist {
ownerToken: string;
publicToken: string;
title: string;
createdAt: string;
isFavorite?: boolean;
}
/**
* Get all local wishlists from localStorage
*/
export function getLocalWishlists(): LocalWishlist[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to parse local wishlists:', error);
return [];
}
}
/**
* Add a wishlist to localStorage
*/
export function addLocalWishlist(wishlist: LocalWishlist): void {
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
// Check if already exists
const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
if (exists) return;
wishlists.push(wishlist);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
} catch (error) {
console.error('Failed to add local wishlist:', error);
}
}
/**
* Remove a wishlist from localStorage (forget it)
*/
export function forgetLocalWishlist(ownerToken: string): void {
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
const filtered = wishlists.filter(w => w.ownerToken !== ownerToken);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to forget local wishlist:', error);
}
}
/**
* Clear all local wishlists (e.g., when user claims all wishlists)
*/
export function clearLocalWishlists(): void {
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_WISHLISTS_KEY);
} catch (error) {
console.error('Failed to clear local wishlists:', error);
}
}
/**
* Check if a wishlist is in local storage
*/
export function isLocalWishlist(ownerToken: string): boolean {
const wishlists = getLocalWishlists();
return wishlists.some(w => w.ownerToken === ownerToken);
}
/**
* Toggle favorite status for a local wishlist
*/
export function toggleLocalFavorite(ownerToken: string): void {
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
const updated = wishlists.map(w =>
w.ownerToken === ownerToken
? { ...w, isFavorite: !w.isFavorite }
: w
);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to toggle local wishlist favorite:', error);
}
}

View File

@@ -10,6 +10,7 @@
import ColorPicker from '$lib/components/ui/ColorPicker.svelte'; import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { addLocalWishlist } from '$lib/utils/localWishlists';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -32,7 +33,19 @@
}); });
if (response.ok) { if (response.ok) {
const { ownerToken } = await response.json(); const result = await response.json();
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
// If user is not authenticated, save to localStorage
if (!data.session?.user) {
addLocalWishlist({
ownerToken,
publicToken,
title: wishlistTitle,
createdAt
});
}
goto(`/wishlist/${ownerToken}/edit`); goto(`/wishlist/${ownerToken}/edit`);
} }
} catch (error) { } catch (error) {
@@ -56,9 +69,8 @@
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
{#if data.session?.user} <Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button> {#if !data.session?.user}
{:else}
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button> <Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
{/if} {/if}
</div> </div>

View File

@@ -29,5 +29,11 @@ export const POST: RequestHandler = async ({ request, locals }) => {
}) })
.returning(); .returning();
return json({ ownerToken, publicToken, id: wishlist.id }); return json({
ownerToken,
publicToken,
id: wishlist.id,
title: wishlist.title,
createdAt: wishlist.createdAt
});
}; };

View File

@@ -7,8 +7,14 @@ import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth(); const session = await event.locals.auth();
// Allow anonymous users to access dashboard for local wishlists
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); return {
user: null,
wishlists: [],
savedWishlists: [],
isAuthenticated: false
};
} }
const userWishlists = await db.query.wishlists.findMany({ const userWishlists = await db.query.wishlists.findMany({
@@ -53,7 +59,8 @@ export const load: PageServerLoad = async (event) => {
return { return {
user: session.user, user: session.user,
wishlists: userWishlists, wishlists: userWishlists,
savedWishlists: savedWithAccess savedWishlists: savedWithAccess,
isAuthenticated: true
}; };
}; };

View File

@@ -4,6 +4,7 @@
import PageContainer from '$lib/components/layout/PageContainer.svelte'; import PageContainer from '$lib/components/layout/PageContainer.svelte';
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte'; import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte'; import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { Star } from 'lucide-svelte'; import { Star } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
@@ -36,8 +37,12 @@
<PageContainer> <PageContainer>
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} /> <DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
<!-- My Wishlists Section --> <!-- Local Wishlists Section (for anonymous and authenticated users) -->
<WishlistSection <LocalWishlistsSection isAuthenticated={data.isAuthenticated} />
{#if data.isAuthenticated}
<!-- My Wishlists Section -->
<WishlistSection
title={t.dashboard.myWishlists} title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription} description={t.dashboard.myWishlistsDescription}
items={myWishlists()} items={myWishlists()}
@@ -189,4 +194,5 @@
</div> </div>
{/snippet} {/snippet}
</WishlistSection> </WishlistSection>
{/if}
</PageContainer> </PageContainer>

View File

@@ -130,6 +130,7 @@
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
isOwner={data.isOwner} isOwner={data.isOwner}
hasClaimed={data.hasClaimed} hasClaimed={data.hasClaimed}
ownerToken={data.wishlist.ownerToken}
/> />
<WishlistActionButtons <WishlistActionButtons