Implement checkout and payment flow with new components
- Added Checkout and Payment pages to handle user authentication and order processing. - Introduced MockPayPalButton for simulating payment during development. - Updated CartSheet and CartSidebar components to navigate to the new checkout page. - Enhanced Cart UI with responsive design for mobile and desktop views. - Implemented order confirmation and success pages to provide feedback after payment completion. These changes complete the checkout and payment functionality, improving the overall user experience and ensuring a seamless transition from cart to order confirmation.
This commit is contained in:
@@ -9,12 +9,20 @@
|
||||
// 15px, which shifted the entire page content 15px to the left. This prop prevents
|
||||
// that unwanted padding injection and layout shift.
|
||||
import { ConfigProvider } from 'reka-ui'
|
||||
|
||||
// Import cart UI components
|
||||
const { isMobile } = useCartUI()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :scroll-body="false">
|
||||
<div>
|
||||
<NuxtPage />
|
||||
|
||||
<!-- Cart UI: Render appropriate component based on screen size -->
|
||||
<!-- Mobile: Bottom sheet, Desktop: Right sidebar -->
|
||||
<CartSheet v-if="isMobile" />
|
||||
<CartSidebar v-else />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
|
||||
// Navigate to checkout
|
||||
function handleCheckout() {
|
||||
close()
|
||||
navigateTo('/kasse')
|
||||
navigateTo('/checkout')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
|
||||
// Navigate to checkout
|
||||
function handleCheckout() {
|
||||
close()
|
||||
navigateTo('/kasse')
|
||||
navigateTo('/checkout')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
106
app/components/MockPayPalButton.vue
Normal file
106
app/components/MockPayPalButton.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Mock PayPal Button Component
|
||||
*
|
||||
* Simulates a PayPal payment for development/testing purposes.
|
||||
* In production, this would be replaced with the real PayPal SDK integration.
|
||||
*
|
||||
* Features:
|
||||
* - Displays mock PayPal button with branding
|
||||
* - Simulates 2-second payment processing
|
||||
* - Emits success event after "payment" completes
|
||||
* - Shows loading state during processing
|
||||
* - Displays amount to be paid
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const isProcessing = ref(false)
|
||||
|
||||
/**
|
||||
* Simulate PayPal payment
|
||||
* In production, this would integrate with PayPal SDK
|
||||
*/
|
||||
async function handlePayment() {
|
||||
isProcessing.value = true
|
||||
|
||||
try {
|
||||
// Simulate payment processing delay (2 seconds)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
|
||||
// In production: Call PayPal API to process payment
|
||||
// const result = await processPayPalPayment(props.orderId, props.amount)
|
||||
|
||||
// Emit success event
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('Mock payment failed:', error)
|
||||
// In production: Show error message to user
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Payment Amount Info -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg border border-white/10">
|
||||
<span class="text-white/80">Zu zahlen:</span>
|
||||
<span class="text-xl font-bold text-experimenta-accent">
|
||||
{{
|
||||
new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(amount)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Mock PayPal Button -->
|
||||
<button
|
||||
@click="handlePayment"
|
||||
:disabled="isProcessing"
|
||||
class="w-full relative overflow-hidden bg-[#0070ba] hover:bg-[#005ea6] disabled:bg-[#0070ba]/50 disabled:cursor-not-allowed text-white font-bold py-4 px-6 rounded-lg transition-colors shadow-lg"
|
||||
>
|
||||
<span v-if="!isProcessing" class="flex items-center justify-center gap-3">
|
||||
<!-- PayPal Logo SVG -->
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20.067 8.478c.492.88.556 2.014.3 3.327-.74 3.806-3.276 5.12-6.514 5.12h-.5a.805.805 0 00-.794.683l-.94 5.96-.267 1.69a.404.404 0 01-.4.342H7.55a.48.48 0 01-.474-.555l.912-5.782.916-5.816a.959.959 0 01.948-.812h1.964c4.332 0 7.299-1.775 8.06-6.145a4.803 4.803 0 00-.808-4.014 6.186 6.186 0 00-1.636-1.293A7.943 7.943 0 0013.784 0H6.732A.959.959 0 005.784.812L2.076 23.235a.48.48 0 00.474.555h3.952a.959.959 0 00.948-.812l.912-5.782h1.74c4.919 0 8.74-2.016 9.965-7.718z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Mit PayPal bezahlen (Mock)</span>
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center gap-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-5 w-5 border-2 border-white/20 border-t-white"
|
||||
/>
|
||||
Zahlung wird verarbeitet...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Mock Info -->
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-white/40 italic">
|
||||
Dies ist ein Mock-PayPal-Button für Entwicklungszwecke.
|
||||
<br />
|
||||
Die Zahlung wird simuliert und ist nicht echt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Checkout Page (/kasse)
|
||||
* Checkout Page (/checkout)
|
||||
*
|
||||
* Features:
|
||||
* - Requires authentication (middleware: auth)
|
||||
* - Shows CheckoutForm with billing address
|
||||
* - Shows CartSummary in right sidebar (desktop) / top (mobile)
|
||||
* - Redirects to /warenkorb if cart is empty
|
||||
* - Redirects to homepage if cart is empty
|
||||
* - "Warenkorb bearbeiten" button opens cart sidebar
|
||||
* - Creates order on form submit
|
||||
* - Redirects to /zahlung?orderId={orderId} after order creation
|
||||
* - Redirects to /payment?orderId={orderId} after order creation
|
||||
*/
|
||||
|
||||
// Type for checkout data (matches server schema)
|
||||
@@ -30,6 +31,7 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const { items, total, itemCount, fetchCart } = useCart()
|
||||
const { open: openCart } = useCartUI()
|
||||
|
||||
// Loading states
|
||||
const isLoading = ref(true) // Start as true to prevent premature redirect
|
||||
@@ -80,7 +82,7 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
|
||||
if (response.success) {
|
||||
// Redirect to payment page with order ID
|
||||
navigateTo(`/zahlung?orderId=${response.orderId}`)
|
||||
navigateTo(`/payment?orderId=${response.orderId}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Order creation failed:', err)
|
||||
@@ -113,9 +115,7 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-12">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4"
|
||||
/>
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white mx-auto mb-4" />
|
||||
<p class="text-white/60">Lade Warenkorb...</p>
|
||||
</div>
|
||||
|
||||
@@ -134,13 +134,8 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
<div class="lg:sticky lg:top-4">
|
||||
<!-- Mobile: Show summary at top, Desktop: Show in sidebar -->
|
||||
<div class="lg:hidden mb-8">
|
||||
<CartSummary
|
||||
:items="items"
|
||||
:total="total"
|
||||
:loading="isCreatingOrder"
|
||||
@checkout="() => {}"
|
||||
class="hidden"
|
||||
/>
|
||||
<CartSummary :items="items" :total="total" :loading="isCreatingOrder" @checkout="() => { }"
|
||||
class="hidden" />
|
||||
<!-- Simplified summary for mobile -->
|
||||
<Card class="p-4 bg-white/5 border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -157,12 +152,12 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/warenkorb"
|
||||
<button
|
||||
@click="openCart"
|
||||
class="text-sm text-experimenta-accent hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -173,12 +168,9 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
<h2 class="text-xl font-bold text-white mb-4">Deine Bestellung</h2>
|
||||
|
||||
<!-- Items List -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex items-start justify-between gap-3 pb-3 border-b border-white/10"
|
||||
>
|
||||
<div class="space-y-3 mb-4 divide-y divide-white/10">
|
||||
<div v-for="item in items" :key="item.id"
|
||||
class="flex items-start justify-between pt-4 py-2 border-white/10">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white">{{ item.product.name }}</p>
|
||||
<p class="text-xs text-white/60">
|
||||
@@ -226,9 +218,7 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between pt-3 border-t border-white/20"
|
||||
>
|
||||
<div class="flex items-center justify-between pt-3 border-t border-white/20">
|
||||
<span class="text-lg font-bold text-white">Gesamt</span>
|
||||
<span class="text-2xl font-bold text-experimenta-accent">
|
||||
{{
|
||||
@@ -243,12 +233,12 @@ async function handleCheckout(checkoutData: CheckoutData) {
|
||||
|
||||
<!-- Edit Cart Link -->
|
||||
<div class="mt-6 pt-6 border-t border-white/20">
|
||||
<NuxtLink
|
||||
to="/warenkorb"
|
||||
<button
|
||||
@click="openCart"
|
||||
class="text-sm text-experimenta-accent hover:underline"
|
||||
>
|
||||
Warenkorb bearbeiten
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Order Confirmation Page (/bestellung/bestaetigen/[orderId])
|
||||
* Order Confirmation Page (/order/confirm/[orderId])
|
||||
*
|
||||
* Features:
|
||||
* - Requires authentication (middleware: auth)
|
||||
@@ -41,7 +41,7 @@ async function fetchOrder() {
|
||||
// Check order status
|
||||
if (order.value.status === 'completed') {
|
||||
// Order already completed - redirect to success page
|
||||
navigateTo(`/bestellung/erfolg/${orderId.value}`)
|
||||
navigateTo(`/order/success/${orderId.value}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ async function fetchOrder() {
|
||||
|
||||
// Redirect to cart after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigateTo('/warenkorb')
|
||||
navigateTo('/cart')
|
||||
}, 3000)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -82,7 +82,7 @@ async function confirmOrder() {
|
||||
|
||||
if (response.success) {
|
||||
// Redirect to success page
|
||||
navigateTo(`/bestellung/erfolg/${orderId.value}`)
|
||||
navigateTo(`/order/success/${orderId.value}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to confirm order:', err)
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Order Success Page (/bestellung/erfolg/[orderId])
|
||||
* Order Success Page (/order/success/[orderId])
|
||||
*
|
||||
* Features:
|
||||
* - Requires authentication (middleware: auth)
|
||||
@@ -21,6 +21,9 @@ definePageMeta({
|
||||
const route = useRoute()
|
||||
const orderId = computed(() => route.params.orderId as string)
|
||||
|
||||
// Get cart composable to refresh cart state
|
||||
const { fetchCart } = useCart()
|
||||
|
||||
// Order data
|
||||
const order = ref<any>(null)
|
||||
const isLoading = ref(false)
|
||||
@@ -43,9 +46,12 @@ async function fetchOrder() {
|
||||
// Redirect to confirmation page if still pending
|
||||
if (order.value.status === 'pending') {
|
||||
setTimeout(() => {
|
||||
navigateTo(`/bestellung/bestaetigen/${orderId.value}`)
|
||||
navigateTo(`/order/confirm/${orderId.value}`)
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
// Order completed successfully - refresh cart to show it's empty
|
||||
await fetchCart()
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch order:', err)
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Payment Mock Page (/zahlung)
|
||||
* Payment Mock Page (/payment)
|
||||
*
|
||||
* Features:
|
||||
* - Requires authentication (middleware: auth)
|
||||
@@ -28,7 +28,7 @@ const error = ref<string | null>(null)
|
||||
// Redirect to cart if no order ID
|
||||
watchEffect(() => {
|
||||
if (!orderId.value) {
|
||||
navigateTo('/warenkorb')
|
||||
navigateTo('/cart')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ onMounted(async () => {
|
||||
|
||||
// Redirect to cart after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigateTo('/warenkorb')
|
||||
navigateTo('/cart')
|
||||
}, 3000)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -59,7 +59,7 @@ function handlePaymentSuccess() {
|
||||
if (!orderId.value) return
|
||||
|
||||
// Redirect to order confirmation page
|
||||
navigateTo(`/bestellung/bestaetigen/${orderId.value}`)
|
||||
navigateTo(`/order/confirm/${orderId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -176,7 +176,7 @@ function handlePaymentSuccess() {
|
||||
|
||||
<!-- Back Link -->
|
||||
<div class="text-center pt-4">
|
||||
<NuxtLink to="/kasse" class="text-sm text-experimenta-accent hover:underline">
|
||||
<NuxtLink to="/checkout" class="text-sm text-experimenta-accent hover:underline">
|
||||
← Zurück zur Kasse
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -100,10 +100,8 @@ const handleAddToCart = async () => {
|
||||
<div class="min-h-screen bg-gradient-primary px-4 py-12 md:px-6 lg:px-8">
|
||||
<!-- Back Button -->
|
||||
<div class="mx-auto mb-8 max-w-container-narrow">
|
||||
<NuxtLink
|
||||
to="/products"
|
||||
class="inline-flex items-center gap-2 text-white/80 transition-colors hover:text-white"
|
||||
>
|
||||
<NuxtLink to="/products"
|
||||
class="inline-flex items-center gap-2 text-white/80 transition-colors hover:text-white">
|
||||
<ArrowLeft :size="20" />
|
||||
<span>Zurück zur Übersicht</span>
|
||||
</NuxtLink>
|
||||
@@ -142,15 +140,9 @@ const handleAddToCart = async () => {
|
||||
<div class="overflow-hidden rounded-2xl border border-white/20 bg-white/10 shadow-glass backdrop-blur-lg">
|
||||
<!-- Product Image (no padding, flush with top) -->
|
||||
<div class="relative aspect-[16/9] w-full overflow-hidden bg-purple-dark">
|
||||
<img
|
||||
src="/img/makerspace-jk-2025.jpg"
|
||||
:alt="product.name"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<img src="/img/makerspace-jk-2025.jpg" :alt="product.name" class="h-full w-full object-cover" />
|
||||
<!-- Gradient overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-purple-darkest/80 via-transparent to-transparent"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-purple-darkest/80 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Product Content -->
|
||||
@@ -178,12 +170,10 @@ const handleAddToCart = async () => {
|
||||
<!-- Availability Card -->
|
||||
<div class="rounded-xl bg-white/5 p-4 backdrop-blur-sm">
|
||||
<span class="mb-1 block text-xs uppercase tracking-wide text-white/60">Verfügbarkeit</span>
|
||||
<div
|
||||
:class="[
|
||||
<div :class="[
|
||||
'flex items-center gap-2 text-xl font-semibold',
|
||||
product.stockQuantity > 0 ? 'text-green' : 'text-red',
|
||||
]"
|
||||
>
|
||||
]">
|
||||
<CheckCircle v-if="product.stockQuantity > 0" :size="24" />
|
||||
<span>{{ product.stockQuantity > 0 ? 'Sofort' : 'Nicht verfügbar' }}</span>
|
||||
</div>
|
||||
@@ -217,19 +207,13 @@ const handleAddToCart = async () => {
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<Button
|
||||
variant="experimenta"
|
||||
size="experimenta"
|
||||
class="flex-1 relative"
|
||||
:disabled="product.stockQuantity === 0 || isAddingToCart"
|
||||
@click="handleAddToCart"
|
||||
>
|
||||
<NuxtLink to="/products" class="btn-secondary flex-1 text-center">
|
||||
Weitere Produkte ansehen
|
||||
</NuxtLink>
|
||||
<Button variant="experimenta" size="experimenta" class="flex-1 relative"
|
||||
:disabled="product.stockQuantity === 0 || isAddingToCart" @click="handleAddToCart">
|
||||
<!-- Loading spinner -->
|
||||
<Loader2
|
||||
v-if="isAddingToCart"
|
||||
:size="20"
|
||||
class="mr-2 animate-spin"
|
||||
/>
|
||||
<Loader2 v-if="isAddingToCart" :size="20" class="mr-2 animate-spin" />
|
||||
|
||||
<!-- Button text -->
|
||||
<span v-if="isAddingToCart">Wird hinzugefügt...</span>
|
||||
@@ -237,16 +221,10 @@ const handleAddToCart = async () => {
|
||||
<span v-else>In den Warenkorb</span>
|
||||
</Button>
|
||||
|
||||
<NuxtLink to="/products" class="btn-secondary flex-1 text-center">
|
||||
Weitere Produkte ansehen
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Already in cart hint -->
|
||||
<div
|
||||
v-if="isInCart && product.stockQuantity > 0"
|
||||
class="mt-2 text-center text-sm text-white/70"
|
||||
>
|
||||
<div v-if="isInCart && product.stockQuantity > 0" class="mt-2 text-center text-sm text-white/70">
|
||||
Dieses Produkt befindet sich bereits in deinem Warenkorb.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, asc } from 'drizzle-orm'
|
||||
import { carts, cartItems, products } from '../database/schema'
|
||||
|
||||
// Re-export shared types
|
||||
@@ -89,6 +89,7 @@ export async function getCartWithItems(cartId: string): Promise<CartSummary> {
|
||||
with: {
|
||||
product: true,
|
||||
},
|
||||
orderBy: asc(cartItems.addedAt), // Sort by addedAt to maintain stable order
|
||||
})
|
||||
|
||||
// Separate available and unavailable items
|
||||
|
||||
Reference in New Issue
Block a user