backoffice first commit
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { api, resolveToken, resolveReturnUrl, clearToken, formatEuro } from './api.js'
|
||||
|
||||
const step = ref('cart') // cart | checkout | done
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const fieldErrors = ref({})
|
||||
const token = ref(null)
|
||||
const carrello = reactive({ items: [], totale: 0 })
|
||||
const numeroConfermato = ref(null)
|
||||
const returnUrl = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
nome: '', cognome: '', ragione_sociale: '', email: '', telefono: '',
|
||||
indirizzo: '', cap: '', citta: '', provincia: '', note: '',
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !carrello.items || carrello.items.length === 0)
|
||||
|
||||
async function loadCarrello() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
token.value = resolveToken()
|
||||
returnUrl.value = resolveReturnUrl()
|
||||
if (!token.value) {
|
||||
carrello.items = []
|
||||
carrello.totale = 0
|
||||
return
|
||||
}
|
||||
const data = await api.getCarrello(token.value)
|
||||
carrello.items = data.carrello.items
|
||||
carrello.totale = data.carrello.totale
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rimuovi(itemId) {
|
||||
try {
|
||||
const data = await api.rimuoviItem(token.value, itemId)
|
||||
carrello.items = data.carrello.items
|
||||
carrello.totale = data.carrello.totale
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function conferma() {
|
||||
submitting.value = true
|
||||
errorMsg.value = ''
|
||||
fieldErrors.value = {}
|
||||
try {
|
||||
const data = await api.conferma(token.value, { ...form })
|
||||
numeroConfermato.value = data.numero
|
||||
clearToken()
|
||||
step.value = 'done'
|
||||
} catch (e) {
|
||||
if (e.status === 422 && e.errors) {
|
||||
fieldErrors.value = e.errors
|
||||
errorMsg.value = 'Controlla i campi evidenziati.'
|
||||
} else {
|
||||
errorMsg.value = e.message
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCarrello)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<img src="/kreios-logo.jpg" alt="Krèios" />
|
||||
<h1>Il tuo preventivo</h1>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<p v-if="loading" class="muted center">Caricamento…</p>
|
||||
|
||||
<div v-else-if="errorMsg && step !== 'checkout'" class="alert">{{ errorMsg }}</div>
|
||||
|
||||
<!-- STEP CARRELLO -->
|
||||
<template v-if="!loading && step === 'cart'">
|
||||
<div v-if="isEmpty" class="empty">
|
||||
<p>Il tuo preventivo è vuoto.</p>
|
||||
<p class="muted">Configura un prodotto e aggiungilo al preventivo per vederlo qui.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="item in carrello.items" :key="item.id" class="card item">
|
||||
<img v-if="item.immagine_url" :src="item.immagine_url" alt="Anteprima" />
|
||||
<div class="item-body">
|
||||
<p class="item-title">{{ item.nome_modello || 'Prodotto' }}</p>
|
||||
<p class="item-sub">
|
||||
<span v-if="item.sistema">{{ item.sistema }}</span>
|
||||
<span v-if="item.larghezza && item.altezza"> · {{ item.larghezza }}×{{ item.altezza }} cm</span>
|
||||
</p>
|
||||
<div class="conf">
|
||||
<div v-for="row in item.configurazione" :key="row.label">
|
||||
<b>{{ row.label }}:</b> {{ row.value }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-foot">
|
||||
<button class="btn-remove" @click="rimuovi(item.id)">Rimuovi</button>
|
||||
<span class="price">
|
||||
{{ item.quantita }} × {{ formatEuro(item.prezzo_unitario) }} =
|
||||
{{ formatEuro(item.prezzo_totale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="totale-row">
|
||||
<span>Totale <span class="iva-nota">IVA 22% inclusa</span></span>
|
||||
<span>{{ formatEuro(carrello.totale) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" @click="step = 'checkout'">
|
||||
Richiedi il preventivo
|
||||
</button>
|
||||
<a v-if="returnUrl" class="btn-link" style="width:100%;margin-top:8px" :href="returnUrl">
|
||||
← Torna al configuratore
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- STEP CHECKOUT -->
|
||||
<template v-else-if="!loading && step === 'checkout'">
|
||||
<div class="card">
|
||||
<p class="muted" style="margin-top:0">
|
||||
Inserisci i tuoi dati: ti invieremo il preventivo via email e ti ricontatteremo.
|
||||
</p>
|
||||
|
||||
<div v-if="errorMsg" class="alert">{{ errorMsg }}</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>Nome *</label>
|
||||
<input v-model="form.nome" />
|
||||
<div v-if="fieldErrors.nome" class="err">{{ fieldErrors.nome[0] }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cognome</label>
|
||||
<input v-model="form.cognome" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Ragione sociale (opzionale)</label>
|
||||
<input v-model="form.ragione_sociale" />
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>Email *</label>
|
||||
<input v-model="form.email" type="email" />
|
||||
<div v-if="fieldErrors.email" class="err">{{ fieldErrors.email[0] }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefono</label>
|
||||
<input v-model="form.telefono" type="tel" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Indirizzo</label>
|
||||
<input v-model="form.indirizzo" />
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label>CAP</label>
|
||||
<input v-model="form.cap" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Città</label>
|
||||
<input v-model="form.citta" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Provincia</label>
|
||||
<input v-model="form.provincia" maxlength="5" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Note</label>
|
||||
<textarea v-model="form.note" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn-primary" :disabled="submitting" @click="conferma">
|
||||
{{ submitting ? 'Invio in corso…' : 'Conferma e invia' }}
|
||||
</button>
|
||||
<button class="btn-link" style="width:100%;margin-top:8px" @click="step = 'cart'">
|
||||
← Torna al preventivo
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- STEP CONFERMA -->
|
||||
<template v-else-if="step === 'done'">
|
||||
<div class="card center">
|
||||
<img class="done-logo" src="/kreios-logo.jpg" alt="Krèios" />
|
||||
<div class="success-icon">✅</div>
|
||||
<h2>Grazie!</h2>
|
||||
<p>La tua richiesta di preventivo <b>n. {{ numeroConfermato }}</b> è stata inviata.</p>
|
||||
<p class="muted">
|
||||
Riceverai a breve il PDF riassuntivo via email. Un nostro consulente ti ricontatterà al più presto.
|
||||
</p>
|
||||
<a v-if="returnUrl" class="btn-link" :href="returnUrl">← Torna al configuratore</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
const BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
...options,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.message || 'Errore di rete')
|
||||
err.status = res.status
|
||||
err.errors = data.errors
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getCarrello: (token) => request(`/carrello/${token}`),
|
||||
rimuoviItem: (token, itemId) => request(`/carrello/${token}/item/${itemId}`, { method: 'DELETE' }),
|
||||
conferma: (token, dati) =>
|
||||
request(`/carrello/${token}/conferma`, { method: 'POST', body: JSON.stringify(dati) }),
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'kreios_cart_token'
|
||||
const RETURN_URL_KEY = 'kreios_return_url'
|
||||
|
||||
export function resolveToken() {
|
||||
const url = new URL(window.location.href)
|
||||
const fromUrl = url.searchParams.get('token')
|
||||
const ritorno = url.searchParams.get('ritorno')
|
||||
if (ritorno) {
|
||||
localStorage.setItem(RETURN_URL_KEY, ritorno)
|
||||
}
|
||||
if (fromUrl) {
|
||||
localStorage.setItem(STORAGE_KEY, fromUrl)
|
||||
// pulisci l'URL mantenendo token/ritorno in localStorage
|
||||
url.searchParams.delete('token')
|
||||
url.searchParams.delete('ritorno')
|
||||
window.history.replaceState({}, '', url.pathname + url.search)
|
||||
return fromUrl
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
// URL del configuratore da cui arriva il carrello: passato dinamicamente come parametro
|
||||
// (vedi mostraLinkPreventivo in starter.js), non hardcoded, perché il configuratore può girare
|
||||
// su domini/porte diverse tra dev e produzione.
|
||||
export function resolveReturnUrl() {
|
||||
return localStorage.getItem(RETURN_URL_KEY)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
export function formatEuro(value) {
|
||||
return new Intl.NumberFormat('it-IT', { style: 'currency', currency: 'EUR' }).format(value || 0)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,93 @@
|
||||
:root {
|
||||
/* Palette del configuratore Krèios */
|
||||
--brand: #12373C;
|
||||
--brand-dark: #0b2427;
|
||||
--accent: #ffd700;
|
||||
--accent-dark: #e6c200;
|
||||
--bg: #f5f6f8;
|
||||
--card: #ffffff;
|
||||
--border: #e4e7ec;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--danger: #c0392b;
|
||||
--ok: #1e874b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.app-header img { height: 34px; width: auto; border-radius: 3px; }
|
||||
.app-header h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
|
||||
.container { max-width: 780px; margin: 0 auto; padding: 20px 16px 60px; }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.item { display: flex; gap: 14px; }
|
||||
.item img {
|
||||
width: 90px; height: 90px; object-fit: contain;
|
||||
background: #fafafa; border: 1px solid var(--border); border-radius: 8px; flex: none;
|
||||
}
|
||||
.item-body { flex: 1; min-width: 0; }
|
||||
.item-title { font-weight: 600; margin: 0 0 2px; }
|
||||
.item-sub { color: var(--muted); font-size: 13px; margin-bottom: 6px; }
|
||||
.conf { font-size: 13px; color: #374151; line-height: 1.5; }
|
||||
.conf b { color: var(--muted); font-weight: 500; }
|
||||
.item-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; }
|
||||
.price { font-weight: 700; }
|
||||
|
||||
.totale-row {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-size: 20px; font-weight: 700; color: var(--brand);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.iva-nota { color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||
|
||||
button {
|
||||
font: inherit; cursor: pointer; border: none; border-radius: 8px; padding: 12px 18px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent); color: #000; font-weight: 700; width: 100%;
|
||||
text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dark); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-link { background: none; color: var(--muted); padding: 6px; text-decoration: underline; }
|
||||
a.btn-link { display: inline-block; margin-top: 16px; text-align: center; }
|
||||
.btn-remove { background: none; color: var(--danger); padding: 4px 8px; font-size: 13px; }
|
||||
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.field input, .field textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font: inherit;
|
||||
}
|
||||
.field .err { color: var(--danger); font-size: 12px; margin-top: 3px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 520px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.center { text-align: center; }
|
||||
.empty { text-align: center; padding: 40px 20px; color: var(--muted); }
|
||||
.success-icon { font-size: 48px; }
|
||||
.done-logo { width: auto; border-radius: 3px; margin-bottom: 16px; }
|
||||
.alert { background: #fdecea; color: var(--danger); padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; font-size: 14px; }
|
||||
Reference in New Issue
Block a user