site-panzai/pages/panier.vue
2026-11-13 13:57:04 +01:00

195 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import type { Order, Payment } from '~/server/utils/store'
useHead({ title: 'Ton panier — PANZAÏ' })
const { lines, mode, subtotal, count, setQty, clear } = useCart()
const { settings, open, wait } = useShop()
const address = ref('')
const slot = ref('Dès que possible')
const name = ref('')
const phone = ref('')
const payment = ref<Payment>('carte')
const paying = ref(false)
const error = ref('')
const slots = computed(() => {
const res = ['Dès que possible']
const d = new Date(Date.now() + (wait.value + 10) * 60000)
d.setMinutes(Math.ceil(d.getMinutes() / 15) * 15, 0, 0)
for (let i = 0; i < 6; i++) { res.push(hhmm(d.getTime())); d.setMinutes(d.getMinutes() + 15) }
return res
})
const liv = computed(() => mode.value === 'livraison')
const freeFrom = computed(() => settings.value?.freeFrom ?? 3500)
const minOrder = computed(() => settings.value?.minOrder ?? 1500)
const fee = computed(() => liv.value && subtotal.value < freeFrom.value ? (settings.value?.deliveryFee ?? 250) : 0)
const total = computed(() => subtotal.value + fee.value)
const belowMin = computed(() => liv.value && subtotal.value < minOrder.value)
watch(mode, (m) => { if (m === 'livraison' && payment.value === 'especes') payment.value = 'carte' })
const pays = computed(() => [
{ id: 'carte', label: 'Carte bancaire', hint: 'CB · Visa · Mastercard' },
{ id: 'apple', label: 'Apple Pay', hint: '' },
{ id: 'google', label: 'Google Pay', hint: '' },
...(liv.value ? [] : [{ id: 'especes', label: 'Espèces au retrait', hint: 'au comptoir' }])
] as { id: Payment, label: string, hint: string }[])
const canSubmit = computed(() => count.value > 0 && open.value && name.value.trim() && phone.value.trim().length >= 8 && (!liv.value || address.value.trim().length > 5) && !belowMin.value)
async function submit() {
error.value = ''
if (!canSubmit.value) { error.value = 'Complète les informations manquantes.'; return }
paying.value = true
try {
// Paiement simulé (en production : Stripe Checkout / Payment Element)
if (payment.value !== 'especes') await new Promise(r => setTimeout(r, 1400))
const order = await $fetch<Order>('/api/orders', {
method: 'POST',
body: {
client: { name: name.value, phone: phone.value }, mode: mode.value,
address: address.value, slot: slot.value, payment: payment.value,
items: lines.value.map(l => ({ dishId: l.dishId, size: l.size, meat: l.meat, sauce: l.sauce, extras: l.extras, note: l.note, qty: l.qty }))
}
})
clear()
await navigateTo(`/commande/${order.id}`)
} catch (e: any) {
error.value = e?.data?.statusMessage || e?.statusMessage || 'Une erreur est survenue, réessaie.'
} finally {
paying.value = false
}
}
</script>
<template>
<main class="container wrapper">
<div class="row" style="gap: 12px; margin-bottom: 20px">
<NuxtLink to="/menu" class="btn btn-ghost btn-icon" aria-label="Retour au menu"><Icon name="back" :stroke="2.4" /></NuxtLink>
<h1 class="display" style="font-size: clamp(28px, 4vw, 40px)">Ton panier</h1>
</div>
<ClientOnly>
<div v-if="!lines.length" class="card empty">
<Toucan :size="110" />
<p class="strong" style="font-size: 18px">Ton panier est vide</p>
<p class="muted">Le Crousty t'attend 🔥</p>
<NuxtLink to="/menu" class="btn btn-primary btn-lg">Voir la carte</NuxtLink>
</div>
<form v-else class="grid" @submit.prevent="submit">
<div class="col">
<!-- Articles -->
<section aria-label="Articles" class="stack" style="gap: 10px">
<article v-for="l in lines" :key="l.key" class="card item">
<img v-if="l.img" :src="l.img" alt="" class="thumb">
<span v-else class="thumb ph" />
<div class="grow">
<div class="row" style="justify-content: space-between; gap: 8px">
<h2 class="strong" style="font-size: 16px">{{ l.name }} · {{ l.size }}</h2>
<strong class="price">{{ eur(l.unit * l.qty) }}</strong>
</div>
<p class="small muted">{{ [l.meat, l.sauce, ...l.extrasLabel].filter(Boolean).join(' · ') }}<template v-if="l.note"> · « {{ l.note }} »</template></p>
<div class="qty" style="margin-top: 8px">
<button type="button" :aria-label="`Retirer un ${l.name}`" @click="setQty(l.key, l.qty - 1)">−</button>
<span>{{ l.qty }}</span>
<button type="button" :aria-label="`Ajouter un ${l.name}`" @click="setQty(l.key, l.qty + 1)">+</button>
</div>
</div>
</article>
<NuxtLink to="/menu" class="addmore">+ Ajouter un plat</NuxtLink>
</section>
<!-- Mode -->
<section aria-labelledby="mode-h" class="stack" style="gap: 12px">
<h2 id="mode-h" class="h">Comment tu le veux ?</h2>
<div class="seg" role="group" aria-label="Mode de commande">
<button type="button" :aria-pressed="liv" @click="mode = 'livraison'">Livraison</button>
<button type="button" :aria-pressed="!liv" @click="mode = 'retrait'">Retrait</button>
</div>
<template v-if="liv">
<div class="field">
<label class="label" for="adr">Adresse de livraison</label>
<input id="adr" v-model="address" class="input" autocomplete="street-address" placeholder="Ex. : 12 rue des Arts, 74200 Thonon-les-Bains">
</div>
<p class="note ok"><Icon name="check" :size="18" :stroke="2.6" /> Livraison Thonon et environs ({{ settings?.radiusKm ?? 5 }} km) · {{ fee ? `frais ${eur(fee)}` : 'livraison offerte' }} · offerte dès {{ eur(freeFrom) }}</p>
<p v-if="belowMin" class="note warn">Minimum de commande en livraison : {{ eur(minOrder) }}</p>
</template>
<template v-else>
<p class="label">Créneau de retrait · 2 avenue de Champagne</p>
<div class="slots">
<button v-for="s in slots" :key="s" type="button" class="opt" :aria-pressed="slot === s" :class="{ wide: s === 'Dès que possible' }" @click="slot = s">{{ s === 'Dès que possible' ? `Dès que possible · ~${wait} min` : s }}</button>
</div>
</template>
</section>
<!-- Coordonnées -->
<section aria-labelledby="co-h" class="stack" style="gap: 12px">
<h2 id="co-h" class="h">Tes coordonnées</h2>
<div class="field"><label class="label" for="nom">Prénom et nom</label><input id="nom" v-model="name" class="input" autocomplete="name" placeholder="Ex. : Sami B."></div>
<div class="field"><label class="label" for="tel">Téléphone</label><input id="tel" v-model="phone" class="input" type="tel" autocomplete="tel" placeholder="06 12 34 56 78"></div>
<p class="small muted">On te prévient par SMS quand c'est prêt.</p>
</section>
<!-- Paiement -->
<section aria-labelledby="pay-h" class="stack" style="gap: 10px">
<h2 id="pay-h" class="h">Paiement</h2>
<button v-for="p in pays" :key="p.id" type="button" class="payopt" :aria-pressed="payment === p.id" @click="payment = p.id">
<span class="radio" /><span class="grow">{{ p.label }}</span><span class="small muted">{{ p.hint }}</span>
</button>
<p class="small muted row" style="gap: 6px"><Icon name="lock" :size="14" /> Paiement sécurisé — simulé dans cette démo</p>
</section>
</div>
<!-- Récap -->
<aside class="recap card" aria-labelledby="recap-h">
<h2 id="recap-h" class="h">Récap</h2>
<dl class="tot">
<dt>Sous-total ({{ count }} article{{ count > 1 ? 's' : '' }})</dt><dd>{{ eur(subtotal) }}</dd>
<dt>{{ liv ? 'Livraison' : 'Retrait sur place' }}</dt><dd>{{ liv ? eur(fee) : 'Gratuit' }}</dd>
<dt>Frais de service</dt><dd style="color: var(--leaf)">0,00 €</dd>
<dt class="big">Total</dt><dd class="big price">{{ eur(total) }}</dd>
</dl>
<p class="small muted">Tu commandes en direct : pas de commission d'appli, 100 % pour le resto 🔥</p>
<p v-if="!open" class="note warn">Les commandes sont en pause pour le moment.</p>
<p v-if="error" class="note warn" role="alert">{{ error }}</p>
<button class="btn btn-primary btn-lg btn-block" type="submit" :disabled="paying || !open">
<template v-if="paying"><span class="spinner" /> Paiement en cours…</template>
<template v-else><Icon name="lock" :size="18" :stroke="2.4" /> {{ payment === 'especes' ? 'Commander' : 'Payer et commander' }} — {{ eur(total) }}</template>
</button>
</aside>
</form>
</ClientOnly>
</main>
</template>
<style scoped>
.wrapper { padding-top: 24px; padding-bottom: 40px; }
.empty { padding: 40px 24px; display: flex; flex-direction: column; align-items: center; gap: 10px; text-align: center; max-width: 520px; margin: 0 auto; }
.grid { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; }
.col { flex: 999 1 480px; min-width: 0; display: flex; flex-direction: column; gap: 30px; }
.h { font-size: 18px; font-weight: 800; color: #fff; }
.item { display: flex; gap: 12px; padding: 12px; }
.thumb { width: 68px; height: 68px; object-fit: contain; flex: none; border-radius: 14px; background: var(--surface-2); padding: 4px; }
.thumb.ph { display: block; }
.addmore { display: flex; align-items: center; justify-content: center; min-height: 48px; border-radius: 16px; border: 1px dashed rgba(255,180,58,.5); font-weight: 700; text-decoration: none; }
.note { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-radius: 14px; font-size: 13px; font-weight: 600; }
.note.ok { background: rgba(140,198,63,.1); border: 1px solid rgba(140,198,63,.3); }
.note.ok :deep(svg) { color: var(--leaf); flex: none; }
.note.warn { background: rgba(232,98,42,.12); border: 1px solid rgba(232,98,42,.45); }
.slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; }
.slots .wide { grid-column: 1 / -1; }
.payopt { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 0 16px; border-radius: 16px; border: 1px solid var(--line-2); background: var(--surface); color: var(--cream); font-weight: 700; cursor: pointer; text-align: left; }
.radio { width: 22px; height: 22px; border-radius: 50%; border: 2px solid rgba(243,227,195,.35); flex: none; transition: border .15s; }
.payopt[aria-pressed="true"] { border-color: var(--orange-1); }
.payopt[aria-pressed="true"] .radio { border: 7px solid var(--orange-1); }
.recap { flex: 1 1 320px; padding: 22px; display: flex; flex-direction: column; gap: 14px; position: sticky; top: 92px; }
.tot { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 8px; font-size: 14px; }
.tot dt { opacity: .75; }
.tot dd { margin: 0; font-weight: 700; text-align: right; }
.tot .big { padding-top: 10px; border-top: 1px solid var(--line); font-size: 20px; font-weight: 800; opacity: 1; color: #fff; }
.tot dd.big { color: var(--orange-1); }
.spinner { width: 18px; height: 18px; border-radius: 50%; border: 3px solid rgba(20,16,13,.3); border-top-color: var(--bg); animation: spin .8s linear infinite; }
@media (max-width: 1023px) { .recap { position: static; } }
</style>