Browse Source

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.
main
Bastian Masanek 2 months ago
parent
commit
b302411626
  1. 8
      app/app.vue
  2. 2
      app/components/Cart/CartSheet.vue
  3. 2
      app/components/Cart/CartSidebar.vue
  4. 106
      app/components/MockPayPalButton.vue
  5. 48
      app/pages/checkout.vue
  6. 8
      app/pages/order/confirm/[orderId].vue
  7. 10
      app/pages/order/success/[orderId].vue
  8. 10
      app/pages/payment.vue
  9. 48
      app/pages/products/[id].vue
  10. 3
      server/utils/cart-helpers.ts

8
app/app.vue

@ -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>

2
app/components/Cart/CartSheet.vue

@ -27,7 +27,7 @@ async function handleRemoveItem(itemId: string) {
// Navigate to checkout
function handleCheckout() {
close()
navigateTo('/kasse')
navigateTo('/checkout')
}
</script>

2
app/components/Cart/CartSidebar.vue

@ -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

@ -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>

48
app/pages/kasse.vue → app/pages/checkout.vue

@ -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>

8
app/pages/bestellung/bestaetigen/[orderId].vue → app/pages/order/confirm/[orderId].vue

@ -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)

10
app/pages/bestellung/erfolg/[orderId].vue → app/pages/order/success/[orderId].vue

@ -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)

10
app/pages/zahlung.vue → app/pages/payment.vue

@ -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>

48
app/pages/products/[id].vue

@ -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>

3
server/utils/cart-helpers.ts

@ -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

Loading…
Cancel
Save