sidecart, toasts for add to cart

This commit is contained in:
nomad 2025-06-13 01:04:52 +03:00
parent befb0c7fdb
commit 68eee09cf3
12 changed files with 295 additions and 108 deletions

View file

@ -5,6 +5,7 @@
"name": "medusa-sv",
"dependencies": {
"daisyui": "^5.0.43",
"svelte-french-toast": "^1.2.0",
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
@ -294,6 +295,10 @@
"svelte-check": ["svelte-check@4.2.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
"svelte-french-toast": ["svelte-french-toast@1.2.0", "", { "dependencies": { "svelte-writable-derived": "^3.1.0" }, "peerDependencies": { "svelte": "^3.57.0 || ^4.0.0" } }, "sha512-5PW+6RFX3xQPbR44CngYAP1Sd9oCq9P2FOox4FZffzJuZI2mHOB7q5gJBVnOiLF5y3moVGZ7u2bYt7+yPAgcEQ=="],
"svelte-writable-derived": ["svelte-writable-derived@3.1.1", "", { "peerDependencies": { "svelte": "^3.2.1 || ^4.0.0-next.1 || ^5.0.0-next.94" } }, "sha512-w4LR6/bYZEuCs7SGr+M54oipk/UQKtiMadyOhW0PTwAtJ/Ai12QS77sLngEcfBx2q4H8ZBQucc9ktSA5sUGZWw=="],
"tailwindcss": ["tailwindcss@4.1.8", "", {}, "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og=="],
"tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="],

View file

@ -25,6 +25,7 @@
"vite": "^6.2.6"
},
"dependencies": {
"daisyui": "^5.0.43"
"daisyui": "^5.0.43",
"svelte-french-toast": "^1.2.0"
}
}

View file

@ -3,10 +3,10 @@
</script>
<header class="absolute left-0 right-0 mx-auto w-[calc(100%-1rem)] md:w-[80%] md:left-1/2 md:-translate-x-1/2 z-50 h-auto mt-2 rounded-lg">
<nav class="navbar bg-transparent px-4 md:px-8 lg:px-12 h-full relative">
<div class="navbar bg-transparent px-4 md:px-8 lg:px-12 h-full relative">
<!-- Logo -->
<div class="flex-1 relative">
<a href="/" class="text-2xl font-display text-white hover:opacity-80 transition-opacity">
<div class="flex-1">
<a href="/" class="inline-block text-2xl font-display text-white hover:opacity-80 transition-opacity">
<img src="/logo.png" alt="Standard Marine GCC" class="h-16 w-auto filter brightness-0 invert" />
</a>
</div>
@ -14,7 +14,7 @@
<!-- Navigation -->
<div class="flex-none">
<ul class="menu menu-horizontal px-1 text-white font-body text-lg">
<li>
<li class="relative">
<a
href="/products"
class="hover:bg-white/10 transition-colors"
@ -23,7 +23,7 @@
Products
</a>
</li>
<li>
<li class="relative">
<a
href="/services"
class="hover:bg-white/10 transition-colors"
@ -32,7 +32,7 @@
Services
</a>
</li>
<li>
<li class="relative">
<a
href="/contact"
class="hover:bg-white/10 transition-colors"
@ -43,7 +43,7 @@
</li>
</ul>
</div>
</nav>
</div>
<!-- Glass effect background -->
<div class="absolute rounded-lg inset-0 bg-black/30 backdrop-blur-sm -z-10"></div>

View file

