Refactor Cart UI to Enhance Responsiveness and User Experience
- Replaced CartSheet and CartSidebar components with a new CartResponsive component that adapts to screen size, providing a unified cart experience. - Implemented responsive animations for the cart, allowing it to slide in from the right on desktop and from the bottom on mobile. - Updated styles and layout in tailwind.css to support new animation effects for the cart component.
This commit is contained in:
10
app/app.vue
10
app/app.vue
@@ -9,9 +9,7 @@
|
|||||||
// 15px, which shifted the entire page content 15px to the left. This prop prevents
|
// 15px, which shifted the entire page content 15px to the left. This prop prevents
|
||||||
// that unwanted padding injection and layout shift.
|
// that unwanted padding injection and layout shift.
|
||||||
import { ConfigProvider } from 'reka-ui'
|
import { ConfigProvider } from 'reka-ui'
|
||||||
|
import CartResponsive from '~/components/Cart/CartResponsive.vue'
|
||||||
// Import cart UI components
|
|
||||||
const { isMobile } = useCartUI()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,10 +17,8 @@ const { isMobile } = useCartUI()
|
|||||||
<div>
|
<div>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
|
||||||
<!-- Cart UI: Render appropriate component based on screen size -->
|
<!-- Responsive Cart: Slides from right on desktop, bottom on mobile -->
|
||||||
<!-- Mobile: Bottom sheet, Desktop: Right sidebar -->
|
<CartResponsive />
|
||||||
<CartSheet v-if="isMobile" />
|
|
||||||
<CartSidebar v-else />
|
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -14,6 +15,17 @@ import CartSummary from './CartSummary.vue'
|
|||||||
const { items, itemCount, total, loading, updateItem, removeItem } = useCart()
|
const { items, itemCount, total, loading, updateItem, removeItem } = useCart()
|
||||||
const { isOpen, close } = useCartUI()
|
const { isOpen, close } = useCartUI()
|
||||||
|
|
||||||
|
// Detect mobile viewport (< 640px = Tailwind sm breakpoint)
|
||||||
|
const isMobile = useMediaQuery('(max-width: 639px)')
|
||||||
|
|
||||||
|
// Responsive side prop: bottom for mobile, right for desktop
|
||||||
|
const side = computed(() => isMobile.value ? 'bottom' : 'right')
|
||||||
|
|
||||||
|
// Responsive sizing classes
|
||||||
|
const sizeClasses = computed(() =>
|
||||||
|
isMobile.value ? 'h-[90vh]' : 'w-full sm:w-[400px]'
|
||||||
|
)
|
||||||
|
|
||||||
// Handle quantity update
|
// Handle quantity update
|
||||||
async function handleUpdateQuantity(itemId: string, quantity: number) {
|
async function handleUpdateQuantity(itemId: string, quantity: number) {
|
||||||
await updateItem(itemId, quantity)
|
await updateItem(itemId, quantity)
|
||||||
@@ -33,7 +45,7 @@ function handleCheckout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sheet :open="isOpen" @update:open="(open) => !open && close()">
|
<Sheet :open="isOpen" @update:open="(open) => !open && close()">
|
||||||
<SheetContent side="right" class="w-full sm:w-[400px] p-0 flex flex-col">
|
<SheetContent :side="side" :class="[sizeClasses, 'p-0 flex flex-col']">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<SheetHeader class="px-6 py-4 border-b">
|
<SheetHeader class="px-6 py-4 border-b">
|
||||||
<SheetTitle class="text-xl font-bold">
|
<SheetTitle class="text-xl font-bold">
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import CartEmpty from './CartEmpty.vue'
|
|
||||||
import CartItem from './CartItem.vue'
|
|
||||||
import CartSummary from './CartSummary.vue'
|
|
||||||
|
|
||||||
// Get cart state and UI controls
|
|
||||||
const { items, itemCount, total, loading, updateItem, removeItem } = useCart()
|
|
||||||
const { isOpen, close } = useCartUI()
|
|
||||||
|
|
||||||
// Handle quantity update
|
|
||||||
async function handleUpdateQuantity(itemId: string, quantity: number) {
|
|
||||||
await updateItem(itemId, quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle item removal
|
|
||||||
async function handleRemoveItem(itemId: string) {
|
|
||||||
await removeItem(itemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to checkout
|
|
||||||
function handleCheckout() {
|
|
||||||
close()
|
|
||||||
navigateTo('/checkout')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Sheet :open="isOpen" @update:open="(open) => !open && close()">
|
|
||||||
<SheetContent side="bottom" class="h-[90vh] p-0 flex flex-col">
|
|
||||||
<!-- Header -->
|
|
||||||
<SheetHeader class="px-6 py-4 border-b">
|
|
||||||
<SheetTitle class="text-xl font-bold">
|
|
||||||
Warenkorb ({{ itemCount }})
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="itemCount === 0" class="flex-1 flex items-center justify-center px-6">
|
|
||||||
<CartEmpty />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cart Items + Summary -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- Scrollable Items List -->
|
|
||||||
<ScrollArea class="flex-1 px-6">
|
|
||||||
<div class="space-y-4 py-4">
|
|
||||||
<CartItem v-for="item in items" :key="item.id" :item="item" :loading="loading"
|
|
||||||
@update:quantity="(qty) => handleUpdateQuantity(item.id, qty)" @remove="handleRemoveItem(item.id)" />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<!-- Sticky Footer with Summary -->
|
|
||||||
<div class="border-t px-6 py-4 bg-background">
|
|
||||||
<CartSummary :items="items" :total="total" :loading="loading" @checkout="handleCheckout" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</template>
|
|
||||||
@@ -2,5 +2,4 @@ export { default as CartItem } from './CartItem.vue'
|
|||||||
export { default as CartSummary } from './CartSummary.vue'
|
export { default as CartSummary } from './CartSummary.vue'
|
||||||
export { default as CartEmpty } from './CartEmpty.vue'
|
export { default as CartEmpty } from './CartEmpty.vue'
|
||||||
export { default as CartFAB } from './CartFAB.vue'
|
export { default as CartFAB } from './CartFAB.vue'
|
||||||
export { default as CartSidebar } from './CartSidebar.vue'
|
export { default as CartResponsive } from './CartResponsive.vue'
|
||||||
export { default as CartSheet } from './CartSheet.vue'
|
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
import AppHeader from '~/components/navigation/AppHeader.vue'
|
import AppHeader from '~/components/navigation/AppHeader.vue'
|
||||||
import BottomNav from '~/components/navigation/BottomNav.vue'
|
import BottomNav from '~/components/navigation/BottomNav.vue'
|
||||||
import CartFAB from '~/components/Cart/CartFAB.vue'
|
import CartFAB from '~/components/Cart/CartFAB.vue'
|
||||||
import CartSidebar from '~/components/Cart/CartSidebar.vue'
|
import CartResponsive from '~/components/Cart/CartResponsive.vue'
|
||||||
import CartSheet from '~/components/Cart/CartSheet.vue'
|
|
||||||
import { Toaster } from '~/components/ui/sonner'
|
import { Toaster } from '~/components/ui/sonner'
|
||||||
|
|
||||||
// Determine which cart UI to show based on screen size
|
|
||||||
const { isMobile } = useCartUI()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -29,11 +25,8 @@ const { isMobile } = useCartUI()
|
|||||||
<!-- Floating Action Button (FAB) for Cart on Product Pages -->
|
<!-- Floating Action Button (FAB) for Cart on Product Pages -->
|
||||||
<CartFAB />
|
<CartFAB />
|
||||||
|
|
||||||
<!-- Cart Sidebar (Desktop: >= 1024px) -->
|
<!-- Responsive Cart (slides from right on desktop, bottom on mobile) -->
|
||||||
<CartSidebar v-if="!isMobile" />
|
<CartResponsive />
|
||||||
|
|
||||||
<!-- Cart Sheet (Mobile: < 1024px) -->
|
|
||||||
<CartSheet v-if="isMobile" />
|
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<Toaster position="top-center" :duration="3000" rich-colors />
|
<Toaster position="top-center" :duration="3000" rich-colors />
|
||||||
|
|||||||
@@ -836,6 +836,170 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/* Animation utilities for Sheet/Dialog components */
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromBottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToBottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromLeft {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToLeft {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromTop {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToTop {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation classes with data-state support */
|
||||||
|
[data-state="open"].animate-in,
|
||||||
|
.data-\[state\=open\]\:animate-in[data-state="open"] {
|
||||||
|
animation-duration: 0.3s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].animate-out,
|
||||||
|
.data-\[state\=closed\]\:animate-out[data-state="closed"] {
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].fade-in-0,
|
||||||
|
.data-\[state\=open\]\:fade-in-0[data-state="open"] {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].fade-out-0,
|
||||||
|
.data-\[state\=closed\]\:fade-out-0[data-state="closed"] {
|
||||||
|
animation-name: fadeOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].slide-in-from-right,
|
||||||
|
.data-\[state\=open\]\:slide-in-from-right[data-state="open"] {
|
||||||
|
animation-name: slideInFromRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].slide-out-to-right,
|
||||||
|
.data-\[state\=closed\]\:slide-out-to-right[data-state="closed"] {
|
||||||
|
animation-name: slideOutToRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].slide-in-from-bottom,
|
||||||
|
.data-\[state\=open\]\:slide-in-from-bottom[data-state="open"] {
|
||||||
|
animation-name: slideInFromBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].slide-out-to-bottom,
|
||||||
|
.data-\[state\=closed\]\:slide-out-to-bottom[data-state="closed"] {
|
||||||
|
animation-name: slideOutToBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].slide-in-from-left,
|
||||||
|
.data-\[state\=open\]\:slide-in-from-left[data-state="open"] {
|
||||||
|
animation-name: slideInFromLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].slide-out-to-left,
|
||||||
|
.data-\[state\=closed\]\:slide-out-to-left[data-state="closed"] {
|
||||||
|
animation-name: slideOutToLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].slide-in-from-top,
|
||||||
|
.data-\[state\=open\]\:slide-in-from-top[data-state="open"] {
|
||||||
|
animation-name: slideInFromTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].slide-out-to-top,
|
||||||
|
.data-\[state\=closed\]\:slide-out-to-top[data-state="closed"] {
|
||||||
|
animation-name: slideOutToTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="open"].duration-500,
|
||||||
|
.data-\[state\=open\]\:duration-500[data-state="open"] {
|
||||||
|
animation-duration: 500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-state="closed"].duration-300,
|
||||||
|
.data-\[state\=closed\]\:duration-300[data-state="closed"] {
|
||||||
|
animation-duration: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
/* Container utilities */
|
/* Container utilities */
|
||||||
.container-main {
|
.container-main {
|
||||||
@apply max-w-container-main mx-auto;
|
@apply max-w-container-main mx-auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user