backoffice first commit

This commit is contained in:
2026-07-17 11:34:15 +02:00
parent 761db06329
commit 1c40ef6601
159 changed files with 25830 additions and 7875 deletions
+222
View File
@@ -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>
+59
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+93
View File
@@ -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; }