feat: implement cookie consent popup and related functionality

This commit is contained in:
2026-05-06 11:56:05 +02:00
parent d0096bf0cc
commit 8b86350a2d
10 changed files with 613 additions and 11 deletions
+7 -2
View File
@@ -4,11 +4,13 @@
import Services from "./components/Services/Services.svelte";
import Footer from "./components/Footer/Footer.svelte";
import Header from "./components/Header/Header.svelte";
import CookiePopUp from "./components/CookiePopUp/CookiePopUp.svelte";
import Contacts from "./pages/Contacts.svelte";
import Forbidden from "./pages/403.svelte";
import ServerError from "./pages/500.svelte";
import ErrorGeneric from "./pages/errore.svelte";
import NotFound from "./pages/404.svelte";
import { initializeStoredConsent } from "./lib/cookieConsent";
let pathname = '/'
@@ -31,10 +33,12 @@
return '404'
}
$: currentRoute = getRoute(pathname)
$: isCenteredMain = ['403', '404', '500', 'error'].includes(currentRoute)
let currentRoute = $derived(getRoute(pathname))
let isCenteredMain = $derived(['403', '404', '500', 'error'].includes(currentRoute))
onMount(() => {
initializeStoredConsent()
try {
const saved = localStorage.getItem('theme')
if (saved) {
@@ -79,4 +83,5 @@
{/if}
</main>
<Footer />
<CookiePopUp />
</div>
+2
View File
@@ -13,6 +13,8 @@
text-decoration: none;
border: none;
box-shadow: inset 0 0 0 var(--button-border-width) var(--primary-color);
font-family: 'IBM Plex Mono', monospace;
white-space: nowrap;
}
.button:hover {
+12 -7
View File
@@ -1,13 +1,16 @@
<script>
import './Button.css'
export let color = 'var(--primary-color)'
export let text = 'Button'
export let textColor = '#fff'
export let round = '8px'
export let padding = '8px 24px'
export let href = null
export let borderWidth = '0px'
let {
color = 'var(--primary-color)',
text = 'Button',
textColor = '#fff',
round = '5px',
padding = '10px 20px',
href = null,
borderWidth = '0px',
...restProps
} = $props()
</script>
{#if href}
@@ -15,6 +18,7 @@
class="button"
style={`--button-color: ${color}; --button-text-color: ${textColor}; --button-radius: ${round}; --button-padding: ${padding}; --button-border-width: ${borderWidth};`}
{href}
{...restProps}
>
{text}
</a>
@@ -22,6 +26,7 @@
<button
class="button"
style={`--button-color: ${color}; --button-text-color: ${textColor}; --button-radius: ${round}; --button-padding: ${padding}; --button-border-width: ${borderWidth};`}
{...restProps}
>
{text}
</button>
+140
View File
@@ -0,0 +1,140 @@
.cookie-backdrop {
position: fixed;
inset: 0;
z-index: 199;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
}
.cookie-popup {
display: flex;
justify-content: space-between;
align-items: center;
gap: 50px;
z-index: 200;
position: fixed;
left: 50%;
bottom: 25px;
transform: translateX(-50%);
width: min(100%, 1100px);
max-width: calc(100% - 25px * 2);
padding: 50px;
border-radius: 5px;
background-color: var(--surface);
color: var(--text-color);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.18);
border: 2px solid rgba(127, 127, 127, 0.3);
}
.cookie-popup-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.cookie-popup-content p {
margin: 10px 0 15px;
}
.cookie-popup-title {
display: flex;
align-items: center;
gap: 20px;
}
.cookie-popup-title h2 {
font-size: 1.25rem;
margin: 0;
}
.cookie-icon {
width: 30px;
height: 30px;
display: inline-block;
background-color: var(--text-color);
-webkit-mask-image: url('./assets/cookie.svg');
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-image: url('./assets/cookie.svg');
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
transition: background-color 0.2s ease;
}
.cookie-popup-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 50px;
flex-wrap: nowrap;
}
.cookie-link {
color: var(--primary-color);
text-decoration: none;
cursor: pointer;
}
.cookie-link-button {
background: transparent;
border: 0;
padding: 0;
font: inherit;
}
.cookie-link:hover {
text-decoration: underline;
}
.cookie-popup-buttons {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.cookie-popup-buttons .button {
white-space: nowrap;
flex-shrink: 0;
min-width: fit-content;
}
.cookie-selector p {
margin: 0 0 5px;
}
.cookie-toggle-container {
display: flex;
flex-wrap: wrap;
gap: 50px;
margin-bottom: 12px;
}
/* toggle item styling moved to Toggle.css */
@media (max-width: 640px) {
.cookie-popup {
bottom: 12px;
padding: 16px;
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.cookie-popup-actions {
justify-content: stretch;
gap: 12px;
flex-direction: column;
}
.cookie-popup-actions .button,
.cookie-link {
flex: 1 1 100%;
text-align: center;
}
.cookie-link-button {
padding: 10px 0;
}
}
@@ -0,0 +1,153 @@
<script>
import { onMount, tick } from 'svelte'
import './CookiePopUp.css'
import Button from "../Button/Button.svelte"
import Toggle from "../Toggle/Toggle.svelte"
import { applyConsent, getStoredConsent, saveConsent } from "../../lib/cookieConsent"
let isOpen = $state(false)
let view = $state('main') // 'main' | 'prefs'
let dialogEl = $state(null)
let tecnici = $state(true) // always enabled (required)
let analitici = $state(false)
let profilazione = $state(false)
const applyAndSaveConsent = (consent) => {
saveConsent(consent)
applyConsent(consent)
}
const close = () => {
isOpen = false
view = 'main'
}
const acceptAll = () => {
applyAndSaveConsent({
value: 'accepted_all',
preferences: { tecnici: true, analitici: true, profilazione: true }
})
close()
}
const rejectAll = () => {
applyAndSaveConsent({
value: 'rejected_all',
preferences: { tecnici: true, analitici: false, profilazione: false }
})
close()
}
const openPrefs = () => {
view = 'prefs'
tick().then(() => {
if (!dialogEl) return
const first = dialogEl.querySelector('input, button, [href], select, textarea, [tabindex]:not([tabindex="-1"])')
first?.focus?.()
})
}
const savePrefs = () => {
applyAndSaveConsent({
value: 'custom',
preferences: { tecnici, analitici, profilazione }
})
close()
}
const handleKeydown = (event) => {
if (!isOpen) return
if (event.key === 'Escape') {
event.preventDefault()
// Treat Escape as an explicit choice so the dialog doesn't reappear immediately.
rejectAll()
}
}
onMount(() => {
const existing = getStoredConsent()
if (existing?.preferences) {
tecnici = true
analitici = existing.preferences.analitici
profilazione = existing.preferences.profilazione
}
isOpen = !existing
if (isOpen) {
tick().then(() => {
if (!dialogEl) return
const first = dialogEl.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
first?.focus?.()
})
}
})
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div class="cookie-backdrop" aria-hidden="true"></div>
<div
class="cookie-popup"
role="dialog"
aria-modal="true"
aria-labelledby="cookie-dialog-title"
bind:this={dialogEl}
>
<div class="cookie-popup-container">
<div class="cookie-popup-content">
<div class="cookie-popup-title">
<div class="cookie-icon" aria-hidden="true"></div>
<h2 id="cookie-dialog-title">Usiamo i cookie</h2>
</div>
{#if view === 'main'}
<p>Questo sito usa cookie per personalizzazione e analisi. Clicca "Accetta tutti" per continuare o gestisci le tue preferenze.</p>
<a href="/cookie-policy" class="cookie-link">Leggi la cookie policy ⇾</a>
{:else}
<p>Questo sito usa cookie per personalizzazione e analisi. Clicca "Accetta tutti" per continuare o gestisci le tue preferenze.</p>
<div class="cookie-selector">
<p>Seleziona le categorie di cookie che desideri abilitare. </p>
<div class="cookie-toggle-container">
<label class="cookie-toggle-item">
<Toggle bind:checked={tecnici} label="Tecnici" disabled />
</label>
<label class="cookie-toggle-item">
<Toggle bind:checked={analitici} label="Analitici" />
</label>
<label class="cookie-toggle-item">
<Toggle bind:checked={profilazione} label="Profilazione" />
</label>
</div>
</div>
<a href="/cookie-policy" class="cookie-link">Leggi la cookie policy ⇾</a>
{/if}
</div>
</div>
<div class="cookie-popup-actions">
{#if view === 'main'}
<button type="button" class="cookie-link cookie-link-button" onclick={openPrefs}>
Personalizza
</button>
<div class="cookie-popup-buttons">
<Button text="Rifiuta" onclick={rejectAll} />
<Button text="Accetta tutti" onclick={acceptAll} />
</div>
{:else}
<button type="button" class="cookie-link cookie-link-button" onclick={() => (view = 'main')}>
Indietro
</button>
<div class="cookie-popup-buttons">
<Button text="Rifiuta" onclick={rejectAll} />
<Button text="Salva preferenze" onclick={savePrefs} />
</div>
{/if}
</div>
</div>
{/if}
@@ -0,0 +1,4 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.4828 12.2668L18.5162 12.2001C19.3828 12.1334 20.1995 11.6501 20.5828 10.8168C21.2162 9.53343 20.6495 7.96677 19.3162 7.4001C18.1995 6.93343 16.8828 7.3501 16.2328 8.38344C16.0162 8.7501 15.8828 9.16677 15.8662 9.56677L15.7995 10.5834C15.7328 11.5334 16.5328 12.3334 17.4828 12.2668ZM6.73285 21.6501L6.79951 22.6834C6.86618 23.5501 7.34951 24.3668 8.18285 24.7501C9.46618 25.3834 11.0328 24.8168 11.5995 23.4834C12.0662 22.3668 11.6495 21.0501 10.6162 20.4001C10.2495 20.1834 9.83285 20.0501 9.43285 20.0334L8.41618 19.9668C7.46618 19.9001 6.66618 20.7001 6.73285 21.6501ZM33.8828 37.9168L33.9662 39.2001C34.0495 40.3834 33.0662 41.3668 31.8828 41.2834L30.6162 41.2001C30.1162 41.1668 29.6162 41.0168 29.1495 40.7334C27.8662 39.9334 27.3495 38.3001 27.9328 36.9001C28.6328 35.2334 30.5828 34.5334 32.1662 35.3334C33.1995 35.8501 33.8162 36.8501 33.8828 37.9168ZM17.6162 22.8834L17.6662 23.6501C17.7662 25.3334 18.7328 26.9168 20.3662 27.7001C22.8828 28.9501 25.9662 27.8334 27.0495 25.1834C27.9662 22.9668 27.0828 20.3668 25.0162 19.1501C24.3162 18.7334 23.5662 18.5168 22.8162 18.4834L22.0662 18.4334C19.5328 18.2834 17.4495 20.3668 17.6162 22.8834ZM38.8662 14.0333L38.9162 13.3333C39.0495 11.05 37.1662 9.14998 34.8828 9.29998L34.1995 9.34998C33.5162 9.38332 32.8328 9.58332 32.1995 9.94999C30.3162 11.05 29.5328 13.4167 30.3495 15.4333C31.3328 17.8333 34.1328 18.8499 36.4162 17.7166C37.8828 16.9833 38.7662 15.5499 38.8662 14.0333ZM11.1995 36.9668L11.2328 36.3668C11.3162 35.0501 12.0662 33.8334 13.3328 33.2334C15.2995 32.2501 17.6828 33.1334 18.5328 35.1834C19.2328 36.9001 18.5662 38.9168 16.9495 39.8668C16.3995 40.1834 15.8162 40.3501 15.2328 40.3834L14.6495 40.4168C12.6995 40.5501 11.0662 38.9168 11.1995 36.9668ZM41.8162 27.5668L41.8828 26.5334C41.9495 25.5834 41.1495 24.7834 40.1995 24.8501L39.1828 24.9168C38.7828 24.9334 38.3662 25.0668 37.9995 25.2834C36.9662 25.9334 36.5495 27.2501 37.0162 28.3668C37.5828 29.7001 39.1495 30.2668 40.4328 29.6334C41.2662 29.2501 41.7662 28.4334 41.8162 27.5668Z" fill="#212121"/>
<path d="M25 0C31.7465 0 38.0168 1.93438 42.6191 6.11893C47.2481 10.3277 50 16.641 50 25C50 33.1378 47.2407 39.4403 42.6284 43.6977C38.0378 47.9348 31.7715 50 25 50C18.2258 50 11.96 47.9208 7.37067 43.678C2.76017 39.4155 0 33.1142 0 25C0 16.7515 2.75566 10.4437 7.37617 6.21067C11.9727 1.99968 18.241 0 25 0ZM46.6667 25C46.6667 17.409 44.1936 12.0557 40.3767 8.58523C36.5332 5.09062 31.1368 3.33333 25 3.33333C18.8757 3.33333 13.4773 5.14198 9.628 8.6685C5.80267 12.1729 3.33334 17.5318 3.33334 25C3.33334 32.3192 5.79817 37.6845 9.6335 41.2303C13.49 44.7958 18.8908 46.6667 25 46.6667C31.1118 46.6667 36.5122 44.8068 40.3675 41.2482C44.201 37.7097 46.6667 32.3455 46.6667 25Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+98
View File
@@ -0,0 +1,98 @@
/* Toggle CSS variables (can be set on the element or a parent):
--toggle-default-checked: true | false
--toggle-default-disabled: true | false
--toggle-on-bg: color
--toggle-off-bg: color
--toggle-knob: color
--primary-color: fallback color for --toggle-on-bg
Size overrides: --toggle-width, --toggle-height
*/
.toggle {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
gap: 12px;
}
.track {
--toggle-width: 44px;
--toggle-height: 24px;
width: var(--toggle-width);
height: var(--toggle-height);
background: var(--toggle-off-bg, var(--background-opaque));
border-radius: 999px;
position: relative;
transition: background 0.18s ease;
}
.knob {
width: calc(var(--toggle-height) - 6px);
height: calc(var(--toggle-height) - 6px);
background: var(--toggle-knob, #fff);
border-radius: 50%;
position: absolute;
top: 3px;
left: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: transform 0.18s ease, background 0.18s ease;
}
.toggle.on .track {
background: var(--toggle-on-bg, var(--primary-color, #0b74ff));
}
.toggle.on .knob {
transform: translateX(calc(var(--toggle-width) - var(--toggle-height)));
}
.toggle.sm .track {
--toggle-width: 34px;
--toggle-height: 18px
}
.toggle.lg .track {
--toggle-width: 56px;
--toggle-height: 32px
}
.toggle:disabled {
cursor: not-allowed;
opacity: 0.6
}
.toggle-label-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.toggle-label {
display: block;
font-weight: 500;
color: var(--text-color);
font-size: 0.95rem;
text-align: left;
}
.toggle-description {
display: block;
font-size: 0.85rem;
color: var(--muted-color, rgba(0, 0, 0, 0.6));
line-height: 1.4;
}
.cookie-toggle-item {
cursor: pointer;
display: flex;
padding: 8px 0;
margin: 0;
}
.cookie-toggle-item .toggle {
flex: 1;
width: 100%;
}
+74
View File
@@ -0,0 +1,74 @@
<script>
import "./Toggle.css";
import { createEventDispatcher, onMount } from "svelte";
export let checked = false;
export let disabled = false;
export let size = "md"; // 'sm' | 'md' | 'lg'
export let ariaLabel = "Toggle";
export let label = ""; // text to display next to toggle
export let description = ""; // optional description text below
const dispatch = createEventDispatcher();
let btn;
onMount(() => {
const style = getComputedStyle(btn);
if (!("checked" in $$props)) {
const v = style.getPropertyValue("--toggle-default-checked").trim();
if (v === "true" || v === "1") checked = true;
else if (v === "false" || v === "0") checked = false;
}
if (!("disabled" in $$props)) {
const d = style
.getPropertyValue("--toggle-default-disabled")
.trim();
if (d === "true" || d === "1") disabled = true;
else if (d === "false" || d === "0") disabled = false;
}
});
function toggle() {
if (disabled) return;
checked = !checked;
dispatch("change", { checked });
dispatch("input", { checked });
}
</script>
<button
bind:this={btn}
type="button"
class="toggle {size} {checked ? 'on' : 'off'}"
role="switch"
aria-checked={checked}
aria-label={label || ariaLabel}
on:click={toggle}
{disabled}
>
<span class="track">
<span class="knob"></span>
</span>
{#if label}
<span class="toggle-label-wrapper">
<span class="toggle-label">{label}</span>
{#if description}
<span class="toggle-description">{description}</span>
{/if}
</span>
{/if}
</button>
<!--
Usage:
<Toggle bind:checked on:change={(e) => console.log(e.detail.checked)} />
CSS variables (scope to the element or a parent):
--toggle-default-checked: true | false
--toggle-default-disabled: true | false
--toggle-on-bg: color
--toggle-off-bg: color
--toggle-knob: color
--primary-color: used as fallback for on color
--toggle-width / --toggle-height can be overridden per size
-->
+103
View File
@@ -0,0 +1,103 @@
const STORAGE_KEY = 'cookie_consent'
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365
const toBoolean = (value) => value === true
const withRequiredDefaults = (preferences = {}) => ({
tecnici: true,
analitici: toBoolean(preferences.analitici),
profilazione: toBoolean(preferences.profilazione)
})
const normalizeConsent = (value) => {
if (!value) return null
if (value === 'accepted_all') {
return {
value: 'accepted_all',
preferences: { tecnici: true, analitici: true, profilazione: true }
}
}
if (value === 'rejected_all') {
return {
value: 'rejected_all',
preferences: { tecnici: true, analitici: false, profilazione: false }
}
}
try {
const parsed = JSON.parse(value)
if (parsed?.value !== 'custom') return null
return {
value: 'custom',
preferences: withRequiredDefaults(parsed.preferences)
}
} catch {
return null
}
}
const serializeConsent = (consent) => {
if (consent.value === 'accepted_all' || consent.value === 'rejected_all') {
return consent.value
}
return JSON.stringify({
value: 'custom',
preferences: withRequiredDefaults(consent.preferences)
})
}
const setCategoryCookie = (name, granted) => {
document.cookie = `${name}=${granted ? 'granted' : 'denied'}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax`
}
export const getStoredConsent = () => {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
return normalizeConsent(raw)
} catch {
return null
}
}
export const applyConsent = (consent) => {
if (typeof window === 'undefined' || !consent) return
const preferences = withRequiredDefaults(consent.preferences)
const root = document.documentElement
root.dataset.cookieConsent = consent.value
root.dataset.cookieTecnici = 'granted'
root.dataset.cookieAnalitici = preferences.analitici ? 'granted' : 'denied'
root.dataset.cookieProfilazione = preferences.profilazione ? 'granted' : 'denied'
setCategoryCookie('cm_cookie_tecnici', true)
setCategoryCookie('cm_cookie_analitici', preferences.analitici)
setCategoryCookie('cm_cookie_profilazione', preferences.profilazione)
window.dispatchEvent(
new CustomEvent('cookie-consent-updated', {
detail: { value: consent.value, preferences }
})
)
}
export const saveConsent = (consent) => {
if (typeof window === 'undefined' || !consent) return
try {
localStorage.setItem(STORAGE_KEY, serializeConsent(consent))
} catch {
// no-op if storage is unavailable
}
}
export const initializeStoredConsent = () => {
const consent = getStoredConsent()
if (consent) applyConsent(consent)
return consent
}
+20 -2
View File
@@ -5,6 +5,7 @@
--muted-color: #6b7280;
--surface: #ffffff;
--background: rgba(246, 249, 255, 0.5);
--background-opaque: rgba(0, 0, 0, 0.12);
}
/* Dark theme overrides (toggle by setting attribute on <html>) */
@@ -14,6 +15,7 @@
--muted-color: #9ca3af;
--surface: #000;
--background: rgba(0, 0, 0, 0.8);
--background-opaque: rgba(255, 255, 255, 0.12);
}
* {
@@ -90,6 +92,22 @@ h2 {
}
p, a {
font-family: Helvetica, 'Segoe UI', sans-serif;
}
}
/* SVG Mask utility: responsive to theme */
.svg-mask {
background-color: var(--text-color);
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
transition: background-color 0.2s ease;
}
/* Usage: add data attribute with SVG path
Example: <div class="svg-mask" style="--mask-url: url('/path/to/icon.svg'); width: 40px; height: 40px;"></div>
Then use inline style: -webkit-mask-image: var(--mask-url); mask-image: var(--mask-url);
OR use a specific class that sets the mask-image */