feat(orders): idempotency guard, order numbers, button lock, success page, my-orders, admin polling
This commit is contained in:
140
shop/components/admin/recent-orders.tsx
Normal file
140
shop/components/admin/recent-orders.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Link from 'next/link'
|
||||
|
||||
type RecentOrder = {
|
||||
id: string
|
||||
order_number: string
|
||||
created_at: string
|
||||
total_price: number
|
||||
status: string
|
||||
customer_data?: {
|
||||
company_name?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass: Record<string, string> = {
|
||||
pending: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
active: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
cancelled: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: 'Eingegangen',
|
||||
active: 'In Bearbeitung',
|
||||
cancelled: 'Storniert',
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
|
||||
export function AdminRecentOrders() {
|
||||
const [orders, setOrders] = useState<RecentOrder[]>([])
|
||||
const [newOrderIds, setNewOrderIds] = useState<Set<string>>(new Set())
|
||||
const [lastChecked, setLastChecked] = useState<Date>(new Date())
|
||||
const knownIds = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/recent-orders', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const { orders: fresh } = await res.json()
|
||||
|
||||
// Neue Bestellungen erkennen (Highlight)
|
||||
const freshIds = new Set<string>(fresh.map((o: RecentOrder) => o.id))
|
||||
const newOnes = new Set<string>()
|
||||
freshIds.forEach(id => {
|
||||
if (!knownIds.current.has(id)) newOnes.add(id)
|
||||
})
|
||||
knownIds.current = freshIds
|
||||
|
||||
setOrders(fresh)
|
||||
setNewOrderIds(newOnes)
|
||||
setLastChecked(new Date())
|
||||
|
||||
// Highlight nach 5 Sekunden entfernen
|
||||
if (newOnes.size > 0) {
|
||||
setTimeout(() => setNewOrderIds(new Set()), 5000)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
const interval = setInterval(fetchOrders, POLL_INTERVAL_MS)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4 glass-dark border-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle>Übersicht</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<div className="h-[200px] flex items-center justify-center text-slate-400 italic">
|
||||
[Umsatz-Chart – folgt in nächstem Update]
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3 glass-dark border-white/5">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle>Neue Bestellungen</CardTitle>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Aktualisiert: {lastChecked.toLocaleTimeString('de-DE')}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm italic text-center py-8">Noch keine Bestellungen</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map(order => {
|
||||
const isNew = newOrderIds.has(order.id)
|
||||
const customerName =
|
||||
order.customer_data?.company_name ||
|
||||
`${order.customer_data?.first_name ?? ''} ${order.customer_data?.last_name ?? ''}`.trim() ||
|
||||
'Unbekannt'
|
||||
|
||||
return (
|
||||
<Link key={order.id} href="/admin/orders" className="block">
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-all duration-500 ${
|
||||
isNew
|
||||
? 'bg-primary/10 border border-primary/30 shadow-[0_0_12px_rgba(59,130,246,0.2)]'
|
||||
: 'bg-white/5 hover:bg-white/10 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary text-xs font-bold shrink-0">
|
||||
{customerName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{customerName}</p>
|
||||
<p className="text-xs text-slate-500 font-mono">#{order.order_number}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<p className="font-medium text-white text-sm">
|
||||
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(order.total_price)}
|
||||
</p>
|
||||
<Badge className={`text-[10px] ${statusClass[order.status] ?? ''}`}>
|
||||
{statusLabel[order.status] ?? order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Product, ProductModule, Profile } from '@/lib/types'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
|
||||
@@ -10,7 +11,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle } from 'lucide-react'
|
||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { submitOrder } from '@/lib/actions/orders'
|
||||
import { Category } from '@/lib/types'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -76,7 +77,9 @@ export function OrderWizard({
|
||||
categories: Category[]
|
||||
initialProfile: Profile | null
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [customerData, setCustomerData] = useState<Partial<Profile>>(
|
||||
initialProfile || {
|
||||
company_name: '',
|
||||
@@ -145,18 +148,20 @@ export function OrderWizard({
|
||||
const prevStep = () => setStep(s => s - 1)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return // Doppelklick-Guard
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await submitOrder({
|
||||
const order = await submitOrder({
|
||||
selections,
|
||||
products,
|
||||
categories,
|
||||
customerProfile: customerData,
|
||||
})
|
||||
alert('Bestellung erfolgreich aufgegeben!')
|
||||
window.location.href = '/'
|
||||
router.push(`/order/success?id=${order.id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
alert('Fehler bei der Bestellung.')
|
||||
alert('Fehler bei der Bestellung. Bitte versuchen Sie es erneut.')
|
||||
setIsSubmitting(false) // Nur bei Fehler wieder entsperren
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,8 +557,13 @@ export function OrderWizard({
|
||||
<Button
|
||||
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Kostenpflichtig bestellen
|
||||
{isSubmitting ? (
|
||||
<><Loader2 className="mr-2 h-5 w-5 animate-spin" /> Bestellung wird verarbeitet...</>
|
||||
) : (
|
||||
'Kostenpflichtig bestellen'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={prevStep} className="w-full">
|
||||
Noch etwas ändern
|
||||
|
||||
Reference in New Issue
Block a user