- aggiunto componenti per Home Template... ma ancora da provare

- sistemato catprods
- Sistemato menu
This commit is contained in:
Surya Paolo
2025-09-22 19:09:14 +02:00
parent 917cdaa754
commit c8589e794f
114 changed files with 3594207 additions and 316 deletions

View File

@@ -24,29 +24,35 @@
</q-expansion-item>
<!-- Foglia -->
<router-link v-else :to="getroute(item)">
<q-item clickable :to="getroute(item)" active-class="my-menu-active">
<q-item-section thumbnail>
<q-avatar
:icon="item.materialIcon"
:size="item.iconsize || '2rem'"
:font-size="item.iconsize || '2rem'"
text-color="primary"
square
rounded
/>
</q-item-section>
<q-item-section>
<span :class="item.extraclass">{{ tools.getLabelByItem(item) }}</span>
<span v-if="item.subtitle" class="subtitle">{{ item.subtitle }}</span>
</q-item-section>
</q-item>
</router-link>
<q-item
v-else
clickable
:to="getroute(item)"
active-class="my-menu-active"
>
<q-item-section thumbnail>
<q-avatar
:icon="item.materialIcon"
:size="item.iconsize || '2rem'"
:font-size="item.iconsize || '2rem'"
text-color="primary"
square
rounded
/>
</q-item-section>
<q-item-section>
<span :class="item.extraclass">{{ tools.getLabelByItem(item) }}</span>
<span
v-if="item.subtitle"
class="subtitle"
>{{ item.subtitle }}</span
>
</q-item-section>
</q-item>
</div>
</template>
<script lang="ts" src="./CMenuItem.ts">
</script>
<script lang="ts" src="./CMenuItem.ts"></script>
<style lang="scss" scoped>
@import './CMenuItem.scss';

View File

@@ -35,7 +35,7 @@
<q-editor
ref="editorRef"
content-class="wrap_anywhere"
content-class="styled-content"
toolbar-text-color="white"
toolbar-toggle-color="yellow-8"
toolbar-bg="primary"

View File

View File

@@ -0,0 +1,43 @@
import { defineComponent } from 'vue';
// Importiamo icone da Quasar - esempio con quelle disponibili tramite Quasar Extras
// Assicurati di averle abilitate in quasar.config.js
import { IFeatSection } from 'app/src/model';
import { tools } from 'app/src/store/Modules/tools';
import { useQuasar } from 'quasar';
export default defineComponent({
name: 'HeroSection',
props: {
title: {
type: String,
required: true,
},
isDark: {
type: Boolean,
required: false,
default: false,
},
subtitle: {
type: String,
required: false,
default: '',
},
description: {
type: String,
required: false,
default: '',
},
features: {
type: Array as () => IFeatSection[],
required: true,
},
},
setup(props) {
return {
tools,
};
},
});

View File

@@ -0,0 +1,10 @@
<template>
<div :class="{'bg-dark': isDark}" class="q-py-xl">
</div>
</template>
<script lang="ts" src="./HeroSection.ts"></script>
<style lang="scss" scoped>
@import './HeroSection.scss';
</style>

View File

@@ -0,0 +1 @@
export { default as HeroSection } from './HeroSection.vue'

View File

