195 lines
11 KiB
Vue
195 lines
11 KiB
Vue
<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>
|