@ -0,0 +1,143 @@
<script>
import { page } from '$app/stores';
import { cart } from '$lib/stores/cart';
import { fly } from 'svelte/transition';
let isMobileMenuOpen = false;
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
</script>
<header class="absolute left-0 right-0 top-0 mx-auto w-[calc(100%-1rem)] md:w-[80%] md:left-1/2 md:-translate-x-1/2 z-50 h-auto mt-2 rounded-lg">
<div class="navbar bg-transparent px-4 md:px-8 lg:px-12 h-full relative">
<!-- Logo -->
<div class="flex-1">
<a href="/" class="inline-block text-2xl font-display text-white hover:opacity-80 transition-opacity">
<img src="/logo.png" alt="Standard Marine GCC" class="h-16 w-auto filter brightness-0 invert" />
</a>
</div>
<!-- Mobile Menu Button -->
<button
class="btn btn-ghost btn-circle text-white md:hidden mr-2"
on:click={toggleMobileMenu}
aria-label="Menu"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Cart Icon (Always Visible) -->
<button
class="btn btn-ghost btn-circle text-white"
on:click={() => cart.toggleCart()}
aria-label="Shopping cart"
>
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{#if $cart.length > 0}
<span class="badge badge-sm indicator-item">
{$cart.reduce((sum, item) => sum + item.quantity, 0)}
</span>
{/if}
</div>
</button>
<!-- Desktop Navigation -->
<div class="hidden md:flex">
<ul class="menu menu-horizontal px-1 text-white font-body text-lg items-center">
<li class="relative">
<a
href="/products"
class="hover:bg-white/10 transition-colors"
class:active={$page.url.pathname === '/products'}
>
Products
</a>
</li>
<li class="relative">
<a
href="/services"
class="hover:bg-white/10 transition-colors"
class:active={$page.url.pathname === '/services'}
>
Services
</a>
</li>
<li class="relative">
<a
href="/contact"
class="hover:bg-white/10 transition-colors"
class:active={$page.url.pathname === '/contact'}
>
Contact Us
</a>
</li>
</ul>
</div>
</div>
<!-- Mobile Menu -->
{#if isMobileMenuOpen}
<div
class="absolute top-full left-0 right-0 mt-2 bg-base-100 rounded-lg shadow-lg overflow-hidden"
transition:fly={{ y: -10, duration: 200 }}
>
<ul class="menu menu-lg p-4 font-body">
<li>
<a
href="/products"
class="hover:bg-base-200 transition-colors"
class:active={$page.url.pathname === '/products'}
on:click={toggleMobileMenu}
>
Products
</a>
</li>
<li>
<a
href="/services"
class="hover:bg-base-200 transition-colors"
class:active={$page.url.pathname === '/services'}
on:click={toggleMobileMenu}
>
Services
</a>
</li>
<li>
<a
href="/contact"
class="hover:bg-base-200 transition-colors"
class:active={$page.url.pathname === '/contact'}
on:click={toggleMobileMenu}
>
Contact Us
</a>
</li>
</ul>
</div>
{/if}
<!-- Glass effect background -->
<div class="absolute rounded-lg inset-0 bg-black/30 backdrop-blur-sm -z-10"></div>
</header>
<style>
.active {
background-color: rgba(255, 255, 255, 0.1);
font-weight: 500;
}
/* Glass effect on scroll */
:global(.scrolled) header {
background-color: rgb(var(--color-base-100) / 0.9);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgb(var(--color-base-200) / 0.5);
transition: all 0.3s ease;
}
</style>

View file

@ -2,6 +2,7 @@
import { cart } from '$lib/stores/cart';
import type { CartItem } from '$lib/stores/cart';
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let isOpen: boolean;
cart.subscribeToOpen(value => isOpen = value);
@ -17,91 +18,124 @@
}
</script>
<div class="fixed bottom-4 right-4 md:right-8 z-50">
<button
class="btn btn-primary btn-circle shadow-lg"
{#if isOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50 z-40"
on:click={() => cart.toggleCart()}
aria-label="Shopping cart"
>
<div class="indicator">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{#if $cart.length > 0}
<span class="badge badge-secondary badge-sm indicator-item">
{$cart.reduce((sum, item) => sum + item.quantity, 0)}
</span>
{/if}
</div>
</button>
transition:fly={{ duration: 200, opacity: 0 }}
></div>
{#if isOpen}
<div
class="absolute bottom-full right-0 mb-2 w-80 bg-base-100 shadow-xl rounded-box p-4 z-50 max-w-[calc(100vw-2rem)]"
transition:fly={{ y: 10, duration: 200 }}
>
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Your Cart</h3>
<button class="btn btn-sm btn-ghost" on:click={() => cart.toggleCart()}>×</button>
</div>
<!-- Side Cart -->
<div
class="fixed top-0 right-0 h-full w-full max-w-md bg-base-100 shadow-xl z-50 flex flex-col"
transition:fly={{ x: 400, duration: 300, easing: quintOut }}
>
<div class="flex justify-between items-center p-4 border-b">
<h3 class="font-bold text-lg">Your Cart</h3>
<button
class="btn btn-ghost btn-sm"
on:click={() => cart.toggleCart()}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-4">
{#if $cart.length === 0}
<p class="text-center py-4">Your cart is empty</p>
<div class="h-full flex flex-col items-center justify-center text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-base-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p class="text-lg mb-2">Your cart is empty</p>
<p class="text-base-content/70 mb-4">Add some items to get started</p>
<button
class="btn btn-primary"
on:click={() => cart.toggleCart()}
>
Continue Shopping
</button>
</div>
{:else}
<div class="space-y-4 max-h-[60vh] overflow-y-auto">
<div class="space-y-4">
{#each $cart as item, i}
<div class="flex flex-col gap-2 pb-3 mb-3 border-b border-base-200">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold">{item.product.title}</h3>
<p class="text-sm">{item.variant.title}</p>
</div>
<button class="btn btn-ghost btn-sm" on:click={() => cart.removeItem(i)}>×</button>
</div>
<div class="flex justify-between items-center">
<div class="join">
<div class="flex gap-4 pb-4 border-b">
{#if item.product.thumbnail}
<img
src={item.product.thumbnail}
alt={item.product.title}
class="w-20 h-20 object-cover rounded-lg bg-base-200"
/>
{/if}
<div class="flex-1">
<div class="flex justify-between">
<div>
<h3 class="font-medium">{item.product.title}</h3>
<p class="text-sm text-base-content/70">{item.variant.title}</p>
</div>
<button
class="btn btn-sm join-item"
on:click={() => cart.updateQuantity(i, item.quantity - 1)}
>-</button>
<input
type="text"
class="input number input-bordered input-sm w-16 join-item text-center"
value={item.quantity}
on:change={(e) => {
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 1;
if (value >= 1) {
cart.updateQuantity(i, value);
}
}}
/>
<button
class="btn btn-sm join-item"
on:click={() => cart.updateQuantity(i, item.quantity + 1)}
>+</button>
class="btn btn-ghost btn-sm"
on:click={() => cart.removeItem(i)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex justify-between items-end mt-2">
<div class="join">
<button
class="btn btn-sm join-item"
on:click={() => cart.updateQuantity(i, item.quantity - 1)}
>-</button>
<input
type="text"
class="input input-bordered input-sm w-14 join-item text-center"
value={item.quantity}
on:change={(e) => {
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 1;
if (value >= 1) {
cart.updateQuantity(i, value);
}
}}
/>
<button
class="btn btn-sm join-item"
on:click={() => cart.updateQuantity(i, item.quantity + 1)}
>+</button>
</div>
<p class="font-medium">{calculateItemTotal(item)}</p>
</div>
<p class="font-bold">{calculateItemTotal(item)}</p>
</div>
</div>
{/each}
<div class="flex justify-between font-bold pt-2">
<span>Total:</span>
<span>{calculateTotal($cart)}</span>
</div>
<div class="pt-4">
<a href="/checkout" class="btn btn-primary w-full">
Proceed to Checkout
</a>
<button class="btn btn-ghost btn-sm w-full mt-2" on:click={() => cart.toggleCart()}>
Continue Shopping
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{#if $cart.length > 0}
<div class="border-t p-4 space-y-4">
<div class="flex justify-between items-center">
<span class="text-lg font-bold">Total:</span>
<span class="text-lg font-bold">{calculateTotal($cart)}</span>
</div>
<div class="grid gap-2">
<a href="/checkout" class="btn btn-primary w-full">
Proceed to Checkout
</a>
<button
class="btn btn-ghost btn-sm w-full"
on:click={() => cart.toggleCart()}
>
Continue Shopping
</button>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import type { MedusaProduct, MedusaVariant } from '$lib/types/medusa';
import toast from 'svelte-french-toast';
export interface CartItem {
product: MedusaProduct;
@ -10,7 +11,6 @@ export interface CartItem {
function createCartStore() {
const { subscribe, set, update } = writable<CartItem[]>([]);
const isOpenStore = writable<boolean>(false);
let autoCloseTimeout: number | undefined;
// Load initial state from localStorage
if (typeof window !== 'undefined') {
@ -20,15 +20,21 @@ function createCartStore() {
}
}
function showAddToCartToast(product: MedusaProduct, variant: MedusaVariant) {
toast(`${product.title} ${variant.title} added to cart`, {
icon: '🛍️',
duration: 2000,
position: 'bottom-center',
style: 'background-color: #000; color: #fff;',
});
}
return {
subscribe,
subscribeToOpen: isOpenStore.subscribe,
toggleCart: () => {
// Clear any existing auto-close timeout
if (autoCloseTimeout) {
clearTimeout(autoCloseTimeout);
autoCloseTimeout = undefined;
}
isOpenStore.update(state => !state);
},
addToCart: (product: MedusaProduct, variant: MedusaVariant) => {
@ -46,20 +52,9 @@ function createCartStore() {
localStorage.setItem('cart', JSON.stringify(newItems));
return newItems;
});
// Clear any existing timeout
if (autoCloseTimeout) {
clearTimeout(autoCloseTimeout);
}
// Open cart
isOpenStore.set(true);
// Auto close after 2 seconds
autoCloseTimeout = window.setTimeout(() => {
isOpenStore.set(false);
autoCloseTimeout = undefined;
}, 800);
// Show toast notification instead of opening cart
showAddToCartToast(product, variant);
},
updateQuantity: (index: number, quantity: number) => {
update(items => {

View file

@ -3,6 +3,7 @@
import Header from '$lib/components/Header.svelte';
import Footer from '$lib/components/Footer.svelte';
import { page } from '$app/stores';
import { Toaster } from 'svelte-french-toast';
</script>
<svelte:head>
@ -19,6 +20,8 @@
<Footer />
</div>
<Toaster />
<style>
:global(h1, h2, h3, h4, h5, h6) {
font-family: 'Playfair Display', serif;

View file

@ -2,9 +2,11 @@
import Hero from '$lib/components/Hero.svelte'
import Content from '$lib/components/Content.svelte'
import Contact from '$lib/components/Contact.svelte'
import Header from '$lib/components/Header.svelte'
</script>
<main>
<Header />
<Hero />
<Content />
<Contact />

View file

@ -1,8 +1,9 @@
<script lang="ts">
import Checkout from '$lib/components/checkout.svelte';
import HeaderCommerce from '$lib/components/HeaderCommerce.svelte';
</script>
<HeaderCommerce />
<div class="container mx-auto px-4 py-8">
<Checkout />
</div>

View file

@ -1,10 +1,12 @@
<script lang="ts">
import MiniCart from '$lib/components/mini-cart.svelte';
import HeaderCommerce from '$lib/components/HeaderCommerce.svelte';
</script>
<div class="min-h-screen bg-base-100">
<slot />
<!-- Mini Cart - only shows on products pages -->
<HeaderCommerce />
<div class="pt-16">
<slot />
</div>
<MiniCart />
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import Products from '$lib/components/products.svelte';
import type { MedusaProduct } from '$lib/types/medusa';
import Header from '$lib/components/HeaderCommerce.svelte';
export let data: { products: MedusaProduct[] }
</script>

View file

@ -5,6 +5,6 @@
export let data: MedusaResponse;
</script>
<div class="container mx-auto px-4 py-8">
<div class="container mx-auto px-4 sm:px-10">
<SingleProduct product={data.product} />
</div>