@@ -10,7 +10,7 @@ export default defineComponent({
},
emits: ['select', 'edit', 'delete', 'open'],
setup(props, { emit }) {
const showGrip = computed(() => props.variant === 'menu');
const showGrip = true
const displayPath = (path?: string) => {
if (!path) return '-';

View File

@@ -516,7 +516,7 @@ export default defineComponent({
}
function imglogo() {
return `../../${tools.getimglogo()}`;
return `${tools.getimglogo()}`;
}
function getappname() {

View File

@@ -1,9 +1,25 @@
<template>
<div v-if="globalStore.showHeader">
<q-header v-if="site" reveal elevated :class="getClassColorHeader" :style="`color: ` + getColorText + `;`">
<q-toolbar color="primary" :glossy="!$q.platform.is.ios && !$q.platform.is.android" :inverted="$q.platform.is.ios"
class="toolbar">
<q-btn flat dense round @click="clickMenu3Orizz" aria-label="Menu">
<q-header
v-if="site"
reveal
elevated
:class="getClassColorHeader"
:style="`color: ` + getColorText + `;`"
>
<q-toolbar
color="primary"
:glossy="!$q.platform.is.ios && !$q.platform.is.android"
:inverted="$q.platform.is.ios"
class="toolbar"
>
<q-btn
flat
dense
round
@click="clickMenu3Orizz"
aria-label="Menu"
>
<q-icon name="menu" />
</q-btn>
@@ -19,15 +35,34 @@
<!--I'm only rendered on Electron!-->
</div>
<q-btn size="md" id="newvers" v-if="isNewVersionAvailable() || data.updateExists" color="secondary" rounded
icon="refresh" class="btnNewVersShow" @click="RefreshApp()" :label="t('notification.newVersionAvailable')">
<q-btn
size="md"
id="newvers"
v-if="isNewVersionAvailable() || data.updateExists"
color="secondary"
rounded
icon="refresh"
class="btnNewVersShow"
@click="RefreshApp()"
:label="t('notification.newVersionAvailable')"
>
</q-btn>
<q-toolbar-title class="row items-center">
<q-avatar @click="toHome" class="imglink">
<img :src="imglogo()" height="27" alt="Immagine Logo" />
<q-avatar
@click="toHome"
class="imglink"
>
<img
:src="imglogo()"
height="27"
alt="Immagine Logo"
/>
</q-avatar>
<div v-if="$q.screen.gt.xs" class="q-mx-sm titlesite">
<div
v-if="$q.screen.gt.xs"
class="q-mx-sm titlesite"
>
{{ getappname() }}
</div>
</q-toolbar-title>
@@ -45,28 +80,76 @@
</div>
-->
<div v-if="site.confpages && site.confpages?.show_darkopt" class="text-h7">
<q-toggle :icon="'fas fa-moon'" v-model="dark"> </q-toggle>
<div
v-if="site.confpages && site.confpages?.show_darkopt"
class="text-h7"
>
<q-toggle
:icon="'fas fa-moon'"
v-model="dark"
>
</q-toggle>
</div>
<div v-if="
tools.isLogged() &&
(isAdmin() || tools.isCollaboratore())
" class="text-h7">
<q-toggle :icon="'fas fa-pencil-alt'" v-model="editOn"> </q-toggle>
<div
v-if="tools.isLogged() && (isAdmin() || tools.isCollaboratore())"
class="text-h7"
>
<q-toggle
:icon="'fas fa-pencil-alt'"
v-model="editOn"
>
</q-toggle>
</div>
<q-btn v-if="!isonline() && site.confpages && site.confpages?.showConnected" flat dense round
aria-label="Connection">
<q-icon :name="iconConn" :class="clIconConn"></q-icon>
<q-icon v-if="isUserNotAuth" name="device_unknown"></q-icon>
<div
v-if="tools.isLogged() && (isAdmin() || tools.isCollaboratore())"
>
<q-btn
flat
dense
round
icon="settings"
:to="{ name: 'admin-dashboard' }"
aria-label="Apri pannello amministrazione"
/>
</div>
<q-btn
v-if="!isonline() && site.confpages && site.confpages?.showConnected"
flat
dense
round
aria-label="Connection"
>
<q-icon
:name="iconConn"
:class="clIconConn"
></q-icon>
<q-icon
v-if="isUserNotAuth"
name="device_unknown"
></q-icon>
</q-btn>
<q-btn-dropdown stretch v-if="isfinishLoading && static_data.lang_available.length > 1" flat :label="langshort"
auto-close>
<q-btn-dropdown
stretch
v-if="isfinishLoading && static_data.lang_available.length > 1"
flat
:label="langshort"
auto-close
>
<q-list bordered>
<q-item clickable v-ripple v-for="langrec in static_data.lang_available" :key="langrec.value"
@click="lang = langrec.value">
<q-item
clickable
v-ripple
v-for="langrec in static_data.lang_available"
:key="langrec.value"
@click="lang = langrec.value"
>
<q-item-section avatar>
<img :src="langrec.image" class="flagimg" alt="flag" />
<img
:src="langrec.image"
class="flagimg"
alt="flag"
/>
</q-item-section>
<q-item-section>
{{ langrec.label }}
@@ -92,155 +175,315 @@
<!-- BUTTON USER BAR -->
<q-btn class="q-mx-xs" v-if="
site.confpages && site.confpages?.enableEcommerce && tools.isLogged()
" round dense flat @click="rightCartOpen = !rightCartOpen" icon="fas fa-shopping-cart">
<q-badge v-if="getnumItemsCart() > 0" color="red" floating transparent>
<q-btn
class="q-mx-xs"
v-if="site.confpages && site.confpages?.enableEcommerce && tools.isLogged()"
round
dense
flat
@click="rightCartOpen = !rightCartOpen"
icon="fas fa-shopping-cart"
>
<q-badge
v-if="getnumItemsCart() > 0"
color="red"
floating
transparent
>
{{ getnumItemsCart() }}
</q-badge>
</q-btn>
<q-btn class="q-mx-xs" v-if="
site.confpages &&
site.confpages?.enableEcommerce &&
tools.isLogged() &&
getnumOrdersCart() > 0
" round dense flat to="/orderinfo" icon="fas fa-list-ol">
<q-badge v-if="getnumOrdersCart() > 0" color="blue" floating transparent>
<q-btn
class="q-mx-xs"
v-if="
site.confpages &&
site.confpages?.enableEcommerce &&
tools.isLogged() &&
getnumOrdersCart() > 0
"
round
dense
flat
to="/orderinfo"
icon="fas fa-list-ol"
>
<q-badge
v-if="getnumOrdersCart() > 0"
color="blue"
floating
transparent
>
{{ getnumOrdersCart() }}
</q-badge>
</q-btn>
<q-btn class="q-mx-xs" v-if="
site.confpages && site.confpages?.showUserMenu && !tools.isLogged()
" dense flat round icon="fas fa-user" @click="rightDrawerOpen = !rightDrawerOpen">
<q-btn
class="q-mx-xs"
v-if="site.confpages && site.confpages?.showUserMenu && !tools.isLogged()"
dense
flat
round
icon="fas fa-user"
@click="rightDrawerOpen = !rightDrawerOpen"
>
</q-btn>
<q-avatar v-else-if="
site.confpages &&
site.confpages?.showUserMenu &&
tools.isLogged() &&
getMyImg() &&
$q.screen.gt.sm
" size="36px" class="center_img cursor-pointer" @click="rightDrawerOpen = !rightDrawerOpen">
<q-img ratio="1" fit="cover" :src="getMyImg()" :alt="Username()" img-class="imgprofile_small"
stretch="false" />
<q-avatar
v-else-if="
site.confpages &&
site.confpages?.showUserMenu &&
tools.isLogged() &&
getMyImg() &&
$q.screen.gt.sm
"
size="36px"
class="center_img cursor-pointer"
@click="rightDrawerOpen = !rightDrawerOpen"
>
<q-img
ratio="1"
fit="cover"
:src="getMyImg()"
:alt="Username()"
img-class="imgprofile_small"
stretch="false"
/>
</q-avatar>
<q-btn v-else-if="$q.screen.gt.xs" class="q-mx-xs iconprofile_small" round dense flat
@click="rightDrawerOpen = !rightDrawerOpen" :icon="getMyImgforIcon()" :color="getcolormenu()">
<q-btn
v-else-if="$q.screen.gt.xs"
class="q-mx-xs iconprofile_small"
round
dense
flat
@click="rightDrawerOpen = !rightDrawerOpen"
:icon="getMyImgforIcon()"
:color="getcolormenu()"
>
</q-btn>
</q-toolbar>
</q-header>
<q-drawer side="left" bordered :show-if-above="globalStore.leftDrawerOpen" :breakpoint="800"
v-model="leftDrawerOpen" :content-class="['bg-grey-1', 'q-pa-sm']" :content-style="{ padding: '0px' }">
<q-drawer
side="left"
bordered
:show-if-above="globalStore.leftDrawerOpen"
:breakpoint="800"
v-model="leftDrawerOpen"
:content-class="['bg-grey-1', 'q-pa-sm']"
:content-style="{ padding: '0px' }"
>
<drawer :clBase="clBase"></drawer>
</q-drawer>
<!-- USER BAR -->
<q-drawer v-if="site.confpages && site.confpages?.enableEcommerce" v-model="rightCartOpen" class="q-drawer-cart"
side="right" elevated>
<q-btn class="absolute-top-right" :style="`margin-right: 10px; color:` + getColorText + `;`" dense flat round
icon="close" @click="rightCartOpen = !rightCartOpen">
<q-drawer
v-if="site.confpages && site.confpages?.enableEcommerce"
v-model="rightCartOpen"
class="q-drawer-cart"
side="right"
elevated
>
<q-btn
class="absolute-top-right"
:style="`margin-right: 10px; color:` + getColorText + `;`"
dense
flat
round
icon="close"
@click="rightCartOpen = !rightCartOpen"
>
</q-btn>
<CSelectUserActive></CSelectUserActive>
<div v-if="tools.isLogged()" class="bg-primary text-white q-pa-sm q-mb-md" style="border-radius: 0px">
<q-icon name="fas fa-shopping-cart" class="q-mr-sm" />
{{ $t("ecomm.carrello_di", { user: products.userActive.username }) }}
<div
v-if="tools.isLogged()"
class="bg-primary text-white q-pa-sm q-mb-md"
style="border-radius: 0px"
>
<q-icon
name="fas fa-shopping-cart"
class="q-mr-sm"
/>
{{ $t('ecomm.carrello_di', { user: products.userActive.username }) }}
</div>
<CMyCart v-if="isfinishLoading"></CMyCart>
</q-drawer>
<!-- USER BAR -->
<q-drawer v-if="site.confpages && site.confpages?.showUserMenu" v-model="rightDrawerOpen" side="right" elevated>
<q-drawer
v-if="site.confpages && site.confpages?.showUserMenu"
v-model="rightDrawerOpen"
side="right"
elevated
>
<div id="profile">
<q-img class="absolute-top" src="/images/landing_first_section.png" style="height: 150px" alt="section page">
<q-img
class="absolute-top"
src="/images/landing_first_section.png"
style="height: 150px"
alt="section page"
>
</q-img>
<div class="absolute-top bg-transparent text-black center_img" style="margin-top: 10px">
<div :class="`text-center q-ma-xs boldhigh text-` + getColorText + ` text-h7`
">
{{ t("header.area_personale") }}
<div
class="absolute-top bg-transparent text-black center_img"
style="margin-top: 10px"
>
<div :class="`text-center q-ma-xs boldhigh text-` + getColorText + ` text-h7`">
{{ t('header.area_personale') }}
</div>
<div v-if="getMyImg()" class="row justify-center q-pa-md">
<q-avatar size="80px" class="center_img q-ma-md">
<q-img fit="cover" :src="getMyImg()" :alt="Username()" img-class="imgprofile" height="80px" />
<div
v-if="getMyImg()"
class="row justify-center q-pa-md"
>
<q-avatar
size="80px"
class="center_img q-ma-md"
>
<q-img
fit="cover"
:src="getMyImg()"
:alt="Username()"
img-class="imgprofile"
height="80px"
/>
</q-avatar>
</div>
<div v-if="tools.isLogged()" class="text-weight-bold text-user">
<div
v-if="tools.isLogged()"
class="text-weight-bold text-user"
>
{{ Username() }}<span v-if="myName()"> - {{ myName() }}</span>
<span v-if="mySurname()">&nbsp;{{ mySurname() }}</span>
</div>
<div class="row justify-evenly q-pa-xs-sm">
<div v-if="tools.isLogged() && isAdmin()" class="text-weight-bold text-user bg-red q-px-xs">
<div
v-if="tools.isLogged() && isAdmin()"
class="text-weight-bold text-user bg-red q-px-xs"
>
Admin
</div>
<div v-if="isSocio" class="text-weight-bold text-user q-px-xs">
<div
v-if="isSocio"
class="text-weight-bold text-user q-px-xs"
>
Socio
</div>
<div v-if="isSocioResidente()" class="text-weight-bold text-user q-px-xs bg-amber">
<div
v-if="isSocioResidente()"
class="text-weight-bold text-user q-px-xs bg-amber"
>
Residente
</div>
<div v-if="isConsiglio()" class="text-weight-bold text-user q-px-xs bg-deep-orange-10">
<div
v-if="isConsiglio()"
class="text-weight-bold text-user q-px-xs bg-deep-orange-10"
>
Consiglio
</div>
<div v-if="tools.isManager()" class="text-weight-bold text-user bg-blue q-px-xs">
<div
v-if="tools.isManager()"
class="text-weight-bold text-user bg-blue q-px-xs"
>
Segreteria
</div>
<div v-if="tools.isEditor()" class="text-weight-bold text-user bg-indigo q-px-xs">
<div
v-if="tools.isEditor()"
class="text-weight-bold text-user bg-indigo q-px-xs"
>
Editore
</div>
<div v-if="tools.isCommerciale()" class="text-weight-bold text-user bg-brown q-px-xs">
<div
v-if="tools.isCommerciale()"
class="text-weight-bold text-user bg-brown q-px-xs"
>
Commerciale
</div>
<div v-if="isFacilitatore()" class="text-weight-bold text-user q-px-xs">
<div
v-if="isFacilitatore()"
class="text-weight-bold text-user q-px-xs"
>
Facilitatore
</div>
<div v-if="isTratuttrici()" class="text-weight-bold text-user q-px-xs">
<div
v-if="isTratuttrici()"
class="text-weight-bold text-user q-px-xs"
>
Editor
</div>
</div>
<div v-if="!tools.isLogged()" class="text-user text-italic bg-red">
{{ t("user.loggati") }}
<div
v-if="!tools.isLogged()"
class="text-user text-italic bg-red"
>
{{ t('user.loggati') }}
</div>
<div v-if="tools.isLogged() && !tools.isVerified()" class="text-verified">
{{ t("components.authentication.email_verification.verify_email") }}
<div
v-if="tools.isLogged() && !tools.isVerified()"
class="text-verified"
>
{{ t('components.authentication.email_verification.verify_email') }}
</div>
<div v-if="tools.isLogged()" class="text-verified">
<div
v-if="tools.isLogged()"
class="text-verified"
>
<!-- <span class="text-white" v-if="Verificato()"> {{t('reg.verificato')}} </span> -->
<span class="text-user text-italic bg-red" v-if="!tools.Verificato()">
{{ t("reg.non_verificato") }}
<span
class="text-user text-italic bg-red"
v-if="!tools.Verificato()"
>
{{ t('reg.non_verificato') }}
</span>
</div>
<div v-if="tools.isLogged()" id="user-actions" class="column justify-center q-gutter-sm q-ma-sm center-150">
<q-btn rounded color="primary" icon="person" :to="`/my/` + getMyUsername()">{{ t("pages.profile") }}
<div
v-if="tools.isLogged()"
id="user-actions"
class="column justify-center q-gutter-sm q-ma-sm center-150"
>
<q-btn
rounded
color="primary"
icon="person"
:to="`/my/` + getMyUsername()"
>{{ t('pages.profile') }}
</q-btn>
<q-btn rounded color="negative" icon="exit_to_app" @click="logoutHandler">{{ t("login.esci") }}</q-btn>
<q-btn
rounded
color="negative"
icon="exit_to_app"
@click="logoutHandler"
>{{ t('login.esci') }}</q-btn
>
</div>
</div>
<div style="margin-top: 120px"></div>
<div v-show="!tools.isLogged()">
<div v-if="site.confpages && site.confpages?.showRegButton" class="q-ma-md" style="">
<div
v-if="site.confpages && site.confpages?.showRegButton"
class="q-ma-md"
style=""
>
<CSigninNoreg :showregbutt="site.confpages && site.confpages?.showRegButton">
</CSigninNoreg>
</div>
</div>
</div>
<div v-if="tools.isLogged()" class="q-mt-lg"></div>
<div
v-if="tools.isLogged()"
class="q-mt-lg"
></div>
</q-drawer>
</div>
</template>
<script lang="ts" src="./MyHeader.ts">
</script>
<script lang="ts" src="./MyHeader.ts"></script>
<style lang="scss" scoped>
@import "./MyHeader.scss";
@import './MyHeader.scss';
</style>

View File

View File

@@ -0,0 +1,168 @@
import { defineComponent, computed, ref, onMounted, watch } from 'vue';
import { useHomeStore } from 'src/stores/home.store';
import type { HomeCMS, GalleryItem, Pillar } from 'src/types/home';
import { date } from 'quasar';
import { Notify } from 'quasar';
import './HomePage.scss';
export default defineComponent({
name: 'HomePage',
props: {
initialData: { type: Object as () => HomeCMS | undefined, default: undefined },
enableParallax: { type: Boolean, default: true },
// SEO/Head (esposti per integrazione router-meta)
pageTitle: { type: String, default: 'Comunità & Permacultura' },
pageDescription: { type: String, default: 'Vita di comunità, autosufficienza, eventi e progetti.' },
ogImage: { type: String, default: '' }
},
setup(props) {
const store = useHomeStore();
// Stato UI editor
const sectionsEnabled = ref({
hero: true,
vision: true,
pillars: true,
events: true,
collabora: true,
testimonials: true,
gallery: true,
faq: true,
posts: true,
map: true,
newsletter: true,
finalCta: true
});
const sectionOptions = [
{ key: 'hero', label: 'Hero' },
{ key: 'vision', label: 'Visione' },
{ key: 'pillars', label: 'Pillars' },
{ key: 'events', label: 'Eventi' },
{ key: 'collabora', label: 'Collabora' },
{ key: 'testimonials', label: 'Testimonianze' },
{ key: 'gallery', label: 'Galleria' },
{ key: 'faq', label: 'FAQ' },
{ key: 'posts', label: 'News' },
{ key: 'map', label: 'Mappa' },
{ key: 'newsletter', label: 'Newsletter' },
{ key: 'finalCta', label: 'CTA finale' }
] as const;
// Lightbox
const lightbox = ref<{ open: boolean; current?: GalleryItem | null }>({ open: false, current: null });
const currentImage = computed(() => lightbox.value.current || null);
const openLightbox = (g: GalleryItem) => { lightbox.value.open = true; lightbox.value.current = g; };
// Carousel
const carouselSlide = ref(0);
const pauseCarousel = ref(false);
// Newsletter
const newsletter = ref({ email: '' });
const emailRule = (val: string) => /.+@.+\..+/.test(val) || 'Email non valida';
const subscribe = async () => {
try {
await store.subscribeNewsletter(newsletter.value.email);
Notify.create({ type: 'positive', message: 'Iscrizione effettuata. Grazie!' });
newsletter.value.email = '';
} catch (e: any) {
// Lo snackbar globale è già gestito dallinterceptor, ma mostriamo feedback locale
Notify.create({ type: 'negative', message: e?.message || 'Errore iscrizione' });
}
};
// Collabora
const collaboraOptions = [
{ key: 'vol', title: 'Volontariato', icon: 'volunteer_activism', excerpt: 'Dai una mano ai progetti in corso.', cta: 'Scrivici', to: '/collabora' },
{ key: 'res', title: 'Residenzialità', icon: 'home', excerpt: 'Vivi con noi periodi di prova e scambio.', cta: 'Info', to: '/residenzialita' },
{ key: 'don', title: 'Sostieni', icon: 'diversity_2', excerpt: 'Sostieni lecovillaggio con una donazione.', cta: 'Dona ora', to: '/sostieni' }
] as const;
// Vision values: usiamo i primi 4 pillars come “valori”
const visionValues = computed<Pillar[]>(() => (store.data?.pillars || []).slice(0, 4));
// Link utili
const collaboraLink = computed(() => '/collabora');
const eventsLink = computed(() => '/calendario-eventi');
const directionsLink = computed(() => 'https://maps.app.goo.gl/');
// Stato derivato
const data = computed(() => store.data);
const loading = computed(() => store.loading);
const eventsState = computed(() => ({ loading: store.loadingEvents, error: store.errorEvents }));
const postsState = computed(() => ({ loading: store.loadingPosts, error: store.errorPosts }));
const nextEvents = computed(() => store.nextEvents);
const latestPosts = computed(() => store.latestPosts);
// Formattazione date
const formatDate = (iso: string) => date.formatDate(iso, 'D MMMM YYYY', { locale: 'it-IT' });
// CTA click (tracking, scroll, ecc.)
const onCta = (cta: { label: string; to?: string; href?: string }) => {
if (cta.to === '/calendario-eventi') {
// esempio: potresti fare scroll a sezione eventi
const el = document.getElementById('events-heading');
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
// Caricamento iniziale
const reloadAll = async () => {
await Promise.all([
store.fetchHome(props.initialData),
store.fetchEvents(),
store.fetchPosts()
]);
};
// Salvataggio layout (solo client-side per demo)
const saveLayout = () => {
localStorage.setItem('home.sections', JSON.stringify(sectionsEnabled.value));
Notify.create({ type: 'positive', message: 'Layout salvato (locale).' });
};
// Ripristino layout
onMounted(async () => {
try {
const saved = localStorage.getItem('home.sections');
if (saved) sectionsEnabled.value = JSON.parse(saved);
} catch {}
await reloadAll();
});
// Aggiorna vision se cambia data
watch(() => store.data?.pillars, () => { /* no-op, computed si aggiorna */ });
return {
// props
enableParallax: props.enableParallax,
// data
data, loading,
sectionsEnabled, sectionOptions,
// events
nextEvents, latestPosts, eventsState, postsState,
formatDate,
// collabora
collaboraOptions, collaboraLink, eventsLink, directionsLink,
// gallery
lightbox, currentImage, openLightbox,
// newsletter
newsletter, emailRule, subscribe,
// carousel
carouselSlide, pauseCarousel,
// editor actions
reloadAll, saveLayout,
// cta
onCta
};
}
});

View File

@@ -0,0 +1,392 @@
<template>
<q-page class="home-page">
<!-- Skip link -->
<a class="skip-link" href="#main-content">Salta al contenuto principale</a>
<!-- Toolbar editor (toggle sezioni) -->
<div class="editor-toolbar q-pa-md q-gutter-sm" role="region" aria-label="Editor sezione pagina">
<q-card flat bordered class="q-pa-md rounded-xl editor-card">
<div class="row items-center q-col-gutter-sm">
<div class="col-12 col-sm-auto">
<div class="text-subtitle1 text-weight-medium">Sezioni visibili</div>
</div>
<div class="col-12 col-sm">
<div class="row q-col-gutter-sm">
<div class="col-auto" v-for="opt in sectionOptions" :key="opt.key">
<q-toggle
v-model="sectionsEnabled[opt.key]"
:label="opt.label"
size="md"
color="primary"
keep-color
dense
:aria-label="`Attiva sezione ${opt.label}`"
/>
</div>
</div>
</div>
<div class="col-12 col-sm-auto q-gutter-sm flex">
<q-btn unelevated color="primary" icon="refresh" label="Ricarica contenuti" @click="reloadAll" />
<q-btn flat color="primary" icon="save" label="Salva configurazione" @click="saveLayout" />
</div>
</div>
</q-card>
</div>
<!-- HERO -->
<section v-if="sectionsEnabled.hero" class="section section--hero" aria-labelledby="hero-heading">
<q-skeleton v-if="loading && !data?.hero" type="rect" height="60vh" />
<template v-else>
<q-parallax v-if="enableParallax" :src="data?.hero?.mediaUrl" :height="560">
<div class="hero-overlay" aria-hidden="true"></div>
<div class="hero-content">
<q-badge v-if="data?.hero?.badge" color="secondary" class="q-mb-md">{{ data?.hero?.badge }}</q-badge>
<h1 id="hero-heading" class="hero-title">{{ data?.hero?.title }}</h1>
<p class="hero-subtitle" v-if="data?.hero?.subtitle">{{ data?.hero?.subtitle }}</p>
<div class="q-gutter-sm">
<q-btn
v-for="(cta, i) in data?.hero?.ctas"
:key="'hero-cta-' + i"
:label="cta.label"
color="primary"
unelevated
:to="cta.to"
:href="cta.href"
@click="onCta(cta)"
/>
</div>
<slot name="hero-extra" />
</div>
</q-parallax>
<div v-else class="hero-fallback">
<q-img :src="data?.hero?.mediaUrl" ratio="16/9">
<div class="absolute-full hero-overlay"></div>
<div class="absolute-full flex flex-center column hero-content">
<q-badge v-if="data?.hero?.badge" color="secondary" class="q-mb-md">{{ data?.hero?.badge }}</q-badge>
<h1 id="hero-heading" class="hero-title">{{ data?.hero?.title }}</h1>
<p class="hero-subtitle" v-if="data?.hero?.subtitle">{{ data?.hero?.subtitle }}</p>
<div class="q-gutter-sm">
<q-btn
v-for="(cta, i) in data?.hero?.ctas"
:key="'hero-cta2-' + i"
:label="cta.label"
color="primary"
unelevated
:to="cta.to"
:href="cta.href"
@click="onCta(cta)"
/>
</div>
<slot name="hero-extra" />
</div>
</q-img>
</div>
</template>
</section>
<main id="main-content">
<!-- Visione/Mission -->
<section v-if="sectionsEnabled.vision" class="section section--vision" aria-labelledby="vision-heading">
<div class="container">
<h2 id="vision-heading" class="section-title">Visione &amp; Mission</h2>
<q-skeleton v-if="loading && !data?.pillars?.length" type="text" class="q-mb-md" />
<div class="row q-col-gutter-md">
<div
v-for="(val, idx) in visionValues"
:key="'vision-' + idx"
class="col-12 col-sm-6 col-md-3"
>
<q-card flat bordered class="rounded-xl h-100">
<q-card-section class="text-center">
<q-icon :name="val.icon" size="md" aria-hidden="true" />
<div class="text-subtitle1 q-mt-sm">{{ val.title }}</div>
<div class="text-body2 text-secondary" v-html="val.excerpt"></div>
</q-card-section>
</q-card>
</div>
</div>
</div>
</section>
<!-- Pillars -->
<section v-if="sectionsEnabled.pillars" class="section section--pillars" aria-labelledby="pillars-heading">
<div class="container">
<h2 id="pillars-heading" class="section-title">Il nostro Progetto</h2>
<q-skeleton v-if="loading && !data?.pillars?.length" type="rect" height="120px" class="q-mb-md" />
<div v-else class="row q-col-gutter-md">
<div
v-for="p in data?.pillars"
:key="p.id"
class="col-12 col-md-4"
>
<q-card flat bordered class="rounded-xl h-100">
<q-card-section>
<div class="row items-center no-wrap">
<q-icon :name="p.icon" size="md" class="q-mr-sm" :aria-label="p.title" />
<div class="text-h6 q-my-none">{{ p.title }}</div>
</div>
<p class="q-mt-sm text-body2">{{ p.excerpt }}</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="primary" label="Scopri di più" :to="p.to" />
</q-card-actions>
</q-card>
</div>
</div>
</div>
</section>
<!-- Eventi -->
<section v-if="sectionsEnabled.events" class="section section--events" aria-labelledby="events-heading">
<div class="container">
<div class="row items-end justify-between q-mb-md">
<h2 id="events-heading" class="section-title col-auto">Eventi</h2>
<div class="col-auto">
<q-btn flat color="primary" label="Vedi tutti" to="/calendario-eventi" />
</div>
</div>
<div v-if="eventsState.loading" class="q-gutter-md">
<q-skeleton type="rect" height="120px" v-for="i in 3" :key="'ev-sk-' + i" />
</div>
<div v-else-if="eventsState.error" class="empty-state">
<q-icon name="warning" class="q-mr-sm" />
<span>{{ eventsState.error }}</span>
</div>
<div v-else-if="!nextEvents?.length" class="empty-state">
<q-icon name="event_busy" class="q-mr-sm" />
<span>Nessun evento in programma. <q-btn flat color="primary" label="Proponi un evento" :to="collaboraLink" /></span>
</div>
<div v-else class="row q-col-gutter-md">
<div v-for="ev in nextEvents" :key="ev.id" class="col-12 col-md-6 col-lg-3">
<q-card flat bordered class="rounded-xl h-100">
<q-img :src="ev.cover" ratio="16/9" />
<q-card-section>
<div class="text-subtitle1 q-mb-xs">{{ ev.title }}</div>
<div class="text-caption text-secondary">
<q-icon name="event" size="16px" aria-hidden="true" /> {{ formatDate(ev.start) }}
<span v-if="ev.place"> · <q-icon name="place" size="16px" aria-hidden="true" /> {{ ev.place }}</span>
</div>
<p class="q-mt-sm text-body2 ellipsis-2-lines">{{ ev.teaser }}</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="primary" label="Dettagli/Iscriviti" :to="ev.to" />
</q-card-actions>
</q-card>
</div>
</div>
</div>
<slot name="below-events" />
</section>
<!-- Collabora / Unisciti -->
<section v-if="sectionsEnabled.collabora" class="section section--collabora" aria-labelledby="collabora-heading">
<div class="container">
<h2 id="collabora-heading" class="section-title">Collabora / Unisciti</h2>
<div class="row q-col-gutter-md">
<div v-for="opt in collaboraOptions" :key="opt.key" class="col-12 col-md-4">
<q-card flat bordered class="rounded-xl h-100">
<q-card-section>
<div class="row items-center no-wrap">
<q-icon :name="opt.icon" size="md" class="q-mr-sm" />
<div class="text-h6 q-my-none">{{ opt.title }}</div>
</div>
<p class="q-mt-sm text-body2">{{ opt.excerpt }}</p>
</q-card-section>
<q-card-actions align="right" class="q-pt-none">
<q-btn :label="opt.cta" color="primary" flat :href="opt.href" :to="opt.to" />
</q-card-actions>
</q-card>
</div>
</div>
</div>
<slot name="below-collabora" />
</section>
<!-- Testimonianze -->
<section v-if="sectionsEnabled.testimonials" class="section section--testi" aria-labelledby="testi-heading">
<div class="container">
<h2 id="testi-heading" class="section-title">Testimonianze</h2>
<q-carousel
v-model="carouselSlide"
:autoplay="5000"
animated
transition-prev="slide-right"
transition-next="slide-left"
swipeable
infinite
height="220px"
@mouseenter="pauseCarousel = true"
@mouseleave="pauseCarousel = false"
:autoplay="pauseCarousel ? 0 : 5000"
>
<q-carousel-slide
v-for="t in data?.testimonials"
:key="t.id"
>
<div class="testimonial">
<q-avatar v-if="t.avatar" size="56px" class="q-mb-sm"><img :src="t.avatar" :alt="t.author" /></q-avatar>
<blockquote class="quote">{{ t.quote }}</blockquote>
<div class="author">{{ t.author }} <span v-if="t.role" class="role"> {{ t.role }}</span></div>
</div>
</q-carousel-slide>
</q-carousel>
</div>
</section>
<!-- Galleria -->
<section v-if="sectionsEnabled.gallery" class="section section--gallery" aria-labelledby="gallery-heading">
<div class="container">
<h2 id="gallery-heading" class="section-title">Galleria</h2>
<div class="row q-col-gutter-sm">
<div v-for="g in data?.gallery" :key="g.id" class="col-6 col-md-3">
<q-img :src="g.src" :alt="g.alt" ratio="1" class="rounded-xl cursor-pointer" @click="openLightbox(g)" />
</div>
</div>
<q-dialog v-model="lightbox.open" persistent>
<q-card class="bg-dark text-white">
<q-card-section class="row items-center justify-between">
<div>{{ currentImage?.alt }}</div>
<q-btn flat dense round icon="close" v-close-popup aria-label="Chiudi" />
</q-card-section>
<q-img :src="currentImage?.src" :alt="currentImage?.alt" ratio="16/9" />
</q-card>
</q-dialog>
</div>
</section>
<!-- FAQ -->
<section v-if="sectionsEnabled.faq" class="section section--faq" aria-labelledby="faq-heading">
<div class="container">
<h2 id="faq-heading" class="section-title">FAQ</h2>
<q-list bordered class="rounded-xl">
<q-expansion-item
v-for="(f, i) in data?.faq"
:key="'faq-' + i"
expand-separator
:label="f.q"
dense
>
<q-card>
<q-card-section class="text-body2">{{ f.a }}</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</div>
</section>
<!-- News / Blog -->
<section v-if="sectionsEnabled.posts" class="section section--news" aria-labelledby="news-heading">
<div class="container">
<div class="row items-end justify-between q-mb-md">
<h2 id="news-heading" class="section-title">News / Blog</h2>
<div class="col-auto">
<q-btn flat color="primary" label="Tutti gli articoli" to="/blog" />
</div>
</div>
<div v-if="postsState.loading" class="q-gutter-md">
<q-skeleton type="rect" height="100px" v-for="i in 3" :key="'post-sk-' + i" />
</div>
<div v-else-if="postsState.error" class="empty-state">
<q-icon name="warning" class="q-mr-sm" />
<span>{{ postsState.error }}</span>
</div>
<div v-else class="row q-col-gutter-md">
<div v-for="p in latestPosts" :key="p.id" class="col-12 col-md-4">
<q-card flat bordered class="rounded-xl h-100">
<q-img v-if="p.cover" :src="p.cover" ratio="16/9" />
<q-card-section>
<div class="text-subtitle1">{{ p.title }}</div>
<div class="text-caption text-secondary q-mt-xs">
<q-icon name="schedule" size="16px" aria-hidden="true" />
{{ formatDate(p.date) }}
<span v-if="p.category"> · {{ p.category }}</span>
</div>
<p class="q-mt-sm text-body2 ellipsis-3-lines">{{ p.teaser }}</p>
</q-card-section>
<q-card-actions align="right">
<q-btn flat color="primary" label="Leggi tutto" :to="p.to" />
</q-card-actions>
</q-card>
</div>
</div>
</div>
</section>
<!-- Mappa / Sedi -->
<section v-if="sectionsEnabled.map" class="section section--map" aria-labelledby="map-heading">
<div class="container">
<h2 id="map-heading" class="section-title">Dove siamo</h2>
<div class="map-wrap rounded-xl">
<slot name="map">
<iframe
class="map-iframe"
title="Mappa della comunità"
src="https://www.openstreetmap.org/export/embed.html?bbox=12.30%2C45.21%2C12.34%2C45.23&layer=mapnik"
style="border:0;"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
></iframe>
</slot>
</div>
<div class="q-mt-md">
<q-btn color="primary" unelevated label="Come arrivare" :href="directionsLink" target="_blank" />
</div>
</div>
</section>
<!-- Newsletter -->
<section v-if="sectionsEnabled.newsletter" class="section section--newsletter" aria-labelledby="newsletter-heading">
<div class="container">
<h2 id="newsletter-heading" class="section-title">Newsletter</h2>
<q-form @submit.prevent="subscribe">
<div class="row items-center q-col-gutter-sm">
<div class="col-12 col-md">
<q-input
v-model="newsletter.email"
type="email"
label="La tua email"
:rules="[emailRule]"
dense
outlined
aria-label="Email per iscrizione newsletter"
>
<template #prepend><q-icon name="mail" /></template>
</q-input>
</div>
<div class="col-12 col-md-auto">
<q-btn type="submit" color="primary" unelevated label="Iscriviti" />
</div>
</div>
<div class="text-caption text-secondary q-mt-xs">
Iscrivendoti accetti la <a :href="privacyLink">privacy policy</a>.
</div>
</q-form>
</div>
</section>
<!-- CTA finale -->
<section v-if="sectionsEnabled.finalCta" class="section section--cta" aria-labelledby="cta-heading">
<div class="container">
<div class="cta-card rounded-xl">
<h2 id="cta-heading" class="cta-title">Progettiamo insieme un nuovo mondo</h2>
<div class="q-gutter-sm">
<q-btn color="secondary" unelevated label="Eventi" :to="eventsLink" />
<q-btn color="primary" unelevated label="Collabora" :to="collaboraLink" />
</div>
</div>
</div>
<slot name="footer-cta" />
</section>
</main>
</q-page>
</template>
<script lang="ts" src="./HomePage.ts"></script>
<style lang="scss" scoped>
@import './HomePage.scss';
</style>

View File

@@ -2684,7 +2684,90 @@ body.body--dark {
text-align: right;
}
.ordine_scontato{
.ordine_scontato {
color: gray;
font-size: 0.85rem;
}
.hero {
position: relative;
border-radius: 20px;
overflow: hidden;
text-align: center;
padding: 40px 20px;
color: white;
// Altezza minima per evitare il collassamento
min-height: 400px;
// Centra il contenuto verticalmente
display: flex;
flex-direction: column;
justify-content: center;
// Background: immagine + overlay
background-size: cover;
background-position: center;
background-repeat: no-repeat;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5); // overlay scuro
z-index: 1;
border-radius: 20px;
}
}
.hero-content {
position: relative;
z-index: 2; // sopra l'overlay
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.intro {
font-size: 1.3rem;
color: #ddd; // testo chiaro, visibile sopra lo sfondo scuro
margin-bottom: 1.5rem;
font-style: italic;
}
.cta {
display: inline-block;
margin-top: 1rem;
padding: 14px 28px;
background-color: #4caf50;
color: white;
border-radius: 50px;
font-weight: 600;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 10px rgba(76, 175, 80, 0.3);
align-self: center; // centrato in flex-column
&:hover {
background-color: #388e3c;
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(76, 175, 80, 0.4);
}
}
// Stili aggiuntivi per la pagina
.container {
background: white;
border-radius: 18px;
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.09);
padding: 32px;
margin-bottom: 32px;
transition: transform 0.2s ease;
}
.container:hover {
transform: translateY(-4px);
}

View File

@@ -8,8 +8,6 @@ const msg_website_enUs = {
products: {
quantity: 'Quantità',
quantityAvailable: 'Disponibili',
stockQty: 'In Magazzino',
stockBloccatiQty: 'Bloccati In Magazzino',
weight: 'Peso',
stars: 'Voto',
color: 'Colore',
@@ -38,7 +36,6 @@ const msg_website_enUs = {
productslist: 'Lista Prodotti',
collabora: 'Collabora',
storehouses: 'Magazzino',
providers: 'Fornitori',
departments: 'Uffici',
orders: 'Ordini Ricevuti',
orders2: 'Ordini Ricevuti',

View File

@@ -8,7 +8,6 @@ const msg_website_es = {
products: {
quantity: 'Quantità',
quantityAvailable: 'Disponibili',
stockQty: 'In Magazzino',
weight: 'Peso',
stars: 'Voto',
color: 'Colore',

View File

@@ -1,7 +1,7 @@
const msg_website_it = {
ws: {
sitename: 'Nuovo Mondo',
siteshortname: 'NuovoMondo',
sitename: 'Gruppo Macro',
siteshortname: 'Gruppo Macro',
description: '',
keywords: '',
},
@@ -30,27 +30,6 @@ const msg_website_it = {
test: 'Test',
projects: 'Progetti',
report: 'Report Ore',
producer: 'Produttore',
orderinfo: 'Ordini Effettuati',
products: 'Prodotti',
cash: 'Cassa',
productInfos: 'Info Prodotti',
listinoprodotti: 'Listino Prodotti',
productslist: 'Lista Prodotti',
collabora: 'Collabora',
categories: 'Categorie',
storehouses: 'Magazzino',
providers: 'Fornitori',
catprods: 'Categorie',
subcatprods: 'Sotto-Categorie',
gasordine: 'Gas Ordine',
scontisticas: 'Scontistica',
departments: 'Uffici',
orders: 'Ordini Ricevuti',
orders2: 'Ordini Ricevuti',
sharewithus: 'Condividi con Noi',
checkout: 'Carrello',
payment: 'Pagamenti',
regok: 'Registrazione Confermata',
presentazione: 'Presentazione',
presentazione2: 'Presentazione',
@@ -96,14 +75,12 @@ const msg_website_it = {
eventodef: 'Evento:',
prova: 'prova',
dbop: 'Operazioni',
dbopmacro: 'Operazioni Macro',
projall: 'Comunitari',
groups: 'Lista Gruppi',
projectsShared: 'Condivisi da me',
myprojects: 'Privati',
favproj: 'Favoriti',
admin_ecommerce: 'ECommerce',
ecommerce: 'Prodotti',
ecommerce_menu: 'ECommerce1',
hours: 'Ore',
department: 'Uffici',
title: 'Titolo',
@@ -132,9 +109,8 @@ const msg_website_it = {
onlyif_logged: 'Solo se Loggati',
only_residenti: 'Solo Residenti',
only_consiglio: 'Solo Consiglieri',
only_collab: 'Solo Collaboratori',
color: 'Colore',
gasordini: 'Gas Ordini',
gestoreordini: 'Gestore Ordini',
},
msg: {
myAppName: 'Più che Buono',
@@ -196,7 +172,18 @@ const msg_website_it = {
descr: '<ul class="mylist" style="padding-left: 20px;">'
+ '<li>📱<strong>Condividendo la APP</strong> a tutti coloro che vogliono far parte insieme della crescita e sviluppo di una Nuova Era</li>'
+ '<li>👥 Aiutando a creare Gruppi Territoriali nella vostra città, impegnandosi a realizzare progetti per il Bene Comune, in onore ai principi Amorevoli e di condivisione.</li>'
+ '<li>🌱 Sostenendo le persone attorno a voi, e rispettando la nostra vera Casa: Madre Natura e Tutti gli Esseri Viventi. ❤️</li>' +
+ '<li>🌱 Sostenendo le persone attorno a voi, e rispettando la nostra vera Casa: Madre Natura e Tutti gli Esseri Viventi. ❤️</li>'
+ '<li>👨🏻‍💻 Con una <strong>piccola donazione</strong> per le spese dei Server, manutenzione e per i continui sviluppi e miglioramenti</li></ul>' +
'1) Tramite <strong><a href="https://paypal.me/paoloarena" target="_blank">Paypal</a></strong>:<br>' +
'<br>2) Tramite <strong>Satispay</strong>: <a href="https://www.satispay.com/app/match/link/money-box/S6Y-SVN--62712D42-35B0-4BB9-8511-410C2AB8CD45" target="_blank">Clicca qui</a><br>' +
'<div style="font-size: 1rem; background-color: white; color: blue; border: solid 2px #f00; margin: 5px; padding: 5px; border-radius: 10px; " ' +
'class="row justify-around">' +
'Se ancora non hai Satispay <a href="https://www.satispay.com/promo/PAOLOARENA4">Richiedila cliccando qui</a></br>' +
'</div>' +
'<br>3) Tramite <strong>Bonifico Bancario</strong>:<br>' +
'(Scrivi a Surya (<a href="https://t.me/surya1977">@surya1977</a>) per le coordinate</br>' +
'' +
'4) In alternativa scegli tu una forma di Dono <br />' +
'Grazie Mille per l\'Aiuto ed il Supporto' +
'<br>',
},

View File

@@ -1,6 +1,6 @@
/* PIUCHEBUONO APP
/* GRUPPOMACRO APP
*/
import type {
import {
IListRoutes,
ILang,
IPreloadImages,
@@ -8,6 +8,30 @@ import type {
} from '@model'
// const SHOW_PROJINTHEMENU = false
//
// let arrlistafavourite = []
// let arrlistaprojtutti = []
// let arrlistaprojmiei = []
// if (SHOW_PROJINTHEMENU) {
// arrlistaprojtutti = Projects.getters.listaprojects(RouteNames.projectsall)
// arrlistaprojmiei = Projects.getters.listaprojects(RouteNames.myprojects)
// arrlistafavourite = Projects.getters.listaprojects(RouteNames.favouriteprojects)
// }
// PROGETTI -> FAVORITI :
// if (arrlistafavourite.length > 0) {
// arrMenu.push({
// icon: 'favorite_border',
// nametranslate: 'pages.' + RouteNames.favouriteprojects,
// urlroute: RouteNames.favouriteprojects,
// level_parent: 0.0,
// level_child: 0.5,
// routes2: arrlistafavourite,
// idelem: ''
// })
// }
const firstPage = {
active: true,
order: 5,
@@ -20,6 +44,7 @@ const firstPage = {
infooter: true,
}
function getDynamicPages(site: ISites): IListRoutes[] {
const baseroutes: IListRoutes[] = [
@@ -41,13 +66,43 @@ function getDynamicPages(site: ISites): IListRoutes[] {
materialIcon: 'fas fa-test',
name: 'mypages.test',
component: () => import('@src/views/testServer/testServer.vue'),
inmenu: false,
infooter: false,
},
{
active: true,
order: 400,
path: '/test-lungo',
materialIcon: 'fas fa-test',
name: 'mypages.test_lungo',
component: () => import('@src/views/testLungo/testLungo.vue'),
inmenu: false,
infooter: false,
},
{
active: true,
order: 15,
path: '/provapao',
materialIcon: 'fas fa-house-user',
name: 'mypages.provapao',
component: () => import('@src/root/provapao/provapao.vue'),
meta: { requiresAuth: true },
inmenu: false,
infooter: false,
},
/*{
active: true,
{
active: site.confpages && site.confpages.enableCircuits,
order: 16,
path: '/circuits',
materialIcon: 'fas fa-coins',
name: 'mypages.circuits',
component: () => import('@src/views/user/mycircuits/mycircuits.vue'),
meta: { requiresAuth: true },
inmenu: true,
infooter: true,
},
{
active: site.confpages && site.confpages.enableEvents,
order: 20,
path: '/events',
materialIcon: 'fas fa-bullhorn',
@@ -56,17 +111,6 @@ function getDynamicPages(site: ISites): IListRoutes[] {
meta: { requiresAuth: true },
inmenu: true,
infooter: true,
},*/
{
active: site.confpages && site.confpages?.showProfile,
order: 120,
path: '/myprofile',
materialIcon: 'fas fa-user',
name: 'pages.profile',
component: () => import('@src/views/user/myprofile/myprofile.vue'),
meta: { requiresAuth: true },
inmenu: true,
infooter: true,
},
{
active: true,
@@ -80,7 +124,18 @@ function getDynamicPages(site: ISites): IListRoutes[] {
infooter: false,
},
{
active: true,
active: site.confpages && site.confpages.showProfile,
order: 120,
path: '/myprofile',
materialIcon: 'fas fa-user',
name: 'pages.profile',
component: () => import('@src/views/user/myprofile/myprofile.vue'),
meta: { requiresAuth: true },
inmenu: true,
infooter: true,
},
{
active: site.confpages && site.confpages.showProfile,
order: 120,
path: '/editprofile',
materialIcon: 'fas fa-user',
@@ -91,7 +146,7 @@ function getDynamicPages(site: ISites): IListRoutes[] {
infooter: false,
},
{
active: site.confpages && site.confpages?.showiscrittiMenu,
active: site.confpages && site.confpages.showiscrittiMenu,
order: 130,
path: '/friends',
materialIcon: 'fas fa-user-friends',
@@ -102,20 +157,7 @@ function getDynamicPages(site: ISites): IListRoutes[] {
infooter: true,
},
{
active: site.confpages && site.confpages?.enableCircuits,
order: 16,
path: '/circuits',
materialIcon: 'fas fa-coins',
name: 'mypages.circuits',
component: () => import('@src/views/user/mycircuits/mycircuits.vue'),
meta: { requiresAuth: true },
inmenu: true,
infooter: true,
onlyAdmin: true,
onlyManager: true,
},
{
active: site.confpages && site.confpages?.enableGroups,
active: site.confpages && site.confpages.enableGroups,
order: 132,
path: '/groups',
materialIcon: 'fas fa-users',
@@ -124,8 +166,6 @@ function getDynamicPages(site: ISites): IListRoutes[] {
meta: { requiresAuth: true },
inmenu: true,
infooter: false,
onlyAdmin: true,
onlyManager: true,
},
{
active: true,
@@ -201,16 +241,6 @@ function getDynamicPages(site: ISites): IListRoutes[] {
inmenu: false,
infooter: false,
},
{
active: true,
order: 150,
path: '/fundraising',
materialIcon: 'fas fa-hand-holding-heart',
name: 'pages.fundraising',
component: () => import('@src/root/fundraising/fundraising.vue'),
inmenu: false,
infooter: false,
},
{
active: true,
order: 80,

View File

@@ -0,0 +1,58 @@
{
"hero": {
"title": "Ecovillaggio Terra Viva",
"subtitle": "Comunità, permacultura e autosufficienza per un futuro condiviso.",
"badge": "Nuove date evento",
"mediaUrl": "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1600",
"ctas": [
{ "label": "Partecipa ad un incontro", "to": "/calendario-eventi" },
{ "label": "Collabora / Unisciti", "to": "/collabora" ]
]
},
"pillars": [
{ "id": "perm", "icon": "spa", "title": "Permacultura", "excerpt": "Progettiamo sistemi rigenerativi.", "to": "/progetto/permacultura" },
{ "id": "com", "icon": "groups", "title": "Comunità", "excerpt": "Relazioni consapevoli e mutuo aiuto.", "to": "/progetto/comunita" },
{ "id": "edu", "icon": "school", "title": "Educazione", "excerpt": "Laboratori e formazione continua.", "to": "/progetto/educazione" }
],
"events": [
{ "id": "e1", "title": "Open Day Ecovillaggio", "start": "2025-10-12T10:00:00Z", "place": "Podere Collealto", "teaser": "Visita guidata e pranzo condiviso.", "cover": "https://images.unsplash.com/photo-1501004318641-b39e6451bec6?q=80&w=1200", "to": "/eventi/open-day-ottobre" },
{ "id": "e2", "title": "Corso di Permacultura Base", "start": "2025-11-02T09:00:00Z", "place": "Sala Comunitaria", "teaser": "Introduzione ai principi e al design.", "cover": "https://images.unsplash.com/photo-1461354464878-ad92f492a5a0?q=80&w=1200", "to": "/eventi/permacultura-base" },
{ "id": "e3", "title": "Weekend di Costruzione Naturale", "start": "2025-12-06T09:00:00Z", "place": "Cantiere Paglia", "teaser": "Tecniche in paglia e terra cruda.", "cover": "https://images.unsplash.com/photo-1523419409543-8f3f3b00d3c1?q=80&w=1200", "to": "/eventi/costruzione-naturale" }
],
"testimonials": [
{ "id": "t1", "quote": "Qui ho trovato persone e un progetto che risuonano con i miei valori.", "author": "Marta P.", "role": "Volontaria" },
{ "id": "t2", "quote": "Un modo concreto di vivere la sostenibilità.", "author": "Giulio R.", "role": "Residente" },
{ "id": "t3", "quote": "I laboratori mi hanno cambiato il punto di vista.", "author": "Elisa D.", "role": "Partecipante" }
],
"gallery": [
{ "id": "g1", "src": "https://images.unsplash.com/photo-1520975793415-62f6d6c49c29?q=80&w=1200", "alt": "Orto sinergico" },
{ "id": "g2", "src": "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1200", "alt": "Case naturali" },
{ "id": "g3", "src": "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?q=80&w=1200", "alt": "Cerchio di condivisione" },
{ "id": "g4", "src": "https://images.unsplash.com/photo-1455218873509-8097305ee378?q=80&w=1200", "alt": "Compost e suolo vivo" },
{ "id": "g5", "src": "https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?q=80&w=1200", "alt": "Mulching" },
{ "id": "g6", "src": "https://images.unsplash.com/photo-1493815793585-c19ebc0a49b9?q=80&w=1200", "alt": "Laboratorio educativo" }
],
"faq": [
{ "q": "Si può venire a trovarvi?", "a": "Sì, durante gli Open Day o su appuntamento." },
{ "q": "Come funziona il contributo spese?", "a": "Chiediamo un contributo libero e responsabile." },
{ "q": "Accogliete famiglie con bambini?", "a": "Assolutamente sì." },
{ "q": "Posso fare volontariato?", "a": "Scrivici dalla pagina Collabora." },
{ "q": "Avete regole di convivenza?", "a": "Sì, basate su ascolto e responsabilità condivisa." },
{ "q": "Sono previste residenzialità?", "a": "Periodi di prova e progettazione condivisa." },
{ "q": "Come posso donare?", "a": "Trovi IBAN e PayPal nella pagina Sostieni." },
{ "q": "Organizzate corsi?", "a": "Formazione continua su permacultura e autocostruzione." }
],
"posts": [
{ "id": "p1", "title": "Raccolta dellacqua piovana: guida pratica", "date": "2025-08-20", "category": "Permacultura", "teaser": "Sistemi semplici per risparmiare acqua.", "cover": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?q=80&w=1200", "to": "/blog/raccolta-acqua" },
{ "id": "p2", "title": "Vivere in comunità: strumenti di facilitazione", "date": "2025-09-01", "category": "Comunità", "teaser": "Decisioni condivise e gestione dei conflitti.", "cover": "https://images.unsplash.com/photo-1519681393784-d120267933ba?q=80&w=1200", "to": "/blog/facilitazione" },
{ "id": "p3", "title": "Orto sinergico in 7 passi", "date": "2025-09-10", "category": "Orto", "teaser": "Dalla preparazione del suolo alla pacciamatura.", "cover": "https://images.unsplash.com/photo-1511690656952-34342bb7c2f2?q=80&w=1200", "to": "/blog/orto-sinergico" }
],
"partners": [
{ "id": "pa1", "name": "Rete Permacultura", "logo": "https://dummyimage.com/160x60/4caf50/ffffff&text=Permacultura", "href": "#" },
{ "id": "pa2", "name": "EcoHub", "logo": "https://dummyimage.com/160x60/6d4c41/ffffff&text=EcoHub", "href": "#" },
{ "id": "pa3", "name": "GreenLab", "logo": "https://dummyimage.com/160x60/2e7d32/ffffff&text=GreenLab", "href": "#" },
{ "id": "pa4", "name": "OpenSchool", "logo": "https://dummyimage.com/160x60/607d8b/ffffff&text=OpenSchool", "href": "#" },
{ "id": "pa5", "name": "Terra Viva Coop", "logo": "https://dummyimage.com/160x60/795548/ffffff&text=Terra+Viva", "href": "#" },
{ "id": "pa6", "name": "BioCostruire", "logo": "https://dummyimage.com/160x60/8bc34a/ffffff&text=BioCostruire", "href": "#" }
]
}

View File

@@ -0,0 +1,27 @@
<template>
<q-page class="q-pa-md">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-card bordered flat class="q-pa-md">
<div class="text-h6 q-mb-sm">Home</div>
<div class="text-body2">Modifica Hero, Pillars e FAQ.</div>
<q-btn class="q-mt-md" color="primary" :to="{ name:'admin-home' }" label="Apri editor Home" />
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card bordered flat class="q-pa-md">
<div class="text-h6 q-mb-sm">Eventi</div>
<div class="text-body2">Gestisci calendario eventi.</div>
<q-btn class="q-mt-md" color="primary" :to="{ name:'admin-events' }" label="Gestisci Eventi" />
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card bordered flat class="q-pa-md">
<div class="text-h6 q-mb-sm">Post</div>
<div class="text-body2">News / Blog.</div>
<q-btn class="q-mt-md" color="primary" :to="{ name:'admin-posts' }" label="Gestisci Post" />
</q-card>
</div>
</div>
</q-page>
</template>

View File

@@ -0,0 +1,101 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center justify-between q-mb-md">
<div class="text-h6">Eventi</div>
<q-btn color="primary" icon="add" label="Nuovo Evento" @click="openCreate" />
</div>
<q-table
:rows="rows"
:columns="columns"
row-key="_id"
flat bordered
:loading="loading"
rows-per-page-label="Righe per pagina"
>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn dense flat icon="edit" @click="openEdit(props.row)" />
<q-btn dense flat icon="delete" color="negative" @click="remove(props.row)" />
</q-td>
</template>
</q-table>
<q-dialog v-model="dlg.open" persistent>
<q-card style="min-width: 600px; max-width: 90vw;">
<q-card-section class="row items-center justify-between">
<div class="text-subtitle1">{{ dlg.mode === 'create' ? 'Nuovo Evento' : 'Modifica Evento' }}</div>
<q-btn dense flat round icon="close" v-close-popup />
</q-card-section>
<q-separator />
<q-card-section>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"><q-input v-model="model.title" label="Titolo" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="model.place" label="Luogo" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="model.start" label="Inizio (ISO)" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="model.end" label="Fine (ISO)" dense /></div>
<div class="col-12 col-md-12"><q-input v-model="model.teaser" label="Teaser" type="textarea" autogrow dense /></div>
<div class="col-12 col-md-8"><q-input v-model="model.cover" label="Cover URL" dense /></div>
<div class="col-12 col-md-4"><q-input v-model="model.to" label="to" dense /></div>
</div>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Annulla" v-close-popup />
<q-btn color="primary" :loading="saving" @click="save">Salva</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
// @ts-check
import { ref, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { useAdminStore } from 'src/stores/admin.store';
const $q = useQuasar();
const store = useAdminStore();
const loading = computed(() => store.loadingEvents);
const saving = computed(() => store.saving);
const rows = computed(() => store.events);
const columns = [
{ name: 'title', label: 'Titolo', field: 'title', align: 'left' },
{ name: 'start', label: 'Inizio', field: r => r.start?.slice?.(0, 16), align: 'left' },
{ name: 'place', label: 'Luogo', field: 'place', align: 'left' },
{ name: 'actions', label: 'Azioni', field: 'actions', align: 'right' }
];
const dlg = ref({ open: false, mode: 'create' });
const model = ref({ title:'', start:'', end:'', place:'', teaser:'', cover:'', to:'' });
onMounted(() => store.loadEvents({ limit: 100, sort: 'start' }));
function openCreate() {
dlg.value = { open: true, mode: 'create' };
model.value = { title:'', start:'', end:'', place:'', teaser:'', cover:'', to:'' };
}
function openEdit(row) {
dlg.value = { open: true, mode: 'edit' };
model.value = { ...row, start: row.start?.slice?.(0, 19), end: row.end?.slice?.(0, 19) };
}
async function save() {
try {
if (dlg.value.mode === 'create') await store.createEvent(model.value);
else await store.updateEvent(model.value._id, model.value);
dlg.value.open = false;
$q.notify({ type:'positive', message:'Salvato' });
} catch (e) {
$q.notify({ type:'negative', message: e?.message || 'Errore salvataggio' });
}
}
async function remove(row) {
$q.dialog({ title:'Conferma', message:`Eliminare "${row.title}"?`, cancel:true }).onOk(async () => {
await store.deleteEvent(row._id);
$q.notify({ type:'positive', message:'Eliminato' });
});
}
</script>

View File

@@ -0,0 +1,127 @@
<template>
<q-page class="q-pa-md">
<q-card flat bordered class="q-pa-md">
<div class="row items-center justify-between">
<div class="text-h6">Editor Home</div>
<q-btn color="primary" :loading="saving" @click="onSave" label="Salva" />
</div>
<q-separator class="q-my-md" />
<!-- HERO -->
<div class="text-subtitle1 q-mb-sm">Hero</div>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"><q-input v-model="form.hero.title" label="Titolo (H1)" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="form.hero.subtitle" label="Sottotitolo" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="form.hero.badge" label="Badge" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="form.hero.mediaUrl" label="Immagine (URL)" dense /></div>
</div>
<div class="q-mt-sm">
<div class="text-caption text-grey-7 q-mb-xs">CTAs</div>
<div v-for="(c,i) in form.hero.ctas" :key="'cta-'+i" class="row q-col-gutter-sm q-mb-xs">
<div class="col-12 col-md-4"><q-input v-model="c.label" label="Label" dense /></div>
<div class="col-12 col-md-4"><q-input v-model="c.to" label="to (router)" dense /></div>
<div class="col-12 col-md-3"><q-input v-model="c.href" label="href (link esterno)" dense /></div>
<div class="col-12 col-md-1 flex items-center">
<q-btn dense round icon="delete" color="negative" flat @click="form.hero.ctas.splice(i,1)" />
</div>
</div>
<q-btn flat icon="add" color="primary" label="Aggiungi CTA" @click="form.hero.ctas.push({ label:'', to:'', href:'' })" />
</div>
<q-separator class="q-my-lg" />
<!-- PILLARS -->
<div class="row items-center justify-between">
<div class="text-subtitle1">Pillars</div>
<q-btn flat icon="add" color="primary" label="Aggiungi" @click="addPillar" />
</div>
<q-list bordered separator class="q-mt-sm">
<q-item v-for="(p,i) in form.pillars" :key="p.id" clickable>
<q-item-section avatar><q-icon :name="p.icon || 'category'" /></q-item-section>
<q-item-section>
<div class="row q-col-gutter-sm">
<div class="col-12 col-md-2"><q-input v-model="p.id" label="ID" dense /></div>
<div class="col-12 col-md-2"><q-input v-model="p.icon" label="Icona (Material)" dense /></div>
<div class="col-12 col-md-3"><q-input v-model="p.title" label="Titolo" dense /></div>
<div class="col-12 col-md-4"><q-input v-model="p.excerpt" label="Descrizione breve" dense /></div>
<div class="col-12 col-md-1"><q-input v-model="p.to" label="to" dense /></div>
</div>
</q-item-section>
<q-item-section side>
<q-btn dense round flat icon="delete" color="negative" @click.stop="form.pillars.splice(i,1)" />
</q-item-section>
</q-item>
</q-list>
<q-separator class="q-my-lg" />
<!-- FAQ -->
<div class="row items-center justify-between">
<div class="text-subtitle1">FAQ</div>
<q-btn flat icon="add" color="primary" label="Aggiungi" @click="addFaq" />
</div>
<q-list bordered separator class="q-mt-sm">
<q-expansion-item v-for="(f,i) in form.faq" :key="'faq-'+i" :label="f.q || ('FAQ #' + (i+1))" dense expand-separator>
<div class="row q-col-gutter-sm q-pa-sm">
<div class="col-12 col-md-6"><q-input v-model="f.q" label="Domanda" dense /></div>
<div class="col-12 col-md-6"><q-input v-model="f.a" label="Risposta" dense type="textarea" autogrow /></div>
<div class="col-12">
<q-btn dense round flat icon="delete" color="negative" label="Rimuovi" @click="form.faq.splice(i,1)" />
</div>
</div>
</q-expansion-item>
</q-list>
</q-card>
</q-page>
</template>
<script setup>
// @ts-check
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { useAdminStore } from 'src/stores/admin.store';
const $q = useQuasar();
const store = useAdminStore();
const saving = computed(() => store.savingHome);
const form = ref({
hero: { title:'', subtitle:'', badge:'', mediaUrl:'', ctas: [] },
pillars: [],
faq: []
});
onMounted(async () => {
await store.loadHome();
if (store.home) {
// clona per editing
form.value = JSON.parse(JSON.stringify({
hero: store.home.hero || form.value.hero,
pillars: store.home.pillars || [],
faq: store.home.faq || []
}));
}
});
function addPillar() {
form.value.pillars.push({ id:'', icon:'category', title:'', excerpt:'', to:'' });
}
function addFaq() {
form.value.faq.push({ q:'', a:'' });
}
async function onSave() {
try {
await store.saveHome({
...store.home,
hero: form.value.hero,
pillars: form.value.pillars,
faq: form.value.faq
});
$q.notify({ type:'positive', message:'Home salvata' });
} catch (e) {
$q.notify({ type:'negative', message: e?.message || 'Errore salvataggio' });
}
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<q-layout view="hHh Lpr fFf">
<q-header elevated>
<q-toolbar>
<q-btn flat dense round icon="admin_panel_settings" />
<q-toolbar-title>Admin CMS</q-toolbar-title>
<q-space />
<q-btn flat :to="{ name:'admin-dashboard' }" label="Dashboard" />
<q-btn flat :to="{ name:'admin-home' }" label="Home" />
<q-btn flat :to="{ name:'admin-events' }" label="Eventi" />
<q-btn flat :to="{ name:'admin-posts' }" label="Post" />
</q-toolbar>
</q-header>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>

View File

@@ -0,0 +1,35 @@
// @ts-check
export default [
{
path: '/admin',
component: () => import('src/pages/admin/AdminLayout.vue'),
children: [
{
active: true,
path: '',
name: 'admin-dashboard',
component: () => import('src/pages/admin/AdminDashboard.vue'),
},
{
active: true,
path: 'home',
name: 'admin-home',
component: () => import('src/pages/admin/AdminHomeEditor.vue'),
},
{
active: true,
path: 'events',
name: 'admin-events',
component: () => import('src/pages/admin/AdminEvents.vue'),
},
{
active: true,
path: 'posts',
name: 'admin-posts',
component: () => import('src/pages/admin/AdminPosts.vue'),
},
],
// Semplice guard opzionale (sostituisci con la tua auth reale)
meta: { requiresAuth: false },
},
];

117
src/store/admin.store.js Normal file
View File

@@ -0,0 +1,117 @@
// @ts-check
import { defineStore } from 'pinia';
import api from 'src/services/api';
export const useAdminStore = defineStore('admin', {
state: () => ({
home: null,
loadingHome: false,
savingHome: false,
events: [],
posts: [],
loadingEvents: false,
loadingPosts: false,
saving: false,
error: null
}),
actions: {
async loadHome() {
this.loadingHome = true; this.error = null;
try {
const { data } = await api.get('/home');
this.home = data;
} catch (e) {
this.error = e?.message || 'Errore caricamento Home';
} finally {
this.loadingHome = false;
}
},
async saveHome(partial) {
this.savingHome = true; this.error = null;
try {
const payload = { ...(this.home || {}), ...(partial || {}) };
const { data } = await api.put('/home', payload);
this.home = data;
return data;
} catch (e) {
this.error = e?.message || 'Errore salvataggio Home';
throw e;
} finally {
this.savingHome = false;
}
},
async loadEvents(params = { limit: 50 }) {
this.loadingEvents = true; this.error = null;
try {
const { data } = await api.get('/events', { params });
this.events = Array.isArray(data?.items) ? data.items : data; // supporta entrambi i formati
} catch (e) {
this.error = e?.message || 'Errore caricamento Eventi';
} finally {
this.loadingEvents = false;
}
},
async createEvent(item) {
this.saving = true;
try {
const { data } = await api.post('/events', item);
this.events.unshift(data);
return data;
} finally { this.saving = false; }
},
async updateEvent(id, partial) {
this.saving = true;
try {
const { data } = await api.put(`/events/${id}`, partial);
const i = this.events.findIndex(e => e._id === id);
if (i >= 0) this.events[i] = data;
return data;
} finally { this.saving = false; }
},
async deleteEvent(id) {
this.saving = true;
try {
await api.delete(`/events/${id}`);
this.events = this.events.filter(e => e._id !== id);
} finally { this.saving = false; }
},
async loadPosts(params = { limit: 50, sort: '-date' }) {
this.loadingPosts = true; this.error = null;
try {
const { data } = await api.get('/posts', { params });
this.posts = Array.isArray(data?.items) ? data.items : data;
} catch (e) {
this.error = e?.message || 'Errore caricamento Post';
} finally {
this.loadingPosts = false;
}
},
async createPost(item) {
this.saving = true;
try {
const { data } = await api.post('/posts', item);
this.posts.unshift(data);
return data;
} finally { this.saving = false; }
},
async updatePost(id, partial) {
this.saving = true;
try {
const { data } = await api.put(`/posts/${id}`, partial);
const i = this.posts.findIndex(p => p._id === id);
if (i >= 0) this.posts[i] = data;
return data;
} finally { this.saving = false; }
},
async deletePost(id) {
this.saving = true;
try {
await api.delete(`/posts/${id}`);
this.posts = this.posts.filter(p => p._id !== id);
} finally { this.saving = false; }
}
}
});

130
src/stores/home.store.ts Normal file
View File

@@ -0,0 +1,130 @@
import { defineStore } from 'pinia';
import api from 'src/services/api';
import type { HomeCMS, EventItem, PostItem } from 'src/types/home';
type State = {
data: HomeCMS | null;
loading: boolean;
error: string | null;
// sottostati per sezioni dinamiche
loadingEvents: boolean;
errorEvents: string | null;
loadingPosts: boolean;
errorPosts: string | null;
_cacheAt?: number;
_eventsAt?: number;
_postsAt?: number;
};
const FIVE_MIN = 5 * 60 * 1000;
export const useHomeStore = defineStore('home', {
state: (): State => ({
data: null,
loading: false,
error: null,
loadingEvents: false,
errorEvents: null,
loadingPosts: false,
errorPosts: null,
_cacheAt: undefined,
_eventsAt: undefined,
_postsAt: undefined
}),
getters: {
nextEvents(state): EventItem[] {
const now = new Date();
const list = (state.data?.events || []).filter(e => new Date(e.start) >= now);
list.sort((a, b) => +new Date(a.start) - +new Date(b.start));
return list.slice(0, 4);
},
latestPosts(state): PostItem[] {
const list = (state.data?.posts || []).slice().sort((a, b) => +new Date(b.date) - +new Date(a.date));
return list.slice(0, 3);
}
},
actions: {
async fetchHome(prefetched?: HomeCMS) {
if (prefetched) {
this.data = prefetched;
this._cacheAt = Date.now();
return;
}
const fresh = !this._cacheAt || (Date.now() - this._cacheAt) > FIVE_MIN;
if (!fresh && this.data) return;
this.loading = true; this.error = null;
try {
const { data } = await api.get<HomeCMS>('/home');
this.data = data;
this._cacheAt = Date.now();
} catch (e: any) {
this.error = e?.message || 'Impossibile caricare la home, uso mock locale.';
// Fallback ai mock
const mock = await import('src/mocks/home.sample.json');
this.data = mock.default as HomeCMS;
this._cacheAt = Date.now();
} finally {
this.loading = false;
}
},
async fetchEvents() {
const fresh = !this._eventsAt || (Date.now() - this._eventsAt) > FIVE_MIN;
if (!fresh && (this.data?.events?.length || 0) > 0) return;
this.loadingEvents = true; this.errorEvents = null;
try {
const { data } = await api.get<EventItem[]>('/events', { params: { limit: 6 } });
this.data = this.data || ({} as HomeCMS);
this.data.events = data;
this._eventsAt = Date.now();
} catch (e: any) {
this.errorEvents = e?.message || 'Eventi non disponibili (mock)';
if (!this.data?.events?.length) {
const mock = await import('src/mocks/home.sample.json');
this.data = this.data || ({} as HomeCMS);
this.data.events = (mock.default as HomeCMS).events;
}
} finally {
this.loadingEvents = false;
}
},
async fetchPosts() {
const fresh = !this._postsAt || (Date.now() - this._postsAt) > FIVE_MIN;
if (!fresh && (this.data?.posts?.length || 0) > 0) return;
this.loadingPosts = true; this.errorPosts = null;
try {
const { data } = await api.get<PostItem[]>('/posts', { params: { limit: 3 } });
this.data = this.data || ({} as HomeCMS);
this.data.posts = data;
this._postsAt = Date.now();
} catch (e: any) {
this.errorPosts = e?.message || 'Post non disponibili (mock)';
if (!this.data?.posts?.length) {
const mock = await import('src/mocks/home.sample.json');
this.data = this.data || ({} as HomeCMS);
this.data.posts = (mock.default as HomeCMS).posts;
}
} finally {
this.loadingPosts = false;
}
},
async subscribeNewsletter(email: string) {
if (!/.+@.+\..+/.test(email)) throw new Error('Email non valida');
await api.post('/newsletter/subscribe', { email });
// niente stato locale: demandiamo a backend
}
}
});

25
src/types/home.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface HeroBlock {
title: string;
subtitle?: string;
badge?: string;
mediaUrl: string;
ctas: { label: string; to?: string; href?: string }[];
}
export interface Pillar { id: string; icon: string; title: string; excerpt: string; to?: string }
export interface EventItem { id: string; title: string; start: string; end?: string; place?: string; teaser?: string; cover?: string; to?: string }
export interface Testimonial { id: string; quote: string; author: string; role?: string; avatar?: string }
export interface GalleryItem { id: string; src: string; alt?: string }
export interface FaqItem { q: string; a: string }
export interface PostItem { id: string; title: string; date: string; category?: string; teaser?: string; cover?: string; to?: string }
export interface Partner { id: string; name: string; logo: string; href?: string }
export interface HomeCMS {
hero: HeroBlock;
pillars: Pillar[];
events: EventItem[];
testimonials: Testimonial[];
gallery: GalleryItem[];
faq: FaqItem[];
posts: PostItem[];
partners: Partner[];
}

View File

@@ -4,6 +4,7 @@ import { LandingFooter } from '@src/components/LandingFooter';
import { useUserStore } from '@store/UserStore';
import MixinUsers from '@src/mixins/mixin-users';
import { useI18n } from 'vue-i18n';
import { tools } from '@tools';
export default defineComponent({
name: 'Regok',
@@ -15,6 +16,7 @@ export default defineComponent({
return {
t,
tools,
};
},
});