Merge branch 'dev' into feat/material-page

# Conflicts:
#	frontend/src/app/core/layout/navbar.component.html
This commit is contained in:
2026-03-24 13:18:08 +01:00
182 changed files with 23182 additions and 1199 deletions

View File

@@ -61,13 +61,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "600kB",
"maximumError": "1.2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "10kB",
"maximumError": "14kB"
}
]
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,23 @@
{
"name": "3D fab",
"short_name": "3D fab",
"description": "Stampa 3D su misura con preventivo online immediato.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/assets/images/Fav-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/images/Fav-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View File

@@ -2,81 +2,81 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/en</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/de</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
@@ -84,40 +84,40 @@
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -125,40 +125,40 @@
<url>
<loc>https://3d-fab.ch/it/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -166,40 +166,40 @@
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -207,40 +207,40 @@
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -248,40 +248,40 @@
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
@@ -289,40 +289,40 @@
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>

View File

@@ -1 +1,14 @@
<router-outlet></router-outlet>
@if (siteIntroState() !== "hidden") {
<div
class="site-intro"
[class.site-intro--closing]="siteIntroState() === 'closing'"
aria-hidden="true"
>
<app-brand-animation-logo
class="site-intro__logo"
variant="site-intro"
></app-brand-animation-logo>
</div>
}

View File

@@ -0,0 +1,40 @@
.site-intro {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-items: center;
background: var(--color-bg);
pointer-events: none;
opacity: 1;
transition: opacity 0.24s ease-out;
}
.site-intro--closing {
opacity: 0;
}
.site-intro__logo {
width: min(calc(100vw - 2rem), 23rem);
--brand-animation-width: 23rem;
--brand-animation-height: 7.1rem;
--brand-animation-letter-width: 3.75rem;
--brand-animation-scale: 0.88;
--brand-animation-width-mobile: 16.8rem;
--brand-animation-height-mobile: 5.3rem;
--brand-animation-letter-width-mobile: 2.8rem;
--brand-animation-scale-mobile: 0.68;
--brand-animation-site-intro-duration: 1.05s;
justify-self: center;
align-self: center;
opacity: 1;
transform: scale(1);
transition:
opacity 0.24s ease-out,
transform 0.24s ease-out;
}
.site-intro--closing .site-intro__logo {
opacity: 0;
transform: scale(0.985);
}

View File

@@ -1,14 +1,50 @@
import { Component, inject } from '@angular/core';
import {
afterNextRender,
Component,
DestroyRef,
Inject,
Optional,
PLATFORM_ID,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { SeoService } from './core/services/seo.service';
import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
imports: [RouterOutlet, BrandAnimationLogoComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent {
private readonly seoService = inject(SeoService);
private readonly destroyRef = inject(DestroyRef);
readonly siteIntroState = signal<'hidden' | 'active' | 'closing'>('hidden');
constructor(@Optional() @Inject(PLATFORM_ID) platformId?: Object) {
if (!isPlatformBrowser(platformId ?? 'browser')) {
return;
}
afterNextRender(() => {
this.siteIntroState.set('active');
const closeTimeoutId = window.setTimeout(() => {
this.siteIntroState.set('closing');
}, 1020);
const hideTimeoutId = window.setTimeout(() => {
this.siteIntroState.set('hidden');
}, 1280);
this.destroyRef.onDestroy(() => {
window.clearTimeout(closeTimeoutId);
window.clearTimeout(hideTimeoutId);
});
});
}
}

View File

@@ -28,21 +28,12 @@ import {
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
import { catchError, firstValueFrom, of } from 'rxjs';
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr'];
function resolveLangFromUrl(url: string): SupportedLang {
const firstSegment = (url || '/')
.split('?')[0]
.split('#')[0]
.split('/')
.filter(Boolean)[0]
?.toLowerCase();
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
? (firstSegment as SupportedLang)
: 'it';
}
import {
getNavigatorLanguagePreferences,
parseAcceptLanguage,
resolveInitialLanguage,
SUPPORTED_LANGS,
} from './core/i18n/language-resolution';
export const appConfig: ApplicationConfig = {
providers: [
@@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = {
withComponentInputBinding(),
withViewTransitions(),
withInMemoryScrolling({
scrollPositionRestoration: 'top',
scrollPositionRestoration: 'enabled',
}),
),
provideHttpClient(
@@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = {
),
importProvidersFrom(
TranslateModule.forRoot({
defaultLanguage: 'it',
fallbackLang: 'it',
loader: {
provide: TranslateLoader,
useClass: StaticTranslateLoader,
@@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = {
const router = inject(Router);
const request = inject(REQUEST, { optional: true }) as {
url?: string;
headers?: Record<string, string | string[] | undefined>;
} | null;
translate.addLangs([...SUPPORTED_LANGS]);
translate.setDefaultLang('it');
translate.setFallbackLang('it');
const requestedUrl =
(typeof request?.url === 'string' && request.url) || router.url || '/';
const lang = resolveLangFromUrl(requestedUrl);
const lang = resolveInitialLanguage({
url: requestedUrl,
preferredLanguages: request
? parseAcceptLanguage(readRequestHeader(request, 'accept-language'))
: getNavigatorLanguagePreferences(
typeof navigator === 'undefined' ? null : navigator,
),
});
return firstValueFrom(
translate.use(lang).pipe(
@@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = {
provideClientHydration(withEventReplay()),
],
};
function readRequestHeader(
request: {
headers?: Record<string, string | string[] | undefined>;
} | null,
headerName: string,
): string | null {
if (!request?.headers) {
return null;
}
const headerValue = request.headers[headerName.toLowerCase()];
if (Array.isArray(headerValue)) {
return headerValue[0] ?? null;
}
return typeof headerValue === 'string' ? headerValue : null;
}

View File

@@ -15,9 +15,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent),
data: {
seoTitle: '3D fab | Stampa 3D su misura',
seoDescription:
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
},
},
{
@@ -27,9 +26,8 @@ const appChildRoutes: Routes = [
(m) => m.CALCULATOR_ROUTES,
),
data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
seoDescription:
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
},
},
{
@@ -37,9 +35,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
},
},
{
@@ -47,19 +44,28 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
},
/* {
path: 'materials',
loadComponent: () =>
import('./features/materials/materials-page.component').then(
(m) => m.MaterialsPageComponent,
),
data: {
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
},
},*/
{
path: 'contact',
loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
},
},
{
@@ -69,7 +75,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -80,7 +87,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -89,7 +97,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -98,7 +107,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -112,7 +122,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: {
seoTitle: 'Admin | 3D fab',
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -123,6 +134,31 @@ const appChildRoutes: Routes = [
];
export const routes: Routes = [
{
path: ':lang/calculator/animation-test',
canMatch: [langPrefixCanMatch],
loadComponent: () =>
import('./features/calculator/calculator-animation-test.component').then(
(m) => m.CalculatorAnimationTestComponent,
),
data: {
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
{
path: 'calculator/animation-test',
loadComponent: () =>
import('./features/calculator/calculator-animation-test.component').then(
(m) => m.CalculatorAnimationTestComponent,
),
data: {
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
{
path: ':lang',
canMatch: [langPrefixCanMatch],

View File

@@ -11,6 +11,8 @@ export interface ColorCategory {
colors: ColorOption[];
}
const DEFAULT_BRAND_COLOR = '#facf0a';
export const PRODUCT_COLORS: ColorCategory[] = [
{
name: 'COLOR.CATEGORY_GLOSSY',
@@ -38,10 +40,81 @@ export const PRODUCT_COLORS: ColorCategory[] = [
},
];
export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find((c) => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
export function normalizeColorValue(value: string | null | undefined): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
}
export function findColorHex(value: string | null | undefined): string | null {
const normalized = normalizeColorValue(value);
if (!normalized) {
return null;
}
for (const category of PRODUCT_COLORS) {
const match = category.colors.find(
(color) => normalizeColorValue(color.value) === normalized,
);
if (match) {
return match.hex;
}
}
return null;
}
export interface LocalizedColorLabelSet {
fallback?: string | null;
it?: string | null;
en?: string | null;
de?: string | null;
fr?: string | null;
}
export function resolveLocalizedColorLabel(
language: string | null | undefined,
labels: LocalizedColorLabelSet,
): string | null {
const normalizedLanguage = String(language ?? '')
.trim()
.toLowerCase()
.split('-')[0];
const preferred =
normalizedLanguage === 'it'
? labels.it
: normalizedLanguage === 'en'
? labels.en
: normalizedLanguage === 'de'
? labels.de
: normalizedLanguage === 'fr'
? labels.fr
: null;
return (
firstNonBlank(preferred, labels.fallback) ??
firstNonBlank(labels.it, labels.en, labels.de, labels.fr)
);
}
function firstNonBlank(
...values: Array<string | null | undefined>
): string | null {
for (const value of values) {
const normalized = String(value ?? '').trim();
if (normalized) {
return normalized;
}
}
return null;
}
export function getColorHex(value: string): string {
return findColorHex(value) ?? DEFAULT_BRAND_COLOR;
}

View File

@@ -0,0 +1,135 @@
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export const SUPPORTED_LANGS: readonly SupportedLang[] = [
'it',
'en',
'de',
'fr',
];
type InitialLanguageOptions = {
url?: string | null;
preferredLanguages?: readonly string[] | null;
fallbackLang?: SupportedLang;
};
type NavigatorLike = {
language?: string;
languages?: readonly string[];
};
export function resolveInitialLanguage({
url,
preferredLanguages,
fallbackLang = 'it',
}: InitialLanguageOptions): SupportedLang {
const explicitLang = resolveExplicitLanguageFromUrl(url);
if (explicitLang) {
return explicitLang;
}
for (const candidate of preferredLanguages ?? []) {
const normalized = normalizeSupportedLanguage(candidate);
if (normalized) {
return normalized;
}
}
return fallbackLang;
}
export function parseAcceptLanguage(
header: string | null | undefined,
): string[] {
if (!header) {
return [];
}
return header
.split(',')
.map((entry, index) => {
const [rawTag, ...params] = entry.split(';').map((part) => part.trim());
if (!rawTag) {
return null;
}
const qualityParam = params.find((param) => param.startsWith('q='));
const quality = qualityParam
? Number.parseFloat(qualityParam.slice(2))
: 1;
return {
tag: rawTag,
quality: Number.isFinite(quality) ? quality : 0,
index,
};
})
.filter(
(
entry,
): entry is {
tag: string;
quality: number;
index: number;
} => entry !== null && entry.quality > 0,
)
.sort(
(left, right) => right.quality - left.quality || left.index - right.index,
)
.map((entry) => entry.tag);
}
export function getNavigatorLanguagePreferences(
navigatorLike: NavigatorLike | null | undefined,
): string[] {
if (!navigatorLike) {
return [];
}
const orderedLanguages = [
...(Array.isArray(navigatorLike.languages) ? navigatorLike.languages : []),
];
if (
typeof navigatorLike.language === 'string' &&
navigatorLike.language &&
!orderedLanguages.includes(navigatorLike.language)
) {
orderedLanguages.push(navigatorLike.language);
}
return orderedLanguages;
}
function resolveExplicitLanguageFromUrl(
url: string | null | undefined,
): SupportedLang | null {
const normalizedUrl = String(url ?? '/');
const [pathAndQuery] = normalizedUrl.split('#', 1);
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
const firstSegment = rawPath.split('/').filter(Boolean)[0];
const pathLanguage = normalizeSupportedLanguage(firstSegment);
if (pathLanguage) {
return pathLanguage;
}
const queryLanguage = new URLSearchParams(rawQuery ?? '').get('lang');
return normalizeSupportedLanguage(queryLanguage);
}
function normalizeSupportedLanguage(
rawLanguage: string | null | undefined,
): SupportedLang | null {
if (typeof rawLanguage !== 'string') {
return null;
}
const normalized = rawLanguage.trim().toLowerCase();
if (!normalized || normalized === '*') {
return null;
}
const [baseLanguage] = normalized.split('-', 1);
return SUPPORTED_LANGS.includes(baseLanguage as SupportedLang)
? (baseLanguage as SupportedLang)
: null;
}

View File

@@ -1,22 +1,93 @@
import { Injectable } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import {
Injectable,
PLATFORM_ID,
TransferState,
inject,
makeStateKey,
} from '@angular/core';
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import de from '../../../assets/i18n/de.json';
import en from '../../../assets/i18n/en.json';
import fr from '../../../assets/i18n/fr.json';
import it from '../../../assets/i18n/it.json';
import { from, Observable } from 'rxjs';
const TRANSLATIONS: Record<string, TranslationObject> = {
it: it as TranslationObject,
en: en as TranslationObject,
de: de as TranslationObject,
fr: fr as TranslationObject,
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
const FALLBACK_LANG: SupportedLang = 'it';
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
const translationLoaders: Record<
SupportedLang,
() => Promise<TranslationObject>
> = {
it: () =>
import('../../../assets/i18n/it.json').then(
(module) => module.default as TranslationObject,
),
en: () =>
import('../../../assets/i18n/en.json').then(
(module) => module.default as TranslationObject,
),
de: () =>
import('../../../assets/i18n/de.json').then(
(module) => module.default as TranslationObject,
),
fr: () =>
import('../../../assets/i18n/fr.json').then(
(module) => module.default as TranslationObject,
),
};
@Injectable()
export class StaticTranslateLoader implements TranslateLoader {
private readonly platformId = inject(PLATFORM_ID);
private readonly transferState = inject(TransferState);
getTranslation(lang: string): Observable<TranslationObject> {
const normalized = String(lang || 'it').toLowerCase();
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']);
const normalized = this.normalizeLanguage(lang);
return from(this.loadTranslation(normalized));
}
private normalizeLanguage(lang: string): SupportedLang {
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
return normalized in translationLoaders
? (normalized as SupportedLang)
: FALLBACK_LANG;
}
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
const transferStateKey = makeStateKey<TranslationObject>(
`i18n:${lang.toLowerCase()}`,
);
if (
isPlatformBrowser(this.platformId) &&
this.transferState.hasKey(transferStateKey)
) {
const transferred = this.transferState.get(transferStateKey, {});
this.transferState.remove(transferStateKey);
return Promise.resolve(transferred);
}
const cached = translationCache.get(lang);
if (cached) {
return cached;
}
const pending = translationLoaders[lang]()
.then((translation) => {
if (
isPlatformServer(this.platformId) &&
!this.transferState.hasKey(transferStateKey)
) {
this.transferState.set(transferStateKey, translation);
}
return translation;
})
.catch(() =>
lang === FALLBACK_LANG
? Promise.resolve({})
: this.loadTranslation(FALLBACK_LANG),
);
translationCache.set(lang, pending);
return pending;
}
}

View File

@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { REQUEST } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { serverOriginInterceptor } from './server-origin.interceptor';
describe('serverOriginInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([serverOriginInterceptor])),
provideHttpClientTesting(),
{
provide: REQUEST,
useValue: {
protocol: 'https',
headers: {
host: 'dev.3d-fab.ch',
authorization: 'Basic dGVzdDp0ZXN0',
cookie: 'session=abc123',
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
},
},
},
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => {
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
const request = httpMock.expectOne(
'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de',
);
expect(request.request.headers.get('authorization')).toBe(
'Basic dGVzdDp0ZXN0',
);
expect(request.request.headers.get('cookie')).toBe('session=abc123');
expect(request.request.headers.get('accept-language')).toBe(
'de-CH,de;q=0.9,en;q=0.8',
);
request.flush({});
});
it('does not overwrite explicit request headers', () => {
http
.get('/api/shop/products', {
headers: {
authorization: 'Bearer explicit-token',
},
})
.subscribe();
const request = httpMock.expectOne(
'https://dev.3d-fab.ch/api/shop/products',
);
expect(request.request.headers.get('authorization')).toBe(
'Bearer explicit-token',
);
expect(request.request.headers.get('cookie')).toBe('session=abc123');
request.flush({});
});
});

View File

@@ -1,63 +1,69 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core';
import {
RequestLike,
resolveRequestOrigin,
} from '../../../core/request-origin';
type RequestLike = {
protocol?: string;
get?: (name: string) => string | undefined;
headers?: Record<string, string | string[] | undefined>;
};
const FORWARDED_REQUEST_HEADERS = [
'authorization',
'cookie',
'accept-language',
] as const;
function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
}
function firstHeaderValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return typeof value === 'string' ? value : null;
}
function resolveOrigin(request: RequestLike | null): string | null {
if (!request) {
return null;
}
const host =
request.get?.('host') ??
firstHeaderValue(request.headers?.['host']) ??
firstHeaderValue(request.headers?.['x-forwarded-host']);
if (!host) {
return null;
}
const forwardedProtoRaw = firstHeaderValue(
request.headers?.['x-forwarded-proto'],
);
const forwardedProto = forwardedProtoRaw
?.split(',')
.map((part) => part.trim().toLowerCase())
.find(Boolean);
const protocol = forwardedProto || request.protocol || 'http';
return `${protocol}://${host}`;
}
function normalizeRelativePath(url: string): string {
const withoutDot = url.replace(/^\.\//, '');
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
}
function readRequestHeader(
request: RequestLike | null,
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
): string | null {
const normalizedName = name.toLowerCase();
const headerValue =
request?.headers?.[normalizedName] ?? request?.get?.(normalizedName);
if (Array.isArray(headerValue)) {
return headerValue[0] ?? null;
}
return typeof headerValue === 'string' ? headerValue : null;
}
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
if (isAbsoluteUrl(req.url)) {
return next(req);
}
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
const origin = resolveOrigin(request);
const origin = resolveRequestOrigin(request);
if (!origin) {
return next(req);
}
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
return next(req.clone({ url: absoluteUrl }));
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
Record<string, string>
>((headers, name) => {
if (req.headers.has(name)) {
return headers;
}
const value = readRequestHeader(request, name);
if (value) {
headers[name] = value;
}
return headers;
}, {});
return next(
req.clone({
url: absoluteUrl,
setHeaders: forwardedHeaders,
}),
);
};

View File

@@ -1,14 +1,24 @@
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<img
class="brand"
src="/assets/images/brand-logo-white.svg"
alt="3D Fab"
/>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
<a [routerLink]="languageService.localizedPath('/privacy')">{{
"FOOTER.PRIVACY" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/terms')">{{
"FOOTER.TERMS" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/contact')">{{
"FOOTER.CONTACT" | translate
}}</a>
</div>
<div class="col social">

View File

@@ -38,9 +38,10 @@
}
.brand {
font-weight: 700;
color: white;
display: block;
width: auto;
height: 1.85rem;
max-width: min(9.25rem, 46vw);
margin-bottom: var(--space-2);
}
.copyright {

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-footer',
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent {}
export class FooterComponent {
readonly languageService = inject(LanguageService);
}

View File

@@ -1,6 +1,15 @@
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<a [routerLink]="langService.localizedPath('/')" class="brand">
<img
class="brand-logo"
ngSrc="/assets/images/Asset%202.svg"
alt="3D Fab"
width="380"
height="86"
priority
/>
</a>
<div
class="mobile-toggle"
@@ -14,33 +23,33 @@
<nav class="nav-links" [class.open]="isMenuOpen">
<a
routerLink="/"
[routerLink]="langService.localizedPath('/')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
[routerLink]="langService.localizedPath('/calculator/basic')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/materials"
[routerLink]="langService.localizedPath('/shop')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.MATERIALS" | translate }}</a
>{{ "NAV.SHOP" | translate }}</a
>
<a
routerLink="/contact"
[routerLink]="langService.localizedPath('/about')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.ABOUT" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/contact')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
@@ -88,7 +97,10 @@
}
</select>
<div class="icon-placeholder" routerLink="/admin">
<div
class="icon-placeholder"
[routerLink]="langService.localizedPath('/admin')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@@ -136,7 +148,9 @@
<div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant }}</span>
<span class="cart-line-meta">{{
variant | translate
}}</span>
}
@if (cartItemColor(item); as color) {
<span class="cart-line-color">
@@ -144,7 +158,7 @@
class="color-dot"
[style.background-color]="cartItemColorHex(item)"
></span>
<span>{{ color }}</span>
<span>{{ color | translate }}</span>
</span>
}
</div>

View File

@@ -14,13 +14,16 @@
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
display: inline-flex;
align-items: center;
text-decoration: none;
}
.highlight {
color: var(--color-brand);
.brand-logo {
display: block;
width: auto;
height: 2.1rem;
max-width: min(11rem, 40vw);
}
.nav-links {

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
NavigationStart,
@@ -15,11 +22,21 @@ import {
ShopService,
} from '../../features/shop/services/shop.service';
import { finalize } from 'rxjs';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../constants/colors.const';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
imports: [
CommonModule,
RouterLink,
RouterLinkActive,
TranslateModule,
NgOptimizedImage,
],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'],
})
@@ -54,16 +71,9 @@ export class NavbarComponent {
];
constructor(public langService: LanguageService) {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
afterNextRender(() => {
this.scheduleCartWarmup();
});
this.router.events
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -92,6 +102,9 @@ export class NavbarComponent {
toggleCart(): void {
this.closeMenu();
this.isCartOpen.update((open) => !open);
if (this.isCartOpen()) {
this.loadCartIfNeeded();
}
}
closeCart(): void {
@@ -129,7 +142,7 @@ export class NavbarComponent {
}
this.closeCart();
this.router.navigate(['/checkout'], {
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
queryParams: {
session: sessionId,
},
@@ -143,15 +156,30 @@ export class NavbarComponent {
}
cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null;
return item.shopVariantLabel || this.cartItemColor(item);
}
cartItemColor(item: ShopCartItem): string | null {
return item.shopVariantColorName || item.colorCode || null;
return (
resolveLocalizedColorLabel(this.langService.selectedLang(), {
fallback: item.shopVariantColorName ?? item.colorCode,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
}) ??
item.shopVariantColorName ??
item.colorCode
);
}
cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6';
return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
}
trackByCartItem(_index: number, item: ShopCartItem): string {
@@ -173,5 +201,44 @@ export class NavbarComponent {
.subscribe();
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
protected readonly routes = routes;
}

View File

@@ -1,7 +1,13 @@
import { Subject } from 'rxjs';
import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router';
import {
DefaultUrlSerializer,
NavigationEnd,
Router,
UrlTree,
} from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
import { RequestLike } from '../../../core/request-origin';
describe('LanguageService', () => {
function createTranslateMock() {
@@ -9,7 +15,7 @@ describe('LanguageService', () => {
const translate = {
currentLang: '',
addLangs: jasmine.createSpy('addLangs'),
setDefaultLang: jasmine.createSpy('setDefaultLang'),
setFallbackLang: jasmine.createSpy('setFallbackLang'),
use: jasmine.createSpy('use').and.callFake((lang: string) => {
translate.currentLang = lang;
onLangChange.next({ lang });
@@ -60,7 +66,14 @@ describe('LanguageService', () => {
parseUrl: (url: string) => serializer.parse(url),
createUrlTree,
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
navigateByUrl: jasmine
.createSpy('navigateByUrl')
.and.callFake((tree: UrlTree) => {
const nextUrl = serializer.serialize(tree);
router.url = nextUrl;
events$.next(new NavigationEnd(1, nextUrl, nextUrl));
return Promise.resolve(true);
}),
};
return router as unknown as Router;
@@ -70,11 +83,17 @@ describe('LanguageService', () => {
const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const request: RequestLike = {
headers: {
'accept-language': 'it-CH,it;q=0.9,en;q=0.8',
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new LanguageService(translate, router);
const service = new LanguageService(translate, router, request);
expect(translate.use).toHaveBeenCalledWith('it');
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent();
@@ -84,6 +103,48 @@ describe('LanguageService', () => {
expect(navOptions.replaceUrl).toBeTrue();
});
it('uses the preferred browser language on the root URL', () => {
const translate = createTranslateMock();
const router = createRouterMock('/');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const request: RequestLike = {
headers: {
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new LanguageService(translate, router, request);
expect(translate.use).toHaveBeenCalledWith('de');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent();
const tree = firstCall.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe('/de');
});
it('uses the default language for non-root URLs without a language prefix', () => {
const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const request: RequestLike = {
headers: {
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new LanguageService(translate, router, request);
expect(translate.use).toHaveBeenCalledWith('de');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent();
const tree = firstCall.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe('/it/calculator?session=abc');
});
it('switches language while preserving path and query params', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
@@ -103,4 +164,34 @@ describe('LanguageService', () => {
'/de/calculator?session=abc&mode=advanced',
);
});
it('builds localized paths for internal links while preserving query and hash', () => {
const translate = createTranslateMock();
const router = createRouterMock('/de/shop');
const service = new LanguageService(translate, router);
expect(service.localizedPath('/privacy')).toBe('/de/privacy');
expect(service.localizedPath('/it/contact?topic=seo#form')).toBe(
'/de/contact?topic=seo#form',
);
});
it('switches product pages using the resolved localized route overrides', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/shop/p/12345678-supporto-cavo');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const service = new LanguageService(translate, router);
service.setLocalizedRouteOverrides({
it: '/it/shop/p/12345678-supporto-cavo',
de: '/de/shop/p/12345678-kabelhalter',
});
navigateSpy.calls.reset();
service.switchLang('de');
const call = navigateSpy.calls.mostRecent();
const tree = call.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe('/de/shop/p/12345678-kabelhalter');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core';
import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import {
NavigationEnd,
@@ -6,25 +6,32 @@ import {
Router,
UrlTree,
} from '@angular/router';
import {
getNavigatorLanguagePreferences,
parseAcceptLanguage,
resolveInitialLanguage,
} from '../i18n/language-resolution';
import { RequestLike } from '../../../core/request-origin';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
@Injectable({
providedIn: 'root',
})
export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
'it',
'en',
'de',
'fr',
];
currentLang = signal<SupportedLang>('it');
private readonly defaultLang: SupportedLang = 'it';
private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
constructor(
private translate: TranslateService,
private router: Router,
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
) {
this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it');
this.translate.setFallbackLang('it');
this.translate.onLangChange.subscribe((event) => {
const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
@@ -34,13 +41,14 @@ export class LanguageService {
});
const initialTree = this.router.parseUrl(this.router.url);
const initialSegments = this.getPrimarySegments(initialTree);
const queryLang = this.getQueryLang(initialTree);
const initialLang = this.isSupportedLang(initialSegments[0])
? initialSegments[0]
: this.isSupportedLang(queryLang)
? queryLang
: 'it';
const initialLang = resolveInitialLanguage({
url: this.router.url,
preferredLanguages: this.request
? parseAcceptLanguage(this.readRequestHeader('accept-language'))
: getNavigatorLanguagePreferences(
typeof navigator === 'undefined' ? null : navigator,
),
});
this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree);
@@ -53,13 +61,21 @@ export class LanguageService {
});
}
switchLang(lang: 'it' | 'en' | 'de' | 'fr') {
switchLang(lang: SupportedLang) {
if (!this.isSupportedLang(lang)) {
return;
}
this.applyLanguage(lang);
const currentTree = this.router.parseUrl(this.router.url);
const localizedRoute = this.resolveLocalizedRouteOverride(
currentTree,
lang,
);
if (localizedRoute) {
this.navigateToLocalizedRoute(currentTree, localizedRoute);
return;
}
const segments = this.getPrimarySegments(currentTree);
let targetSegments: string[];
@@ -77,7 +93,7 @@ export class LanguageService {
this.navigateIfChanged(currentTree, targetSegments);
}
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
selectedLang(): SupportedLang {
const activeLang =
typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase()
@@ -85,6 +101,41 @@ export class LanguageService {
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
}
localizedPath(path: string): string {
const lang = this.selectedLang();
const rawValue = String(path ?? '').trim();
const normalized = rawValue || '/';
const match = normalized.match(/^([^?#]*)([?#].*)?$/);
const rawPath = match?.[1] || '/';
const suffix = match?.[2] || '';
const segments = rawPath.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}${suffix}`;
}
if (this.isSupportedLang(segments[0])) {
segments[0] = lang;
return `/${segments.join('/')}${suffix}`;
}
if (this.looksLikeLangToken(segments[0])) {
return `/${[lang, ...segments.slice(1)].join('/')}${suffix}`;
}
return `/${[lang, ...segments].join('/')}${suffix}`;
}
setLocalizedRouteOverrides(
paths: LocalizedRouteOverrides | null | undefined,
): void {
this.localizedRouteOverrides = this.normalizeLocalizedRouteOverrides(paths);
}
clearLocalizedRouteOverrides(): void {
this.localizedRouteOverrides = null;
}
private ensureLanguageInPath(urlTree: UrlTree): void {
const segments = this.getPrimarySegments(urlTree);
@@ -93,23 +144,26 @@ export class LanguageService {
return;
}
const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (activeLang !== this.currentLang()) {
this.applyLanguage(activeLang);
}
let targetSegments: string[];
if (segments.length === 0) {
targetSegments = [activeLang];
} else if (this.looksLikeLangToken(segments[0])) {
targetSegments = [activeLang, ...segments.slice(1)];
} else {
targetSegments = [activeLang, ...segments];
const queryLang = this.getQueryLang(urlTree);
const rootLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (rootLang !== this.currentLang()) {
this.applyLanguage(rootLang);
}
this.navigateIfChanged(urlTree, [rootLang]);
return;
}
if (this.currentLang() !== this.defaultLang) {
this.applyLanguage(this.defaultLang);
}
const targetSegments = this.looksLikeLangToken(segments[0])
? [this.defaultLang, ...segments.slice(1)]
: [this.defaultLang, ...segments];
this.navigateIfChanged(urlTree, targetSegments);
}
@@ -126,12 +180,23 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null;
}
private readRequestHeader(headerName: string): string | null {
const headerValue =
this.request?.headers?.[headerName.toLowerCase()] ??
this.request?.get?.(headerName.toLowerCase());
if (Array.isArray(headerValue)) {
return headerValue[0] ?? null;
}
return typeof headerValue === 'string' ? headerValue : null;
}
private isSupportedLang(
lang: string | null | undefined,
): lang is 'it' | 'en' | 'de' | 'fr' {
): lang is SupportedLang {
return (
typeof lang === 'string' &&
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
this.supportedLangs.includes(lang as SupportedLang)
);
}
@@ -141,7 +206,7 @@ export class LanguageService {
);
}
private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void {
private applyLanguage(lang: SupportedLang): void {
if (this.currentLang() === lang && this.translate.currentLang === lang) {
return;
}
@@ -149,6 +214,88 @@ export class LanguageService {
this.currentLang.set(lang);
}
private resolveLocalizedRouteOverride(
currentTree: UrlTree,
lang: SupportedLang,
): string | null {
const overrides = this.localizedRouteOverrides;
if (!overrides) {
return null;
}
const currentPath = this.getCleanPath(
this.router.serializeUrl(currentTree),
);
const paths = Object.values(overrides)
.map((path) => this.normalizeLocalizedRoutePath(path))
.filter((path): path is string => !!path);
if (!paths.includes(currentPath)) {
return null;
}
return this.normalizeLocalizedRoutePath(overrides[lang]);
}
private normalizeLocalizedRouteOverrides(
paths: LocalizedRouteOverrides | null | undefined,
): LocalizedRouteOverrides | null {
if (!paths) {
return null;
}
const normalized = this.supportedLangs.reduce<LocalizedRouteOverrides>(
(accumulator, lang) => {
const path = this.normalizeLocalizedRoutePath(paths[lang]);
if (path) {
accumulator[lang] = path;
}
return accumulator;
},
{},
);
return Object.keys(normalized).length > 0 ? normalized : null;
}
private normalizeLocalizedRoutePath(
path: string | null | undefined,
): string | null {
const rawPath = String(path ?? '').trim();
if (!rawPath) {
return null;
}
const cleanPath = this.getCleanPath(rawPath);
return cleanPath.startsWith('/') ? cleanPath : null;
}
private navigateToLocalizedRoute(
currentTree: UrlTree,
localizedPath: string,
): void {
const { lang: _unusedLang, ...queryParams } = currentTree.queryParams;
const targetTree = this.router.createUrlTree(
['/', ...localizedPath.split('/').filter(Boolean)],
{
queryParams,
fragment: currentTree.fragment ?? undefined,
},
);
if (
this.router.serializeUrl(targetTree) ===
this.router.serializeUrl(currentTree)
) {
return;
}
this.router.navigateByUrl(targetTree, { replaceUrl: true });
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
}
private navigateIfChanged(
currentTree: UrlTree,
targetSegments: string[],

View File

@@ -0,0 +1,217 @@
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';
import { Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { SeoService } from './seo.service';
describe('SeoService', () => {
function createSnapshot(
data: Record<string, unknown>,
firstChild: ActivatedRouteSnapshot | null = null,
): ActivatedRouteSnapshot {
return {
data,
firstChild,
} as unknown as ActivatedRouteSnapshot;
}
function cleanupSeoDom(): void {
document.head
.querySelectorAll(
'link[rel="canonical"], link[rel="alternate"][data-seo-managed="true"], meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
document.documentElement.removeAttribute('lang');
}
function createService(options: {
url: string;
data: Record<string, unknown>;
translations: Record<string, string>;
}): {
service: SeoService;
meta: jasmine.SpyObj<Meta>;
title: jasmine.SpyObj<Title>;
} {
const events$ = new Subject<unknown>();
const title = jasmine.createSpyObj<Title>('Title', ['setTitle']);
const meta = jasmine.createSpyObj<Meta>('Meta', ['updateTag']);
const translate = {
instant: (key: string) => options.translations[key] ?? key,
} as TranslateService;
const router = {
url: options.url,
events: events$.asObservable(),
routerState: {
snapshot: {
root: createSnapshot(options.data),
},
},
} as unknown as Router;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new SeoService(router, title, meta, translate, document);
return { service, meta, title };
}
beforeEach(() => {
cleanupSeoDom();
});
afterEach(() => {
cleanupSeoDom();
});
it('adds the language prefix to canonical and hreflang URLs', () => {
const { meta, title } = createService({
url: '/privacy?utm=test',
data: {
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
},
translations: {
'SEO.ROUTES.LEGAL.PRIVACY.TITLE': 'Privacy Policy | 3D fab',
'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION': 'Privacy description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('Privacy Policy | 3D fab');
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/privacy`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'en-CH',
href: `${document.location.origin}/en/privacy`,
});
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/it/privacy`,
});
expect(document.documentElement.lang).toBe('it-CH');
const ogUrlCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.property === 'og:url');
expect(ogUrlCall?.[0].content).toBe(
`${document.location.origin}/it/privacy`,
);
const ogLocaleCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.property === 'og:locale');
expect(ogLocaleCall?.[0].content).toBe('it_CH');
});
it('uses the locale-adaptive root as x-default for home pages', () => {
createService({
url: '/de',
data: {
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
},
translations: {
'SEO.ROUTES.HOME.TITLE': '3D-Druck in Zürich | 3D fab',
'SEO.ROUTES.HOME.DESCRIPTION': '3D-Druckservice in Zürich',
},
});
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/`,
});
});
it('resolves translated route metadata for the active language', () => {
const { meta, title } = createService({
url: '/en/about',
data: {
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
translations: {
'SEO.ROUTES.ABOUT.TITLE': 'About Us | 3D fab',
'SEO.ROUTES.ABOUT.DESCRIPTION': 'About description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('About Us | 3D fab');
const descriptionCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.name === 'description');
expect(descriptionCall?.[0].content).toBe('About description');
expect(document.documentElement.lang).toBe('en-CH');
});
it('applies canonical and hreflang values resolved from localized paths', () => {
const { service } = createService({
url: '/it/shop/p/12345678-supporto-cavo-scrivania',
data: {},
translations: {},
});
service.applyResolvedSeo({
title: 'Supporto cavo scrivania | 3D fab',
description: 'Accessorio tecnico',
robots: 'index, follow',
ogTitle: 'Supporto cavo scrivania | 3D fab',
ogDescription: 'Accessorio tecnico',
canonicalPath: '/it/shop/p/12345678-supporto-cavo-scrivania',
alternates: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
},
xDefault: '/it/shop/p/12345678-supporto-cavo-scrivania',
});
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'de-CH',
href: `${document.location.origin}/de/shop/p/12345678-schreibtisch-kabelhalter`,
});
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
});
});
});

View File

@@ -2,29 +2,78 @@ import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter } from 'rxjs/operators';
export interface PageSeoOverride {
title?: string | null;
titleKey?: string | null;
description?: string | null;
descriptionKey?: string | null;
robots?: string | null;
ogTitle?: string | null;
ogTitleKey?: string | null;
ogDescription?: string | null;
ogDescriptionKey?: string | null;
}
export interface ResolvedPageSeo extends PageSeoOverride {
canonicalPath: string | null;
alternates?: SeoMap | null;
xDefault?: string | null;
}
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type SeoMap = Partial<Record<SupportedLang, string>>;
type SeoTextDataKey =
| 'seoTitle'
| 'seoDescription'
| 'ogTitle'
| 'ogDescription';
@Injectable({
providedIn: 'root',
})
export class SeoService {
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
private readonly defaultDescription =
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
private readonly defaultTitleByLang: Record<SupportedLang, string> = {
it: '3D fab | Stampa 3D su misura',
en: '3D fab | Custom 3D Printing',
de: '3D fab | 3D-Druck nach Maß',
fr: '3D fab | Impression 3D sur mesure',
};
private readonly defaultDescriptionByLang: Record<SupportedLang, string> = {
it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.',
en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.',
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
};
private readonly supportedLangs: readonly SupportedLang[] = [
'it',
'en',
'de',
'fr',
];
private readonly supportedLangSet = new Set<SupportedLang>(
this.supportedLangs,
);
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
it: 'it_CH',
en: 'en_CH',
de: 'de_CH',
fr: 'fr_CH',
};
private readonly seoLocaleByLang: Record<SupportedLang, string> = {
it: 'it-CH',
en: 'en-CH',
de: 'de-CH',
fr: 'fr-CH',
};
constructor(
private router: Router,
private titleService: Title,
private metaService: Meta,
private translate: TranslateService,
@Inject(DOCUMENT) private document: Document,
) {
this.applyRouteSeo(this.router.routerState.snapshot.root);
@@ -40,27 +89,81 @@ export class SeoService {
}
applyPageSeo(override: PageSeoOverride): void {
const title = this.asString(override.title) ?? this.defaultTitle;
const description =
this.asString(override.description) ?? this.defaultDescription;
const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description;
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const { title, description, robots, ogTitle, ogDescription } =
this.resolvePageSeoOverride(override, lang);
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const alternates = this.buildAlternatePaths(canonicalPath);
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
this.buildXDefaultPath(canonicalPath, alternates),
lang,
);
}
applyResolvedSeo(override: ResolvedPageSeo): void {
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const { title, description, robots, ogTitle, ogDescription } =
this.resolvePageSeoOverride(override, lang);
const canonicalPath = this.normalizeSeoPath(override.canonicalPath);
const alternates = this.normalizeAlternatePaths(override.alternates);
const xDefault =
this.normalizeSeoPath(override.xDefault) ??
this.buildXDefaultPath(canonicalPath, alternates);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
xDefault,
lang,
);
}
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot);
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const title =
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
this.defaultTitle(lang);
const description =
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
this.defaultDescription(lang);
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
const ogTitle = this.asString(mergedData['ogTitle']) ?? title;
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription =
this.asString(mergedData['ogDescription']) ?? description;
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const alternates = this.buildAlternatePaths(canonicalPath);
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
this.buildXDefaultPath(canonicalPath, alternates),
lang,
);
}
private applySeoValues(
@@ -69,6 +172,11 @@ export class SeoService {
robots: string,
ogTitle: string,
ogDescription: string,
cleanPath: string,
canonicalPath: string | null,
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang,
): void {
this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description });
@@ -79,13 +187,21 @@ export class SeoService {
content: ogDescription,
});
this.metaService.updateTag({ property: 'og:type', content: 'website' });
this.metaService.updateTag({ property: 'og:site_name', content: '3D fab' });
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
this.metaService.updateTag({ name: 'twitter:title', content: ogTitle });
this.metaService.updateTag({
name: 'twitter:description',
content: ogDescription,
});
const cleanPath = this.getCleanPath(this.router.url);
const canonical = `${this.document.location.origin}${cleanPath}`;
this.metaService.updateTag({ property: 'og:url', content: canonical });
this.updateCanonicalTag(canonical);
this.updateLangAndAlternates(cleanPath);
const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
this.metaService.updateTag({ property: 'og:url', content: ogUrl });
this.updateCanonicalTag(
canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
);
this.updateOpenGraphLocales(lang);
this.updateLangAndAlternates(alternates, xDefaultPath, lang);
}
private getMergedRouteData(
@@ -104,15 +220,206 @@ export class SeoService {
return typeof value === 'string' ? value : undefined;
}
private resolveOverrideSeoText(
value: string | null | undefined,
key: string | null | undefined,
): string | undefined {
return this.asString(value) ?? this.resolveTranslation(key);
}
private resolvePageSeoOverride(
override: PageSeoOverride,
lang: SupportedLang,
): {
title: string;
description: string;
robots: string;
ogTitle: string;
ogDescription: string;
} {
const title =
this.resolveOverrideSeoText(override.title, override.titleKey) ??
this.defaultTitle(lang);
const description =
this.resolveOverrideSeoText(
override.description,
override.descriptionKey,
) ?? this.defaultDescription(lang);
const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle =
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
title;
const ogDescription =
this.resolveOverrideSeoText(
override.ogDescription,
override.ogDescriptionKey,
) ?? description;
return {
title,
description,
robots,
ogTitle,
ogDescription,
};
}
private resolveSeoText(
routeData: Record<string, unknown>,
key: SeoTextDataKey,
lang: SupportedLang,
): string | undefined {
const mapKey = `${key}ByLang`;
const localized = routeData[mapKey];
if (
localized &&
typeof localized === 'object' &&
!Array.isArray(localized)
) {
const mapped = localized as SeoMap;
const byLang = this.asString(mapped[lang]);
if (byLang) {
return byLang;
}
}
const translated = this.resolveTranslation(routeData[`${key}Key`]);
if (translated) {
return translated;
}
return this.asString(routeData[key]);
}
private resolveTranslation(value: unknown): string | undefined {
const key = this.asString(value)?.trim();
if (!key) {
return undefined;
}
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: undefined;
}
private defaultTitle(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.TITLE') ??
this.defaultTitleByLang[lang]
);
}
private defaultDescription(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.DESCRIPTION') ??
this.defaultDescriptionByLang[lang]
);
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
}
private updateCanonicalTag(url: string): void {
private resolveLangFromPath(path: string): SupportedLang {
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
return firstSegment as SupportedLang;
}
return 'it';
}
private buildLocalizedPath(path: string, lang: SupportedLang): string {
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}`;
}
const firstSegment = segments[0]?.toLowerCase();
if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
segments[0] = lang;
return `/${segments.join('/')}`;
}
return `/${[lang, ...segments].join('/')}`;
}
private buildAlternatePaths(canonicalPath: string): SeoMap {
const suffixSegments = canonicalPath.split('/').filter(Boolean).slice(1);
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
return this.supportedLangs.reduce<SeoMap>((accumulator, alt) => {
accumulator[alt] = `/${alt}${suffix}`;
return accumulator;
}, {});
}
private buildXDefaultPath(
canonicalPath: string | null,
alternates: SeoMap | null,
): string | null {
if (canonicalPath && this.isLocalizedHomePath(canonicalPath)) {
return '/';
}
return alternates?.it ?? canonicalPath;
}
private isLocalizedHomePath(path: string): boolean {
const segments = path.split('/').filter(Boolean);
return (
segments.length === 1 &&
this.supportedLangSet.has(segments[0] as SupportedLang)
);
}
private normalizeAlternatePaths(
paths: SeoMap | null | undefined,
): SeoMap | null {
if (!paths) {
return null;
}
const normalized = this.supportedLangs.reduce<SeoMap>(
(accumulator, lang) => {
const path = this.normalizeSeoPath(paths[lang]);
if (path) {
accumulator[lang] = path;
}
return accumulator;
},
{},
);
return Object.keys(normalized).length > 0 ? normalized : null;
}
private normalizeSeoPath(path: string | null | undefined): string | null {
const rawPath = String(path ?? '').trim();
if (!rawPath) {
return null;
}
const normalized = this.getCleanPath(rawPath);
return normalized.startsWith('/') ? normalized : null;
}
private toAbsoluteUrl(path: string): string {
return `${this.document.location.origin}${path}`;
}
private updateCanonicalTag(url: string | null): void {
let link = this.document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!url) {
link?.remove();
return;
}
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
@@ -121,33 +428,54 @@ export class SeoService {
link.setAttribute('href', url);
}
private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase();
const hasLang = Boolean(
firstSegment && this.supportedLangs.has(firstSegment),
);
const lang = hasLang ? firstSegment : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
private updateOpenGraphLocales(lang: SupportedLang): void {
this.metaService.updateTag({
property: 'og:locale',
content: this.ogLocaleByLang[lang],
});
this.document.documentElement.lang = lang;
this.document.head
.querySelectorAll(
'meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
for (const alternateLang of this.supportedLangs) {
if (alternateLang === lang) {
continue;
}
this.appendOgLocaleAlternate(this.ogLocaleByLang[alternateLang]);
}
}
private updateLangAndAlternates(
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang,
): void {
this.document.documentElement.lang = this.seoLocaleByLang[lang];
this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) {
if (!alternates) {
return;
}
for (const alt of this.supportedLangs) {
const path = alternates[alt];
if (!path) {
continue;
}
this.appendAlternateLink(
alt,
`${this.document.location.origin}/${alt}${suffix}`,
this.seoLocaleByLang[alt],
this.toAbsoluteUrl(path),
);
}
this.appendAlternateLink(
'x-default',
`${this.document.location.origin}/it${suffix}`,
);
if (xDefaultPath) {
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
}
}
private appendAlternateLink(hreflang: string, href: string): void {
@@ -158,4 +486,12 @@ export class SeoService {
link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link);
}
private appendOgLocaleAlternate(locale: string): void {
const meta = this.document.createElement('meta');
meta.setAttribute('property', 'og:locale:alternate');
meta.setAttribute('content', locale);
meta.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(meta);
}
}

View File

@@ -6,9 +6,8 @@ export const ABOUT_ROUTES: Routes = [
path: '',
component: AboutPageComponent,
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
},
];

View File

@@ -101,6 +101,22 @@
placeholder="Nero, Bianco..."
/>
</label>
<label class="form-field">
<span>Label IT</span>
<input type="text" [(ngModel)]="newVariant.colorLabelIt" />
</label>
<label class="form-field">
<span>Label EN</span>
<input type="text" [(ngModel)]="newVariant.colorLabelEn" />
</label>
<label class="form-field">
<span>Label DE</span>
<input type="text" [(ngModel)]="newVariant.colorLabelDe" />
</label>
<label class="form-field">
<span>Label FR</span>
<input type="text" [(ngModel)]="newVariant.colorLabelFr" />
</label>
<label class="form-field">
<span>Hex colore</span>
<input
@@ -229,7 +245,7 @@
class="color-dot"
[style.background-color]="getVariantColorHex(variant)"
></span>
{{ variant.colorName || "N/D" }}
{{ variant.colorLabelIt || variant.colorName || "N/D" }}
</span>
<span
>Stock spools:
@@ -290,6 +306,22 @@
<span>Colore</span>
<input type="text" [(ngModel)]="variant.colorName" />
</label>
<label class="form-field">
<span>Label IT</span>
<input type="text" [(ngModel)]="variant.colorLabelIt" />
</label>
<label class="form-field">
<span>Label EN</span>
<input type="text" [(ngModel)]="variant.colorLabelEn" />
</label>
<label class="form-field">
<span>Label DE</span>
<input type="text" [(ngModel)]="variant.colorLabelDe" />
</label>
<label class="form-field">
<span>Label FR</span>
<input type="text" [(ngModel)]="variant.colorLabelFr" />
</label>
<label class="form-field">
<span>Hex colore</span>
<input type="text" [(ngModel)]="variant.colorHex" />

View File

@@ -47,6 +47,10 @@ export class AdminFilamentStockComponent implements OnInit {
materialTypeId: 0,
variantDisplayName: '',
colorName: '',
colorLabelIt: '',
colorLabelEn: '',
colorLabelDe: '',
colorLabelFr: '',
colorHex: '',
finishType: 'GLOSSY',
brand: '',
@@ -206,6 +210,10 @@ export class AdminFilamentStockComponent implements OnInit {
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '',
colorName: '',
colorLabelIt: '',
colorLabelEn: '',
colorLabelDe: '',
colorLabelFr: '',
colorHex: '',
finishType: 'GLOSSY',
brand: '',
@@ -359,6 +367,10 @@ export class AdminFilamentStockComponent implements OnInit {
materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(),
colorName: (source.colorName || '').trim(),
colorLabelIt: (source.colorLabelIt || '').trim() || undefined,
colorLabelEn: (source.colorLabelEn || '').trim() || undefined,
colorLabelDe: (source.colorLabelDe || '').trim() || undefined,
colorLabelFr: (source.colorLabelFr || '').trim() || undefined,
colorHex: (source.colorHex || '').trim() || undefined,
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
brand: (source.brand || '').trim() || undefined,

View File

@@ -206,17 +206,6 @@
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">Nome categoria</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.name"
name="categoryName"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">Slug</span>
<div class="input-with-action">
@@ -237,36 +226,6 @@
</div>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">Descrizione</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.description"
name="categoryDescription"
rows="3"
></textarea>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO title</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitle"
name="categorySeoTitle"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO description</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoDescription"
name="categorySeoDescription"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">OG title</span>
<input
@@ -288,6 +247,141 @@
</label>
</div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua contenuti categoria</span>
<p>IT / EN / DE / FR</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategoryContentLanguageComplete(language)"
[class.incomplete]="
isCategoryContentLanguageIncomplete(language)
"
[class.empty]="!isCategoryContentLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
Nome categoria {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.names[activeContentLanguage]"
[name]="'category-name-' + activeContentLanguage"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
Descrizione {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.descriptions[activeContentLanguage]"
[name]="'category-description-' + activeContentLanguage"
rows="3"
></textarea>
</label>
</div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua SEO categoria</span>
<p>Stessa lingua attiva dell'editor</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategorySeoLanguageComplete(language)"
[class.incomplete]="isCategorySeoLanguageIncomplete(language)"
[class.empty]="!isCategorySeoLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
SEO title {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitles[activeContentLanguage]"
[name]="'category-seo-title-' + activeContentLanguage"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
SEO description {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control"
[(ngModel)]="
categoryForm.seoDescriptions[activeContentLanguage]
"
[name]="'category-seo-description-' + activeContentLanguage"
rows="3"
></textarea>
<span
class="seo-counter"
[class.seo-counter--danger]="
categorySeoDescriptionLength(activeContentLanguage) > 160
"
>
{{ categorySeoDescriptionLength(activeContentLanguage) }}/160
</span>
</label>
</div>
<div class="toggle-row">
<label class="ui-checkbox">
<input
@@ -574,9 +668,31 @@
<div>
<h3>Contenuti localizzati</h3>
<p>
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
Nome obbligatorio in tutte le lingue. Descrizioni opzionali. La
traduzione usa la lingua editor come sorgente e compila il form
senza salvare.
</p>
</div>
<button
type="button"
class="ui-button ui-button--ghost"
(click)="translateProductFromCurrentLanguage()"
[disabled]="!canTranslateProductFromCurrentLanguage()"
>
{{ translatingProduct ? "Traduco..." : "Traduci" }}
</button>
</div>
<div class="toggle-row toggle-row--compact">
<label class="ui-checkbox">
<input
type="checkbox"
[(ngModel)]="overwriteExistingTranslations"
name="productOverwriteExistingTranslations"
/>
<span class="ui-checkbox__mark" aria-hidden="true"></span>
<span>Sovrascrivi traduzioni esistenti</span>
</label>
</div>
<div class="ui-language-toolbar">

View File

@@ -18,6 +18,8 @@ import {
AdminShopProductModel,
AdminShopProductVariant,
AdminShopService,
AdminTranslateShopProductPayload,
AdminTranslateShopProductResponse,
AdminUpsertShopCategoryPayload,
AdminUpsertShopProductPayload,
AdminUpsertShopProductVariantPayload,
@@ -41,10 +43,10 @@ interface CategoryFormState {
id: string | null;
parentCategoryId: string | null;
slug: string;
name: string;
description: string;
seoTitle: string;
seoDescription: string;
names: Record<ShopLanguage, string>;
descriptions: Record<ShopLanguage, string>;
seoTitles: Record<ShopLanguage, string>;
seoDescriptions: Record<ShopLanguage, string>;
ogTitle: string;
ogDescription: string;
indexable: boolean;
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
loading = false;
detailLoading = false;
savingProduct = false;
translatingProduct = false;
deletingProduct = false;
savingCategory = false;
deletingCategory = false;
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
productStatusFilter: ProductStatusFilter = 'ALL';
showCategoryManager = false;
activeContentLanguage: ShopLanguage = 'it';
overwriteExistingTranslations = false;
errorMessage: string | null = null;
successMessage: string | null = null;
@@ -554,7 +558,56 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
slugifyCategoryFromName(): void {
this.categoryForm.slug = this.slugify(this.categoryForm.name);
const source =
this.categoryForm.names[this.activeContentLanguage] ||
this.categoryForm.names['it'];
this.categoryForm.slug = this.slugify(source);
}
translateProductFromCurrentLanguage(): void {
if (this.translatingProduct) {
return;
}
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
const sourceLanguage = this.activeContentLanguage;
if (!this.productForm.names[sourceLanguage].trim()) {
this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`;
this.successMessage = null;
return;
}
const payload = this.buildProductTranslationPayload(sourceLanguage);
this.translatingProduct = true;
this.errorMessage = null;
this.successMessage = null;
this.adminShopService.translateProduct(payload).subscribe({
next: (response) => {
this.translatingProduct = false;
this.applyProductTranslation(response, payload.overwriteExisting);
this.successMessage = response.targetLanguages.length
? `Traduzioni ${response.targetLanguages
.map((language) => this.languageLabels[language])
.join(' / ')} aggiornate nel form.`
: 'Nessun campo da tradurre.';
},
error: (error) => {
this.translatingProduct = false;
this.errorMessage = this.extractErrorMessage(
error,
'Traduzione prodotto non riuscita.',
);
},
});
}
canTranslateProductFromCurrentLanguage(): boolean {
return (
!this.translatingProduct &&
!!this.productForm.names[this.activeContentLanguage].trim()
);
}
setActiveContentLanguage(language: ShopLanguage): void {
@@ -603,6 +656,45 @@ export class AdminShopComponent implements OnInit, OnDestroy {
);
}
isCategoryContentLanguageComplete(language: ShopLanguage): boolean {
return !!this.categoryForm.names[language].trim();
}
isCategoryContentLanguageStarted(language: ShopLanguage): boolean {
return (
!!this.categoryForm.names[language].trim() ||
!!this.categoryForm.descriptions[language].trim()
);
}
isCategoryContentLanguageIncomplete(language: ShopLanguage): boolean {
return (
this.isCategoryContentLanguageStarted(language) &&
!this.isCategoryContentLanguageComplete(language)
);
}
isCategorySeoLanguageComplete(language: ShopLanguage): boolean {
return (
!!this.categoryForm.seoTitles[language].trim() &&
!!this.categoryForm.seoDescriptions[language].trim()
);
}
isCategorySeoLanguageStarted(language: ShopLanguage): boolean {
return (
!!this.categoryForm.seoTitles[language].trim() ||
!!this.categoryForm.seoDescriptions[language].trim()
);
}
isCategorySeoLanguageIncomplete(language: ShopLanguage): boolean {
return (
this.isCategorySeoLanguageStarted(language) &&
!this.isCategorySeoLanguageComplete(language)
);
}
preventRichTextToolbarMouseDown(event: MouseEvent): void {
event.preventDefault();
}
@@ -1228,10 +1320,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
id: null,
parentCategoryId: null,
slug: '',
name: '',
description: '',
seoTitle: '',
seoDescription: '',
names: this.createEmptyLocalizedTextRecord(),
descriptions: this.createEmptyLocalizedTextRecord(),
seoTitles: this.createEmptyLocalizedTextRecord(),
seoDescriptions: this.createEmptyLocalizedTextRecord(),
ogTitle: '',
ogDescription: '',
indexable: true,
@@ -1241,6 +1333,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
private resetCategoryForm(): void {
this.activeContentLanguage = 'it';
Object.assign(this.categoryForm, this.createEmptyCategoryForm());
}
@@ -1249,10 +1342,30 @@ export class AdminShopComponent implements OnInit, OnDestroy {
id: category.id,
parentCategoryId: category.parentCategoryId,
slug: category.slug ?? '',
name: category.name ?? '',
description: category.description ?? '',
seoTitle: category.seoTitle ?? '',
seoDescription: category.seoDescription ?? '',
names: {
it: category.nameIt ?? category.name ?? '',
en: category.nameEn ?? category.name ?? '',
de: category.nameDe ?? category.name ?? '',
fr: category.nameFr ?? category.name ?? '',
},
descriptions: {
it: category.descriptionIt ?? category.description ?? '',
en: category.descriptionEn ?? category.description ?? '',
de: category.descriptionDe ?? category.description ?? '',
fr: category.descriptionFr ?? category.description ?? '',
},
seoTitles: {
it: category.seoTitleIt ?? category.seoTitle ?? '',
en: category.seoTitleEn ?? category.seoTitle ?? '',
de: category.seoTitleDe ?? category.seoTitle ?? '',
fr: category.seoTitleFr ?? category.seoTitle ?? '',
},
seoDescriptions: {
it: category.seoDescriptionIt ?? category.seoDescription ?? '',
en: category.seoDescriptionEn ?? category.seoDescription ?? '',
de: category.seoDescriptionDe ?? category.seoDescription ?? '',
fr: category.seoDescriptionFr ?? category.seoDescription ?? '',
},
ogTitle: category.ogTitle ?? '',
ogDescription: category.ogDescription ?? '',
indexable: category.indexable,
@@ -1265,10 +1378,36 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return {
parentCategoryId: this.categoryForm.parentCategoryId || null,
slug: this.categoryForm.slug.trim(),
name: this.categoryForm.name.trim(),
description: this.categoryForm.description.trim(),
seoTitle: this.categoryForm.seoTitle.trim(),
seoDescription: this.categoryForm.seoDescription.trim(),
name: this.categoryForm.names['it'].trim(),
nameIt: this.categoryForm.names['it'].trim(),
nameEn: this.categoryForm.names['en'].trim(),
nameDe: this.categoryForm.names['de'].trim(),
nameFr: this.categoryForm.names['fr'].trim(),
description: this.optionalValue(this.categoryForm.descriptions['it']),
descriptionIt: this.optionalValue(this.categoryForm.descriptions['it']),
descriptionEn: this.optionalValue(this.categoryForm.descriptions['en']),
descriptionDe: this.optionalValue(this.categoryForm.descriptions['de']),
descriptionFr: this.optionalValue(this.categoryForm.descriptions['fr']),
seoTitle: this.optionalValue(this.categoryForm.seoTitles['it']),
seoTitleIt: this.optionalValue(this.categoryForm.seoTitles['it']),
seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']),
seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']),
seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']),
seoDescription: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionIt: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionEn: this.optionalValue(
this.categoryForm.seoDescriptions['en'],
),
seoDescriptionDe: this.optionalValue(
this.categoryForm.seoDescriptions['de'],
),
seoDescriptionFr: this.optionalValue(
this.categoryForm.seoDescriptions['fr'],
),
ogTitle: this.categoryForm.ogTitle.trim(),
ogDescription: this.categoryForm.ogDescription.trim(),
indexable: this.categoryForm.indexable,
@@ -1278,12 +1417,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
private validateCategoryForm(): string | null {
if (!this.categoryForm.name.trim()) {
return 'Il nome categoria è obbligatorio.';
for (const language of this.shopLanguages) {
if (!this.categoryForm.names[language].trim()) {
return `Il nome categoria ${this.languageLabels[language]} è obbligatorio.`;
}
}
if (!this.categoryForm.slug.trim()) {
return 'Lo slug categoria è obbligatorio.';
}
for (const language of this.shopLanguages) {
if (this.categoryForm.seoDescriptions[language].trim().length > 160) {
return `La SEO description categoria ${this.languageLabels[language]} deve avere massimo 160 caratteri.`;
}
}
return null;
}
@@ -1573,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy {
};
}
private buildProductTranslationPayload(
sourceLanguage: ShopLanguage,
): AdminTranslateShopProductPayload {
const materialCodes = Array.from(
new Set(
this.productForm.materials
.map((material) => material.materialCode.trim().toUpperCase())
.filter((materialCode) => !!materialCode),
),
);
return {
categoryId: this.productForm.categoryId || undefined,
sourceLanguage,
overwriteExisting: this.overwriteExistingTranslations,
materialCodes,
names: { ...this.productForm.names },
excerpts: { ...this.productForm.excerpts },
descriptions: { ...this.productForm.descriptions },
seoTitles: { ...this.productForm.seoTitles },
seoDescriptions: { ...this.productForm.seoDescriptions },
};
}
private applyProductTranslation(
response: AdminTranslateShopProductResponse,
overwriteExisting: boolean,
): void {
for (const language of response.targetLanguages) {
this.mergeLocalizedText(
this.productForm.names,
response.names,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.excerpts,
response.excerpts,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.descriptions,
response.descriptions,
language,
overwriteExisting,
true,
);
this.mergeLocalizedText(
this.productForm.seoTitles,
response.seoTitles,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.seoDescriptions,
response.seoDescriptions,
language,
overwriteExisting,
);
}
this.renderActiveDescriptionInEditor();
}
private mergeLocalizedText(
target: Record<ShopLanguage, string>,
translated:
| Partial<Record<ShopLanguage, string>>
| Record<ShopLanguage, string>
| undefined,
language: ShopLanguage,
overwriteExisting: boolean,
richText = false,
): void {
const incoming = translated?.[language];
if (incoming === undefined) {
return;
}
const hasCurrentValue = richText
? this.hasMeaningfulRichText(target[language] ?? '')
: !!target[language]?.trim();
if (hasCurrentValue && !overwriteExisting) {
return;
}
target[language] = richText
? this.normalizeDescriptionForEditor(incoming)
: incoming.trim();
}
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
const existingVariantsByKey = new Map(
(this.selectedProduct?.variants ?? []).map((variant) => [
@@ -1616,6 +1854,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
sku: this.optionalValue(existingVariant?.sku ?? ''),
variantLabel: materialCode,
colorName: stockVariant.colorName.trim(),
colorLabelIt: this.optionalValue(stockVariant.colorLabelIt ?? ''),
colorLabelEn: this.optionalValue(stockVariant.colorLabelEn ?? ''),
colorLabelDe: this.optionalValue(stockVariant.colorLabelDe ?? ''),
colorLabelFr: this.optionalValue(stockVariant.colorLabelFr ?? ''),
colorHex: this.optionalValue(
stockVariant.colorHex ?? '',
)?.toUpperCase(),
@@ -1714,7 +1956,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
private stockVariantLabel(variant: AdminFilamentVariant): string {
const colorName = variant.colorName.trim();
const colorName = (variant.colorLabelIt || variant.colorName).trim();
const variantDisplayName = variant.variantDisplayName.trim();
if (
variantDisplayName &&
@@ -2193,6 +2435,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return this.productForm.seoDescriptions[language].trim().length;
}
categorySeoDescriptionLength(language: ShopLanguage): number {
return this.categoryForm.seoDescriptions[language].trim().length;
}
private createEmptyLocalizedTextRecord(): Record<ShopLanguage, string> {
return {
it: '',
en: '',
de: '',
fr: '',
};
}
private slugify(source: string): string {
return source
.normalize('NFD')

View File

@@ -32,6 +32,10 @@ export interface AdminFilamentVariant {
materialTechnicalTypeLabel?: string;
variantDisplayName: string;
colorName: string;
colorLabelIt: string;
colorLabelEn: string;
colorLabelDe: string;
colorLabelFr: string;
colorHex?: string;
finishType?: string;
brand?: string;
@@ -57,6 +61,10 @@ export interface AdminUpsertFilamentVariantPayload {
materialTypeId: number;
variantDisplayName: string;
colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
colorHex?: string;
finishType?: string;
brand?: string;

View File

@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import {
AdminShopService,
AdminTranslateShopProductPayload,
} from './admin-shop.service';
describe('AdminShopService', () => {
let service: AdminShopService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AdminShopService],
});
service = TestBed.inject(AdminShopService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('posts product translation requests with credentials', () => {
const payload: AdminTranslateShopProductPayload = {
categoryId: 'category-1',
sourceLanguage: 'it',
overwriteExisting: false,
materialCodes: ['PLA', 'PETG'],
names: {
it: 'Supporto cavo scrivania',
en: '',
de: '',
fr: '',
},
excerpts: {
it: 'Accessorio tecnico',
en: '',
de: '',
fr: '',
},
descriptions: {
it: '<p>Descrizione prodotto</p>',
en: '',
de: '',
fr: '',
},
seoTitles: {
it: 'Supporto cavo scrivania | 3D fab',
en: '',
de: '',
fr: '',
},
seoDescriptions: {
it: 'Supporto tecnico stampato in 3D per scrivania.',
en: '',
de: '',
fr: '',
},
};
service.translateProduct(payload).subscribe((response) => {
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
expect(response.names.en).toBe('Desk cable clip');
});
const request = httpMock.expectOne(
'http://localhost:8000/api/admin/shop/products/translate',
);
expect(request.request.method).toBe('POST');
expect(request.request.withCredentials).toBeTrue();
expect(request.request.body).toEqual(payload);
request.flush({
sourceLanguage: 'it',
targetLanguages: ['en', 'de', 'fr'],
names: {
en: 'Desk cable clip',
de: 'Schreibtisch-Kabelhalter',
fr: 'Support de cable de bureau',
},
excerpts: {},
descriptions: {},
seoTitles: {},
seoDescriptions: {},
});
});
});

View File

@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
altText: string;
}
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
export interface AdminShopCategoryRef {
id: string;
slug: string;
@@ -30,9 +32,25 @@ export interface AdminShopCategory {
parentCategoryName: string | null;
slug: string;
name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description: string | null;
descriptionIt: string | null;
descriptionEn: string | null;
descriptionDe: string | null;
descriptionFr: string | null;
seoTitle: string | null;
seoTitleIt: string | null;
seoTitleEn: string | null;
seoTitleDe: string | null;
seoTitleFr: string | null;
seoDescription: string | null;
seoDescriptionIt: string | null;
seoDescriptionEn: string | null;
seoDescriptionDe: string | null;
seoDescriptionFr: string | null;
ogTitle: string | null;
ogDescription: string | null;
indexable: boolean;
@@ -54,9 +72,25 @@ export interface AdminUpsertShopCategoryPayload {
parentCategoryId?: string | null;
slug: string;
name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description?: string;
descriptionIt?: string;
descriptionEn?: string;
descriptionDe?: string;
descriptionFr?: string;
seoTitle?: string;
seoTitleIt?: string;
seoTitleEn?: string;
seoTitleDe?: string;
seoTitleFr?: string;
seoDescription?: string;
seoDescriptionIt?: string;
seoDescriptionEn?: string;
seoDescriptionDe?: string;
seoDescriptionFr?: string;
ogTitle?: string;
ogDescription?: string;
indexable: boolean;
@@ -69,6 +103,10 @@ export interface AdminShopProductVariant {
sku: string | null;
variantLabel: string;
colorName: string;
colorLabelIt: string;
colorLabelEn: string;
colorLabelDe: string;
colorLabelFr: string;
colorHex: string | null;
internalMaterialCode: string;
priceChf: number;
@@ -170,6 +208,10 @@ export interface AdminUpsertShopProductVariantPayload {
sku?: string;
variantLabel?: string;
colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
colorHex?: string;
internalMaterialCode: string;
priceChf: number;
@@ -215,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
variants: AdminUpsertShopProductVariantPayload[];
}
export interface AdminTranslateShopProductPayload {
categoryId?: string;
sourceLanguage: AdminShopLanguage;
overwriteExisting: boolean;
materialCodes: string[];
names: Record<AdminShopLanguage, string>;
excerpts: Record<AdminShopLanguage, string>;
descriptions: Record<AdminShopLanguage, string>;
seoTitles: Record<AdminShopLanguage, string>;
seoDescriptions: Record<AdminShopLanguage, string>;
}
export interface AdminTranslateShopProductResponse {
sourceLanguage: AdminShopLanguage;
targetLanguages: AdminShopLanguage[];
names: Partial<Record<AdminShopLanguage, string>>;
excerpts: Partial<Record<AdminShopLanguage, string>>;
descriptions: Partial<Record<AdminShopLanguage, string>>;
seoTitles: Partial<Record<AdminShopLanguage, string>>;
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
}
@Injectable({
providedIn: 'root',
})
@@ -311,6 +375,18 @@ export class AdminShopService {
});
}
translateProduct(
payload: AdminTranslateShopProductPayload,
): Observable<AdminTranslateShopProductResponse> {
return this.http.post<AdminTranslateShopProductResponse>(
`${this.productsBaseUrl}/translate`,
payload,
{
withCredentials: true,
},
);
}
uploadProductModel(
productId: string,
file: File,

View File

@@ -0,0 +1,28 @@
<section class="animation-test-page">
<div class="animation-toolbar" role="group" aria-label="Animation variants">
<button
type="button"
class="variant-toggle"
[class.active]="variant() === 'site-intro'"
(click)="setVariant('site-intro')"
>
Site intro
</button>
<button
type="button"
class="variant-toggle"
[class.active]="variant() === 'calculator-loader'"
(click)="setVariant('calculator-loader')"
>
Calculator loader
</button>
</div>
<div class="animation-stage" [attr.data-variant]="variant()">
<app-brand-animation-logo
[variant]="variant()"
[decorative]="false"
ariaLabel="3D fab animation test"
></app-brand-animation-logo>
</div>
</section>

View File

@@ -0,0 +1,60 @@
:host {
display: block;
}
.animation-test-page {
min-height: 100vh;
display: grid;
align-content: center;
justify-items: center;
gap: 1.5rem;
padding: 2rem 1.5rem 3rem;
background: #fff;
}
.animation-toolbar {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem;
border: 1px solid rgba(16, 24, 32, 0.12);
border-radius: 999px;
background: #f7f5ef;
}
.variant-toggle {
min-height: 2.4rem;
padding: 0 1rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--color-text-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
}
.variant-toggle.active {
background: #fff;
color: var(--color-text);
box-shadow: 0 6px 16px rgba(16, 24, 32, 0.08);
}
.animation-stage {
width: min(100%, 26rem);
}
@media (max-width: 640px) {
.animation-toolbar {
flex-wrap: wrap;
justify-content: center;
}
.animation-stage {
width: min(100%, 19rem);
}
}

View File

@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import {
BrandAnimationLogoComponent,
BrandAnimationVariant,
} from '../../shared/components/brand-animation-logo/brand-animation-logo.component';
@Component({
selector: 'app-calculator-animation-test',
standalone: true,
imports: [CommonModule, BrandAnimationLogoComponent],
templateUrl: './calculator-animation-test.component.html',
styleUrl: './calculator-animation-test.component.scss',
})
export class CalculatorAnimationTestComponent {
readonly variant = signal<BrandAnimationVariant>('site-intro');
setVariant(variant: BrandAnimationVariant): void {
this.variant.set(variant);
}
}

View File

@@ -57,7 +57,10 @@
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<app-brand-animation-logo
class="loader-logo"
variant="calculator-loader"
></app-brand-animation-logo>
<h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }}
</h3>

View File

@@ -93,7 +93,7 @@
.loader-content {
text-align: center;
max-width: 300px;
max-width: 22rem;
margin: 0 auto;
/* Center content vertically within the stretched card */
@@ -101,12 +101,14 @@
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: var(--space-3);
}
.loading-title {
font-size: 1.1rem;
font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
margin: 0;
color: var(--color-text);
}
@@ -114,23 +116,21 @@
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
}
.spinner {
border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
.loader-logo {
display: block;
width: min(100%, 16rem);
margin: 0 auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
--brand-animation-width: 16rem;
--brand-animation-height: 4.8rem;
--brand-animation-letter-width: 2.85rem;
--brand-animation-scale: 0.84;
--brand-animation-word-spacing: 0.97;
--brand-animation-width-mobile: 14rem;
--brand-animation-height-mobile: 4.1rem;
--brand-animation-letter-width-mobile: 2.45rem;
--brand-animation-scale-mobile: 0.84;
--brand-animation-loader-loop-duration: 2.65s;
}

View File

@@ -17,6 +17,7 @@ import { catchError, map } from 'rxjs/operators';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { BrandAnimationLogoComponent } from '../../shared/components/brand-animation-logo/brand-animation-logo.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import {
@@ -48,6 +49,7 @@ type TrackedPrintSettings = {
AppCardComponent,
AppAlertComponent,
AppButtonComponent,
BrandAnimationLogoComponent,
UploadFormComponent,
QuoteResultComponent,
SuccessStateComponent,

View File

@@ -3,14 +3,25 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
{
path: 'animation-test',
loadComponent: () =>
import('./calculator-animation-test.component').then(
(m) => m.CalculatorAnimationTestComponent,
),
data: {
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
{
path: 'basic',
component: CalculatorPageComponent,
data: {
mode: 'easy',
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
seoDescription:
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.BASIC.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.BASIC.DESCRIPTION',
},
},
{
@@ -18,9 +29,8 @@ export const CALCULATOR_ROUTES: Routes = [
component: CalculatorPageComponent,
data: {
mode: 'advanced',
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
seoDescription:
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.DESCRIPTION',
},
},
];

View File

@@ -101,9 +101,11 @@
<p class="upload-privacy-note">
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
>.
</p>

View File

@@ -30,6 +30,7 @@ import {
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
import { LanguageService } from '../../../../core/services/language.service';
interface FormItem {
file: File;
@@ -106,6 +107,7 @@ export class UploadFormComponent implements OnInit {
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
private translate = inject(TranslateService);
readonly languageService = inject(LanguageService);
form: FormGroup;
@@ -125,7 +127,8 @@ export class UploadFormComponent implements OnInit {
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false;
acceptedFormats = '.stl,.3mf,.step,.stp';
acceptedFormats = '.stl,.3mf';
private readonly allowedExtensions = ['stl', '3mf'] as const;
constructor() {
this.form = this.fb.group({
@@ -284,6 +287,13 @@ export class UploadFormComponent implements OnInit {
return name.endsWith('.stl');
}
isSupportedFile(file: File | null): boolean {
if (!file) return false;
const name = file.name.toLowerCase().trim();
return this.allowedExtensions.some((ext) => name.endsWith(`.${ext}`));
}
canPreviewSelectedFile(): boolean {
return this.isStlFile(this.getSelectedPreviewFile());
}
@@ -338,13 +348,19 @@ export class UploadFormComponent implements OnInit {
onFilesDropped(newFiles: File[]) {
const MAX_SIZE = 200 * 1024 * 1024;
const validItems: FormItem[] = [];
let hasError = false;
let hasInvalidType = false;
let hasOversize = false;
const defaults = this.getCurrentGlobalItemDefaults();
for (const file of newFiles) {
if (!this.isSupportedFile(file)) {
hasInvalidType = true;
continue;
}
if (file.size > MAX_SIZE) {
hasError = true;
hasOversize = true;
continue;
}
@@ -365,7 +381,11 @@ export class UploadFormComponent implements OnInit {
});
}
if (hasError) {
if (hasInvalidType) {
alert(this.translate.instant('CALC.ERR_INVALID_FILE_TYPE'));
}
if (hasOversize) {
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
}

View File

@@ -120,13 +120,18 @@
<input type="checkbox" formControlName="acceptLegal" />
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>

View File

@@ -1,4 +1,4 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, inject, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ReactiveFormsModule,
@@ -11,6 +11,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteResult } from '../../services/quote-estimator.service';
import { LanguageService } from '../../../../core/services/language.service';
@Component({
selector: 'app-user-details',
@@ -30,6 +31,7 @@ export class UserDetailsComponent {
quote = input<QuoteResult>();
submitOrder = output<any>();
cancel = output<void>();
readonly languageService = inject(LanguageService);
form: FormGroup;
submitting = signal(false);

View File

@@ -75,6 +75,10 @@ export interface VariantOption {
id: number;
name: string;
colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
hexColor: string;
finishType: string;
stockSpools: number;

View File

@@ -1,10 +1,10 @@
<div class="checkout-page">
<div class="container ui-page-hero">
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
Servizio CAD
{{ "CHECKOUT.CAD_SERVICE" | translate }}
<ng-container *ngIf="cadRequestId()">
riferito alla richiesta contatto #{{ cadRequestId() }}
{{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
</ng-container>
</p>
</div>
@@ -204,13 +204,18 @@
<span class="ui-checkbox__mark"></span>
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>
@@ -265,14 +270,16 @@
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}:
{{ variantLabel }}
{{ variantLabel | translate }}
</span>
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
<span
class="color-dot"
[style.background-color]="itemColorSwatch(item)"
></span>
<span class="color-name">{{ itemColorLabel(item) }}</span>
<span class="color-name">{{
itemColorLabel(item) | translate
}}</span>
</span>
</div>
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
@@ -327,7 +334,9 @@
</div>
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
<div class="item-details">
<span class="item-name">Servizio CAD</span>
<span class="item-name">{{
"CHECKOUT.CAD_SERVICE" | translate
}}</span>
<div class="item-specs-sub">{{ cadHours() }}h</div>
</div>
<div class="item-price">

View File

@@ -1,3 +1,7 @@
.checkout-hero {
padding-top: calc(var(--space-12) + var(--space-4));
}
.cad-subtitle {
margin: 0;
}
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
.mb-6 {
margin-bottom: var(--space-6);
}
@media (max-width: 640px) {
.checkout-hero {
padding-top: calc(var(--space-8) + var(--space-4));
}
}

View File

@@ -22,7 +22,12 @@ import {
} from '../../shared/components/price-breakdown/price-breakdown.component';
import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
import { getColorHex } from '../../core/constants/colors.const';
import {
findColorHex,
getColorHex,
normalizeColorValue,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
@Component({
selector: 'app-checkout',
@@ -46,7 +51,7 @@ export class CheckoutComponent implements OnInit {
private quoteService = inject(QuoteEstimatorService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private languageService = inject(LanguageService);
readonly languageService = inject(LanguageService);
checkoutForm: FormGroup;
sessionId: string | null = null;
@@ -142,7 +147,7 @@ export class CheckoutComponent implements OnInit {
this.sessionId = params['session'];
if (!this.sessionId) {
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
this.router.navigate(['/']); // Redirect if no session
this.router.navigate(['/', this.languageService.selectedLang()]);
return;
}
@@ -252,8 +257,7 @@ export class CheckoutComponent implements OnInit {
if (variantLabel) {
return variantLabel;
}
const colorName = String(item?.shopVariantColorName ?? '').trim();
return colorName || null;
return this.localizedShopColorLabel(item);
}
showItemMaterial(item: any): boolean {
@@ -282,12 +286,7 @@ export class CheckoutComponent implements OnInit {
}
itemColorLabel(item: any): string {
const shopColor = String(item?.shopVariantColorName ?? '').trim();
if (shopColor) {
return shopColor;
}
const raw = String(item?.colorCode ?? '').trim();
return raw || '-';
return this.localizedShopColorLabel(item) || String(item?.colorCode ?? '-');
}
itemColorSwatch(item: any): string {
@@ -310,12 +309,12 @@ export class CheckoutComponent implements OnInit {
return raw;
}
const byName = this.variantHexByColorName.get(raw.toLowerCase());
const byName = this.variantHexByColorName.get(normalizeColorValue(raw));
if (byName) {
return byName;
}
const fallback = getColorHex(raw);
const fallback = findColorHex(raw) ?? getColorHex(raw);
if (fallback && fallback !== '#facf0a') {
return fallback;
}
@@ -331,6 +330,16 @@ export class CheckoutComponent implements OnInit {
return !!this.previewLoading()[id];
}
private localizedShopColorLabel(item: any): string | null {
return resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: item?.shopVariantColorName ?? item?.colorCode,
it: item?.shopVariantColorLabelIt,
en: item?.shopVariantColorLabelEn,
de: item?.shopVariantColorLabelDe,
fr: item?.shopVariantColorLabelFr,
});
}
hasPreviewError(item: any): boolean {
const id = String(item?.id ?? '');
if (!id) {
@@ -373,7 +382,10 @@ export class CheckoutComponent implements OnInit {
this.variantHexById.set(variantId, colorHex);
}
if (colorName && colorHex) {
this.variantHexByColorName.set(colorName.toLowerCase(), colorHex);
this.variantHexByColorName.set(
normalizeColorValue(colorName),
colorHex,
);
}
}
}

View File

@@ -85,9 +85,11 @@
<p class="ui-form-hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
<p class="ui-form-hint upload-privacy-note">
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
>.
</p>
@@ -161,13 +163,18 @@
<span class="ui-checkbox__mark"></span>
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>

View File

@@ -70,7 +70,7 @@ export class ContactFormComponent implements OnDestroy {
];
private quoteRequestService = inject(QuoteRequestService);
private languageService = inject(LanguageService);
readonly languageService = inject(LanguageService);
constructor(
private fb: FormBuilder,

View File

@@ -6,9 +6,8 @@ export const CONTACT_ROUTES: Routes = [
loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
},
},
];

View File

@@ -1,6 +1,8 @@
<main class="home-page">
<section class="hero">
<div class="container hero-grid ui-content-grid ui-content-grid--spacious">
<div
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
>
<div class="hero-copy">
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
<h1
@@ -14,17 +16,47 @@
{{ "HOME.HERO_SUBTITLE" | translate }}
</p>
<div class="hero-actions ui-inline-actions ui-inline-actions--wide">
<app-button variant="primary" routerLink="/calculator/basic">{{
"HOME.BTN_CALCULATE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/shop">{{
"HOME.BTN_SHOP" | translate
}}</app-button>
<app-button variant="text" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ "HOME.BTN_CALCULATE" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/shop')"
>{{ "HOME.BTN_SHOP" | translate }}</app-button
>
<app-button
variant="text"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
</div>
<aside class="hero-swiss-card">
<div class="hero-swiss-head">
<span class="hero-swiss-emblem" aria-hidden="true">
<span class="hero-swiss-cross"></span>
</span>
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
{{ "HOME.HERO_SWISS_TITLE" | translate }}
</p>
</div>
<p class="hero-swiss-copy">
{{ "HOME.HERO_SWISS_COPY" | translate }}
</p>
<div class="hero-swiss-locations">
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_1" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_2" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_3" | translate
}}</span>
</div>
</aside>
</div>
</section>
@@ -110,13 +142,13 @@
<app-button
variant="primary"
[fullWidth]="true"
routerLink="/calculator/basic"
[routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
>
<app-button
variant="outline"
[fullWidth]="true"
routerLink="/contact"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
@@ -141,12 +173,16 @@
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
</ul>
<div class="shop-actions ui-inline-actions">
<app-button variant="primary" routerLink="/shop">{{
"HOME.BTN_DISCOVER" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_REQ_SOLUTION" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/shop')"
>{{ "HOME.BTN_DISCOVER" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
>
</div>
</div>
<div
@@ -211,12 +247,16 @@
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
</p>
<div class="about-actions ui-inline-actions">
<app-button variant="primary" routerLink="/about">{{
"HOME.SEC_ABOUT_TITLE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/about')"
>{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
</div>
<div class="about-media">

View File

@@ -45,6 +45,99 @@
animation: fadeUp 0.8s ease both;
}
.hero-grid {
align-items: start;
}
.hero-swiss-card {
--swiss-red: #d52b1e;
align-self: center;
justify-self: center;
width: min(100%, 340px);
padding: 1rem 1.1rem;
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-brand);
border-radius: 12px;
background: #fff;
animation: fadeUp 0.85s ease both;
}
.hero-swiss-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.hero-swiss-emblem {
width: 1.3rem;
height: 1.3rem;
border-radius: 4px;
background: var(--swiss-red);
display: inline-grid;
place-items: center;
}
.hero-swiss-cross {
position: relative;
width: 0.86rem;
height: 0.86rem;
display: block;
}
.hero-swiss-cross::before,
.hero-swiss-cross::after {
content: "";
position: absolute;
background: #fff;
border-radius: 1px;
}
.hero-swiss-cross::before {
width: 0.28rem;
height: 100%;
left: calc(50% - 0.14rem);
top: 0;
}
.hero-swiss-cross::after {
width: 100%;
height: 0.28rem;
left: 0;
top: calc(50% - 0.14rem);
}
.hero-swiss-kicker {
margin: 0;
color: var(--color-text);
}
.hero-swiss-copy {
margin: 0 0 0.7rem;
color: var(--color-text);
font-weight: 500;
line-height: 1.4;
}
.hero-swiss-locations {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.hero-swiss-chip {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0.2rem 0.58rem;
border-radius: 999px;
border: 1px solid rgba(14, 24, 38, 0.14);
background: #fff;
font-size: 0.84rem;
font-weight: 600;
color: #2a2f36;
}
.capabilities {
position: relative;
border-bottom: 1px solid var(--color-border);
@@ -165,6 +258,13 @@
}
@media (max-width: 640px) {
.hero-swiss-card {
align-self: start;
justify-self: center;
width: min(100%, 340px);
margin-top: 1rem;
}
.shop-gallery {
width: 100%;
max-width: none;

View File

@@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { LanguageService } from '../../core/services/language.service';
import {
buildPublicMediaUsageScopeKey,
PublicMediaDisplayImage,
@@ -69,6 +70,7 @@ const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
})
export class HomeComponent {
private readonly publicMediaService = inject(PublicMediaService);
readonly languageService = inject(LanguageService);
private readonly mediaByUsage = toSignal(
this.publicMediaService.getUsageCollections([

View File

@@ -6,9 +6,8 @@ export const LEGAL_ROUTES: Routes = [
loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
data: {
seoTitle: 'Privacy Policy | 3D fab',
seoDescription:
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
},
},
{
@@ -16,9 +15,8 @@ export const LEGAL_ROUTES: Routes = [
loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent),
data: {
seoTitle: 'Termini e condizioni | 3D fab',
seoDescription:
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
seoTitleKey: 'SEO.ROUTES.LEGAL.TERMS.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.TERMS.DESCRIPTION',
},
},
];

View File

@@ -68,9 +68,12 @@
</div>
</app-card>
<div class="payment-layout ui-two-column-layout">
<div class="payment-main">
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
<div
class="payment-layout ui-two-column-layout"
[class.payment-layout--summary-only]="o.status !== 'PENDING_PAYMENT'"
>
<div class="payment-main" *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card class="mb-6">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
</div>
@@ -174,69 +177,6 @@
</app-button>
</div>
</app-card>
<app-card class="mb-6">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
<p class="ui-card-subtitle">
{{ orderKindLabel(o) }}
</p>
</div>
<div class="order-items">
<div class="order-item" *ngFor="let item of o.items || []">
<div class="order-item-copy">
<div class="order-item-name-row">
<strong class="order-item-name">{{
itemDisplayName(item)
}}</strong>
<span
class="order-item-kind"
[class.order-item-kind-shop]="isShopItem(item)"
>
{{
isShopItem(item)
? ("ORDER.TYPE_SHOP" | translate)
: ("ORDER.TYPE_CALCULATOR" | translate)
}}
</span>
</div>
<div class="order-item-meta">
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
}}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
</span>
<span class="item-color-chip">
<span
class="color-swatch"
*ngIf="itemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ itemColorLabel(item) }}</span>
</span>
</div>
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
{{ item.materialGrams || 0 | number: "1.0-0" }}g
</div>
</div>
<strong class="order-item-total">
{{ item.lineTotalChf || 0 | currency: "CHF" }}
</strong>
</div>
</div>
</app-card>
</div>
<div class="payment-summary">
@@ -271,6 +211,72 @@
[currency]="'CHF'"
[totalLabelKey]="'PAYMENT.TOTAL'"
></app-price-breakdown>
<div class="summary-items-section" *ngIf="(o.items || []).length > 0">
<div class="summary-items-head">
<h4>{{ "ORDER.ITEMS_TITLE" | translate }}</h4>
<span>{{ (o.items || []).length }}</span>
</div>
<div class="order-items">
<div class="order-item" *ngFor="let item of o.items || []">
<div class="order-item-copy">
<div class="order-item-name-row">
<strong class="order-item-name">{{
itemDisplayName(item)
}}</strong>
<span
class="order-item-kind"
[class.order-item-kind-shop]="isShopItem(item)"
>
{{
isShopItem(item)
? ("ORDER.TYPE_SHOP" | translate)
: ("ORDER.TYPE_CALCULATOR" | translate)
}}
</span>
</div>
<div class="order-item-meta">
<span
>{{ "CHECKOUT.QTY" | translate }}:
{{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
}}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}:
{{ variantLabel | translate }}
</span>
<span class="item-color-chip">
<span
class="color-swatch"
*ngIf="itemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ itemColorLabel(item) | translate }}</span>
</span>
</div>
<div
class="order-item-tech"
*ngIf="showItemPrintMetrics(item)"
>
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
{{ item.materialGrams || 0 | number: "1.0-0" }}g
</div>
</div>
<strong class="order-item-total">
{{ item.lineTotalChf || 0 | currency: "CHF" }}
</strong>
</div>
</div>
</div>
</app-card>
</div>
</div>

View File

@@ -10,6 +10,11 @@
margin-bottom: var(--space-6);
}
.payment-layout--summary-only {
grid-template-columns: minmax(0, 440px);
justify-content: center;
}
.payment-details {
margin-bottom: var(--space-6);
@@ -119,9 +124,52 @@
top: var(--space-6);
}
.payment-summary {
display: grid;
gap: var(--space-6);
}
.summary-items-section {
margin-top: var(--space-6);
padding-top: var(--space-5);
border-top: 1px solid var(--color-border);
}
.summary-items-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
h4 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(16, 24, 32, 0.06);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 700;
}
}
.order-items {
display: grid;
gap: var(--space-3);
gap: var(--space-2);
max-height: 420px;
overflow-y: auto;
padding-right: var(--space-1);
scrollbar-width: thin;
}
.order-item {
@@ -129,7 +177,7 @@
justify-content: space-between;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-card);
@@ -149,7 +197,7 @@
}
.order-item-name {
font-size: 1rem;
font-size: 0.96rem;
line-height: 1.35;
}
@@ -176,7 +224,7 @@
flex-wrap: wrap;
gap: 0.5rem 0.9rem;
color: var(--color-text-muted);
font-size: 0.92rem;
font-size: 0.88rem;
}
.item-color-chip {
@@ -194,13 +242,13 @@
}
.order-item-tech {
font-size: 0.86rem;
font-size: 0.82rem;
color: var(--color-text-muted);
}
.order-item-total {
white-space: nowrap;
font-size: 1rem;
font-size: 0.96rem;
}
.order-summary-meta {
@@ -325,6 +373,10 @@
padding-top: calc(var(--space-8) + var(--space-4));
}
.payment-layout--summary-only {
grid-template-columns: 1fr;
}
.status-timeline {
margin-top: var(--space-4);
margin-bottom: var(--space-8);
@@ -362,4 +414,10 @@
.order-summary-meta {
grid-template-columns: 1fr;
}
.order-items {
max-height: none;
overflow: visible;
padding-right: 0;
}
}

View File

@@ -6,6 +6,10 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { environment } from '../../../environments/environment';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
import {
PriceBreakdownComponent,
PriceBreakdownRow,
@@ -25,9 +29,17 @@ interface PublicOrderItem {
shopProductName?: string;
shopVariantLabel?: string;
shopVariantColorName?: string;
shopVariantColorLabelIt?: string;
shopVariantColorLabelEn?: string;
shopVariantColorLabelDe?: string;
shopVariantColorLabelFr?: string;
shopVariantColorHex?: string;
filamentVariantDisplayName?: string;
filamentColorName?: string;
filamentColorLabelIt?: string;
filamentColorLabelEn?: string;
filamentColorLabelDe?: string;
filamentColorLabelFr?: string;
filamentColorHex?: string;
quality?: string;
nozzleDiameterMm?: number;
@@ -233,7 +245,9 @@ export class OrderComponent implements OnInit {
amount: order?.subtotalChf ?? 0,
},
{
label: `Servizio CAD (${order?.cadHours || 0}h)`,
label: this.translate.instant('ORDER.CAD_SERVICE', {
hours: order?.cadHours || 0,
}),
amount: order?.cadTotalChf ?? 0,
visible: (order?.cadTotalChf ?? 0) > 0,
},
@@ -278,23 +292,16 @@ export class OrderComponent implements OnInit {
return variantLabel;
}
const colorName = String(item?.shopVariantColorName ?? '').trim();
return colorName || null;
return this.localizedColorLabel(item, 'shop');
}
itemColorLabel(item: PublicOrderItem): string {
const shopColor = String(item?.shopVariantColorName ?? '').trim();
if (shopColor) {
return shopColor;
}
const filamentColor = String(item?.filamentColorName ?? '').trim();
if (filamentColor) {
return filamentColor;
}
const rawColor = String(item?.colorCode ?? '').trim();
return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE');
return (
this.localizedColorLabel(item, 'shop') ||
this.localizedColorLabel(item, 'filament') ||
String(item?.colorCode ?? '').trim() ||
this.translate.instant('ORDER.NOT_AVAILABLE')
);
}
itemColorHex(item: PublicOrderItem): string | null {
@@ -313,7 +320,7 @@ export class OrderComponent implements OnInit {
return rawColor;
}
return null;
return findColorHex(rawColor);
}
showItemMaterial(item: PublicOrderItem): boolean {
@@ -324,6 +331,29 @@ export class OrderComponent implements OnInit {
return !this.isShopItem(item);
}
private localizedColorLabel(
item: PublicOrderItem,
source: 'shop' | 'filament',
): string | null {
if (source === 'shop') {
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.shopVariantColorName,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
});
}
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.filamentColorName ?? item.colorCode,
it: item.filamentColorLabelIt,
en: item.filamentColorLabelEn,
de: item.filamentColorLabelDe,
fr: item.filamentColorLabelFr,
});
}
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
const items = order?.items ?? [];
const hasShop = items.some((item) => this.isShopItem(item));

View File

@@ -1,5 +1,10 @@
<article class="product-card">
<a class="media" [routerLink]="productLink()" [state]="navigationState()">
<a
class="media"
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
>
@if (imageUrl(); as imageUrl) {
<img
[src]="imageUrl"
@@ -32,9 +37,12 @@
</div>
<h3 class="name">
<a [routerLink]="productLink()" [state]="navigationState()">{{
product().name
}}</a>
<a
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
>{{ product().name }}</a
>
</h3>
<p class="excerpt">
@@ -62,6 +70,7 @@
<a
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
class="view-btn"
>{{ "SHOP.DETAILS" | translate }}</a
>

View File

@@ -21,7 +21,7 @@
.media {
position: relative;
display: block;
aspect-ratio: 1 / 1;
aspect-ratio: 4 / 3;
background: #f2eee5;
overflow: hidden;
}

View File

@@ -74,4 +74,16 @@ export class ProductCardComponent {
shopReturnUrl: this.router.url,
};
}
rememberCatalogScroll(): void {
if (typeof window === 'undefined') {
return;
}
const nextState = {
...(history.state ?? {}),
shopRestoreScrollY: Math.max(0, Math.round(window.scrollY)),
};
history.replaceState(nextState, '');
}
}

View File

@@ -4,7 +4,7 @@
← {{ "SHOP.BACK" | translate }}
</button>
@if (loading()) {
@if (loading() || softFallbackActive()) {
<div class="detail-grid skeleton-grid">
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>
@@ -15,12 +15,16 @@
} @else {
@if (product(); as p) {
<nav class="breadcrumbs">
<a [routerLink]="shopRootLink()">{{
<a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
"SHOP.BREADCRUMB_ROOT" | translate
}}</a>
@for (crumb of p.breadcrumbs; track crumb.id) {
<span>/</span>
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a>
<span class="breadcrumbs__separator">/</span>
<a
class="breadcrumbs__item"
[routerLink]="categoryLink(crumb.slug)"
>{{ crumb.name }}</a
>
}
</nav>
@@ -129,13 +133,32 @@
<app-card class="purchase-shell">
<div class="purchase-card">
<div class="price-row">
<div>
<div class="offer-header">
<div class="offer-price">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
{{ "SHOP.PRICE_LABEL" | translate }}
</p>
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
@if (selectedVariant(); as activeVariant) {
<p class="offer-caption">
@if (selectedMaterial()?.label) {
<span>{{ selectedMaterial()?.label }}</span>
}
@if (
colorLabel(activeVariant) !==
selectedMaterial()?.label
) {
@if (selectedMaterial()?.label) {
<span aria-hidden="true">·</span>
}
<span>{{
colorLabel(activeVariant) | translate
}}</span>
}
</p>
}
</div>
@if (selectedVariantCartQuantity() > 0) {
<span class="cart-pill">
{{
@@ -148,32 +171,61 @@
</div>
@if (materialOptions().length > 1) {
<div class="material-grid">
@for (material of materialOptions(); track material.key) {
<button
type="button"
class="material-option"
[class.active]="
selectedMaterial()?.key === material.key
"
(click)="selectMaterial(material.key)"
>
<span class="material-copy">
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</span>
<strong>{{
materialPriceLabel(material) | currency: "CHF"
}}</strong>
</button>
}
<div class="material-section">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
</p>
</div>
<div class="material-grid">
@for (
material of materialOptions();
track material.key
) {
<button
type="button"
class="material-option"
[class.active]="
selectedMaterial()?.key === material.key
"
(click)="selectMaterial(material.key)"
>
<span class="material-copy">
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</span>
<strong>{{
materialPriceLabel(material) | currency: "CHF"
}}</strong>
</button>
}
</div>
</div>
} @else {
@if (selectedMaterial(); as material) {
<div class="material-summary">
<div class="material-summary__copy">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
</p>
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</div>
</div>
}
}
@if (
@@ -196,90 +248,97 @@
</div>
}
<div class="color-selector-block">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }}
</p>
</div>
<div class="selector-layout">
<div class="selector-card color-selector-block">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }}
</p>
</div>
@if (selectedVariant(); as activeVariant) {
<button
type="button"
class="color-trigger"
[class.open]="colorPopupOpen()"
(click)="toggleColorPopup()"
>
<span class="color-trigger__ring">
<span
class="color-trigger__swatch"
[style.background-color]="colorHex(activeVariant)"
></span>
</span>
@if (selectedVariant(); as activeVariant) {
<button
type="button"
class="color-trigger"
[class.open]="colorPopupOpen()"
(click)="toggleColorPopup()"
>
<span class="color-trigger__ring">
<span
class="color-trigger__swatch"
[style.background-color]="colorHex(activeVariant)"
></span>
</span>
<span class="color-trigger__copy">
<strong>{{ colorLabel(activeVariant) }}</strong>
<small>{{ selectedMaterial()?.label }}</small>
</span>
</button>
}
<span class="color-trigger__copy">
<strong>{{
colorLabel(activeVariant) | translate
}}</strong>
<small>{{ selectedMaterial()?.label }}</small>
</span>
</button>
}
@if (colorPopupOpen()) {
<button
type="button"
class="color-popup-backdrop"
(click)="closeColorPopup()"
></button>
<div class="color-popup">
<div class="color-popup__category">
{{ selectedMaterial()?.label || "" | uppercase }}
</div>
@if (colorPopupOpen()) {
<button
type="button"
class="color-popup-backdrop"
(click)="closeColorPopup()"
></button>
<div class="color-popup">
<div class="color-popup__category">
{{ selectedMaterial()?.label || "" | uppercase }}
</div>
<div class="color-popup__grid">
@for (variant of colorOptions(); track variant.id) {
<button
type="button"
class="color-popup__item"
(click)="selectVariant(variant)"
>
<span
class="color-popup__ring"
[class.active]="
selectedVariant()?.id === variant.id
"
<div class="color-popup__grid">
@for (variant of colorOptions(); track variant.id) {
<button
type="button"
class="color-popup__item"
(click)="selectVariant(variant)"
>
<span
class="color-popup__swatch"
[style.background-color]="colorHex(variant)"
></span>
</span>
class="color-popup__ring"
[class.active]="
selectedVariant()?.id === variant.id
"
>
<span
class="color-popup__swatch"
[style.background-color]="colorHex(variant)"
></span>
</span>
<span class="color-popup__name">{{
colorLabel(variant)
}}</span>
</button>
}
<span class="color-popup__name">{{
colorLabel(variant) | translate
}}</span>
</button>
}
</div>
</div>
</div>
}
</div>
}
</div>
<div class="quantity-row">
<span>{{ "SHOP.QUANTITY" | translate }}</span>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">
-
</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="increaseQuantity()">
+
</button>
<div class="selector-card quantity-card">
<p class="panel-kicker">
{{ "SHOP.QUANTITY" | translate }}
</p>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">
-
</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="increaseQuantity()">
+
</button>
</div>
</div>
</div>
<div class="actions">
<app-button
variant="primary"
[fullWidth]="true"
[disabled]="isAddingToCart()"
(click)="addToCart()"
>
@@ -290,7 +349,11 @@
</app-button>
@if (shopService.cartItemCount() > 0) {
<app-button variant="outline" (click)="goToCheckout()">
<app-button
variant="outline"
[fullWidth]="true"
(click)="goToCheckout()"
>
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button>
}

View File

@@ -18,15 +18,51 @@
border: 0;
background: none;
font: inherit;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
cursor: pointer;
text-align: left;
}
.back-link:hover {
color: var(--color-text);
}
.breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
font-size: 0.9rem;
align-items: center;
gap: 0.35rem;
font-size: 0.82rem;
}
.breadcrumbs__item {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.26rem 0.7rem;
border-radius: 999px;
border: 1px solid rgba(16, 24, 32, 0.1);
background: rgba(255, 255, 255, 0.92);
color: var(--color-secondary-600);
font-weight: 600;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
.breadcrumbs__item:hover {
color: var(--color-text);
border-color: rgba(16, 24, 32, 0.18);
background: #fff;
text-decoration: none;
}
.breadcrumbs__separator {
color: rgba(81, 77, 67, 0.64);
font-weight: 700;
}
.detail-grid {
@@ -53,9 +89,8 @@
.hero-media {
position: relative;
aspect-ratio: 1 / 1;
min-height: 420px;
max-height: 620px;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 1.25rem;
border: 1px solid rgba(16, 24, 32, 0.12);
@@ -67,14 +102,14 @@
width: 100%;
height: 100%;
display: block;
object-fit: contain;
object-fit: cover;
object-position: center;
background: #f2eee5;
}
.image-fallback {
width: 100%;
height: 100%;
min-height: 420px;
display: flex;
align-items: flex-end;
padding: var(--space-6);
@@ -111,8 +146,8 @@
}
.thumb {
flex: 0 0 92px;
height: 92px;
flex: 0 0 96px;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 0.85rem;
border: 1px solid rgba(16, 24, 32, 0.12);
@@ -226,15 +261,34 @@ h1 {
.purchase-card {
display: grid;
gap: 0.78rem;
gap: 1rem;
}
.price-row,
.quantity-row {
.offer-header {
display: flex;
justify-content: space-between;
gap: var(--space-4);
align-items: start;
}
.offer-price {
display: grid;
gap: 0.12rem;
}
.offer-price h3 {
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
line-height: 1;
}
.offer-caption {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.38rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.cart-pill {
@@ -249,6 +303,11 @@ h1 {
font-weight: 600;
}
.material-section {
display: grid;
gap: 0.65rem;
}
.material-grid {
display: grid;
gap: 0.55rem;
@@ -301,6 +360,31 @@ h1 {
font-size: 1.04rem;
}
.material-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: #fff;
}
.material-summary__copy {
display: grid;
gap: 0.16rem;
}
.material-summary__copy strong {
font-size: 1rem;
}
.material-summary__copy small {
color: var(--color-text-muted);
font-size: 0.84rem;
}
.property-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -340,11 +424,30 @@ h1 {
border-left: 3px solid rgba(245, 158, 11, 0.7);
}
.selector-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
gap: 0.75rem;
align-items: stretch;
}
.selector-card {
position: relative;
display: grid;
gap: 0.5rem;
padding: 0.82rem 0.9rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: rgba(255, 255, 255, 0.9);
height: 100%;
}
.qty-control {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.2rem;
min-height: 3.2rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.82);
@@ -366,10 +469,20 @@ h1 {
font-weight: 700;
}
.quantity-card {
justify-items: start;
align-content: start;
grid-template-rows: auto 1fr;
}
.quantity-card .qty-control {
align-self: center;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.success-note {
@@ -459,6 +572,10 @@ h1 {
grid-template-columns: 1fr;
}
.selector-layout {
grid-template-columns: 1fr;
}
.property-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -469,15 +586,14 @@ h1 {
}
@media (max-width: 640px) {
.price-row,
.quantity-row {
.offer-header,
.material-summary {
flex-direction: column;
align-items: start;
}
.hero-media,
.image-fallback {
min-height: 300px;
.hero-media--portrait .hero-image {
object-fit: cover;
}
.thumb-strip {
@@ -485,8 +601,7 @@ h1 {
}
.thumb {
flex-basis: 78px;
height: 78px;
flex-basis: 84px;
}
.model-launch-row {
@@ -514,6 +629,10 @@ h1 {
grid-template-columns: 1fr;
}
.selector-card {
padding: 0.74rem 0.78rem;
}
:host ::ng-deep app-card.purchase-shell .card-body {
padding: 0.82rem 0.82rem;
}

View File

@@ -0,0 +1,237 @@
import { Location } from '@angular/common';
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import { ShopRouteService } from './services/shop-route.service';
import { ShopProductDetail, ShopService } from './services/shop.service';
import { ProductDetailComponent } from './product-detail.component';
describe('ProductDetailComponent', () => {
function buildProduct(
overrides: Partial<ShopProductDetail> = {},
): ShopProductDetail {
return {
id: '91823f84-1111-2222-3333-444444444444',
slug: 'bike-wall-hanger',
name: 'Bike Wall-Hanger',
excerpt: 'Wall mount for bicycles',
description: '<p>Wall mount for bicycles</p>',
seoTitle: null,
seoDescription: null,
ogTitle: null,
ogDescription: null,
indexable: true,
isFeatured: false,
sortOrder: 0,
category: {
id: 'category-1',
slug: 'bike-accessories',
name: 'Bike Accessories',
},
breadcrumbs: [],
priceFromChf: 29.9,
priceToChf: 29.9,
defaultVariant: {
id: 'variant-1',
sku: 'BW-1',
variantLabel: 'PLA',
colorName: 'Black',
colorLabel: 'Black',
colorHex: '#111111',
priceChf: 29.9,
isDefault: true,
},
variants: [
{
id: 'variant-1',
sku: 'BW-1',
variantLabel: 'PLA',
colorName: 'Black',
colorLabel: 'Black',
colorHex: '#111111',
priceChf: 29.9,
isDefault: true,
},
],
primaryImage: null,
images: [],
model3d: null,
publicPath: '91823f84-bike-wall-hanger',
localizedPaths: {
it: '/it/shop/p/91823f84-supporto-bici-muro',
en: '/en/shop/p/91823f84-bike-wall-hanger',
de: '/de/shop/p/91823f84-bike-wall-hanger',
fr: '/fr/shop/p/91823f84-support-mural-velo',
},
...overrides,
};
}
function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') {
const responseInit: { status?: number } = {};
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
'applyResolvedSeo',
'applyPageSeo',
]);
const translate = jasmine.createSpyObj<TranslateService>(
'TranslateService',
['instant'],
);
translate.instant.and.callFake((key: string) => {
const translations: Record<string, string> = {
'SHOP.TITLE': 'Technische Lösungen',
'SHOP.CATALOG_META_DESCRIPTION':
'Entdecken Sie technische 3D-Druck-Lösungen.',
'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab',
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION':
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
};
return translations[key] ?? key;
});
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
const languageService = {
currentLang,
selectedLang: () => currentLang(),
setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'),
clearLocalizedRouteOverrides: jasmine.createSpy(
'clearLocalizedRouteOverrides',
),
};
const shopService = {
cartLoaded: signal(false),
cartLoading: signal(false),
getProductByPublicPath: jasmine
.createSpy('getProductByPublicPath')
.and.returnValue(of(buildProduct())),
quantityForVariant: jasmine
.createSpy('quantityForVariant')
.and.returnValue(0),
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null),
};
const router = {
url: routerUrl,
navigate: jasmine.createSpy('navigate'),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
parseUrl: jasmine.createSpy('parseUrl'),
createUrlTree: jasmine.createSpy('createUrlTree'),
serializeUrl: jasmine.createSpy('serializeUrl'),
} as unknown as Router;
const activatedRoute = {
paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })),
snapshot: {
paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
},
} as unknown as ActivatedRoute;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [ProductDetailComponent],
providers: [
{ provide: SeoService, useValue: seoService },
{ provide: TranslateService, useValue: translate },
{ provide: LanguageService, useValue: languageService },
{ provide: ShopService, useValue: shopService },
{
provide: ShopRouteService,
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
'shopRootCommands',
'productPathSegment',
'isCatalogUrl',
]),
},
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: activatedRoute },
{
provide: Location,
useValue: jasmine.createSpyObj<Location>('Location', ['back']),
},
{ provide: RESPONSE_INIT, useValue: responseInit },
{ provide: PLATFORM_ID, useValue: 'server' },
],
});
const fixture: ComponentFixture<ProductDetailComponent> =
TestBed.createComponent(ProductDetailComponent);
return {
component: fixture.componentInstance,
seoService,
responseInit,
};
}
it('applies index follow SEO for indexable products', () => {
const { component, seoService } = createComponent();
(component as any).applySeo(buildProduct());
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
title: 'Bike Wall-Hanger | 3D fab',
robots: 'index, follow',
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
alternates: buildProduct().localizedPaths,
xDefault: '/it/shop/p/91823f84-supporto-bici-muro',
}),
);
});
it('applies noindex for products explicitly marked as non-indexable', () => {
const { component, seoService } = createComponent();
(component as any).applySeo(buildProduct({ indexable: false }));
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
robots: 'noindex, nofollow',
}),
);
});
it('builds a soft SSR fallback with 200 + index follow', () => {
const { component, seoService, responseInit } = createComponent();
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
(component as any).setResponseStatus(200);
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
expect(responseInit.status).toBe(200);
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
title: 'Bike Wall Hanger | 3D fab',
description:
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
robots: 'index, follow',
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
alternates: null,
xDefault: null,
}),
);
});
it('keeps hard fallback noindex for missing products', () => {
const { component, seoService, responseInit } = createComponent();
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
(component as any).setResponseStatus(404);
(component as any).applyHardFallbackSeo();
expect(responseInit.status).toBe(404);
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
robots: 'noindex, nofollow',
alternates: null,
xDefault: null,
}),
);
});
});

View File

@@ -1,21 +1,31 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
import {
RESPONSE_INIT,
afterNextRender,
Component,
DestroyRef,
Injector,
PLATFORM_ID,
computed,
inject,
input,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
import {
catchError,
combineLatest,
distinctUntilChanged,
finalize,
map,
of,
switchMap,
tap,
} from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import { getColorHex } from '../../core/constants/colors.const';
import { findColorHex } from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -25,6 +35,7 @@ import {
ShopService,
} from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
interface ShopMaterialOption {
key: string;
@@ -58,18 +69,23 @@ export class ProductDetailComponent {
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>();
readonly productSlug = input<string | undefined>();
readonly routeCategorySlug = signal<string | null>(
this.readRouteParam('categorySlug'),
);
readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly error = signal<string | null>(null);
readonly product = signal<ShopProductDetail | null>(null);
readonly selectedVariantId = signal<string | null>(null);
@@ -193,53 +209,82 @@ export class ProductDetailComponent {
);
constructor() {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
afterNextRender(() => {
this.scheduleCartWarmup();
});
this.destroyRef.onDestroy(() => {
this.languageService.clearLocalizedRouteOverrides();
});
combineLatest([
toObservable(this.productSlug, { injector: this.injector }),
this.route.paramMap.pipe(
map((params) => ({
categorySlug: this.normalizeRouteParam(params.get('categorySlug')),
productSlug: this.normalizeRouteParam(params.get('productSlug')),
})),
distinctUntilChanged(
(previous, current) =>
previous.categorySlug === current.categorySlug &&
previous.productSlug === current.productSlug,
),
),
toObservable(this.languageService.currentLang, {
injector: this.injector,
}),
}).pipe(distinctUntilChanged()),
])
.pipe(
tap(() => {
this.loading.set(true);
this.softFallbackActive.set(false);
this.error.set(null);
this.addSuccess.set(false);
this.modelError.set(false);
this.colorPopupOpen.set(false);
this.modelModalOpen.set(false);
}),
switchMap(([productSlug]) => {
if (!productSlug) {
switchMap(([routeParams]) => {
this.routeCategorySlug.set(routeParams.categorySlug);
if (!routeParams.productSlug) {
this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
this.loading.set(false);
return of(null);
}
return this.shopService.getProductByPublicPath(productSlug).pipe(
catchError((error) => {
this.product.set(null);
this.selectedVariantId.set(null);
this.selectedImageAssetId.set(null);
this.modelFile.set(null);
this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
);
this.applyFallbackSeo();
return of(null);
}),
finalize(() => this.loading.set(false)),
);
const productSlug = routeParams.productSlug as string;
return this.shopService
.getProductByPublicPath(productSlug)
.pipe(
catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null);
this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null);
this.modelFile.set(null);
const isNotFound = error?.status === 404;
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
return of(null);
}
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.setResponseStatus(200);
this.applySoftFallbackSeo(productSlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null);
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
@@ -249,6 +294,7 @@ export class ProductDetailComponent {
}
this.product.set(product);
this.softFallbackActive.set(false);
this.selectedVariantId.set(
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
);
@@ -257,12 +303,13 @@ export class ProductDetailComponent {
product.defaultVariant ?? product.variants[0] ?? null,
),
);
this.selectedImageAssetId.set(
this.setSelectedImageAssetId(
product.primaryImage?.mediaAssetId ??
product.images[0]?.mediaAssetId ??
null,
);
this.quantity.set(1);
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
this.syncPublicUrl(product);
this.applySeo(product);
this.modelFile.set(null);
@@ -282,8 +329,47 @@ export class ProductDetailComponent {
);
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
selectImage(mediaAssetId: string): void {
this.selectedImageAssetId.set(mediaAssetId);
this.setSelectedImageAssetId(mediaAssetId);
}
showPreviousImage(): void {
@@ -293,7 +379,7 @@ export class ProductDetailComponent {
}
const nextIndex =
(this.selectedImageIndex() - 1 + images.length) % images.length;
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
}
showNextImage(): void {
@@ -302,7 +388,7 @@ export class ProductDetailComponent {
return;
}
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
}
selectVariant(variant: ShopProductVariantOption): void {
@@ -366,9 +452,12 @@ export class ProductDetailComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
queryParams: { session: sessionId },
});
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: { session: sessionId },
},
);
}
priceLabel(): number {
@@ -378,7 +467,9 @@ export class ProductDetailComponent {
}
colorLabel(variant: ShopProductVariantOption): string {
return variant.colorName || variant.variantLabel || '-';
return (
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
);
}
colorHex(variant: ShopProductVariantOption | null | undefined): string {
@@ -440,7 +531,8 @@ export class ProductDetailComponent {
}
productLinkRoot(): string[] {
const categorySlug = this.product()?.category.slug || this.categorySlug();
const categorySlug =
this.product()?.category.slug || this.routeCategorySlug();
return this.shopRouteService.shopRootCommands(categorySlug);
}
@@ -451,6 +543,11 @@ export class ProductDetailComponent {
: null;
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
if (this.isBrowser && window.history.length > 1) {
this.location.back();
return;
}
void this.router.navigateByUrl(returnUrl);
return;
}
@@ -479,6 +576,10 @@ export class ProductDetailComponent {
});
}
private setSelectedImageAssetId(mediaAssetId: string | null): void {
this.selectedImageAssetId.set(mediaAssetId);
}
private normalizeHexColor(value: string | null | undefined): string | null {
const raw = String(value ?? '').trim();
if (!raw) {
@@ -494,17 +595,7 @@ export class ProductDetailComponent {
}
private colorHexFromName(value: string | null | undefined): string | null {
const colorName = String(value ?? '').trim();
if (!colorName) {
return null;
}
const fallback = getColorHex(colorName);
if (!fallback || fallback === '#facf0a') {
return null;
}
return fallback;
return findColorHex(value);
}
private applySeo(product: ShopProductDetail): void {
@@ -516,28 +607,86 @@ export class ProductDetailComponent {
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
const lang = this.languageService.selectedLang();
const canonicalPath =
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
this.seoService.applyPageSeo({
this.seoService.applyResolvedSeo({
title,
description,
robots,
ogTitle: product.ogTitle || title,
ogDescription: product.ogDescription || description,
canonicalPath,
alternates: product.localizedPaths,
xDefault: product.localizedPaths?.it ?? canonicalPath,
});
}
private applyFallbackSeo(): void {
private applyHardFallbackSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyPageSeo({
this.seoService.applyResolvedSeo({
title,
description,
robots: 'noindex, nofollow',
ogTitle: title,
ogDescription: description,
canonicalPath: null,
alternates: null,
xDefault: null,
});
}
private applySoftFallbackSeo(productSlug: string): void {
const title = this.buildSoftFallbackTitle(productSlug);
const description = this.resolveTranslatedText(
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
ogTitle: title,
ogDescription: description,
canonicalPath: this.currentPath(),
alternates: null,
xDefault: null,
});
}
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
return !this.isBrowser && error?.status !== 404;
}
private buildSoftFallbackTitle(productSlug: string): string {
const humanized = humanizeShopSlug(productSlug, {
stripProductIdPrefix: true,
});
if (humanized) {
return `${humanized} | 3D fab`;
}
return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.PRODUCT_TITLE',
`${this.translate.instant('SHOP.TITLE')} | 3D fab`,
);
}
private resolveTranslatedText(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: fallback;
}
private currentPath(): string {
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
return path.startsWith('/') ? path : `/${path}`;
}
private materialLabelForVariant(
variant: ShopProductVariantOption | null,
): string {
@@ -709,21 +858,23 @@ export class ProductDetailComponent {
return;
}
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
const targetProductSlug = this.shopRouteService.productPathSegment(product);
if (currentProductSlug === targetProductSlug) {
const currentTree = this.router.parseUrl(this.router.url);
const lang = this.languageService.selectedLang();
const targetPath =
product.localizedPaths?.[lang] ??
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
const normalizedTargetPath = targetPath.startsWith('/')
? targetPath
: `/${targetPath}`;
const currentPath = this.router
.serializeUrl(currentTree)
.split(/[?#]/, 1)[0];
if (currentPath === normalizedTargetPath) {
return;
}
const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree(
[
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
['/', ...normalizedTargetPath.split('/').filter(Boolean)],
{
queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined,
@@ -742,4 +893,21 @@ export class ProductDetailComponent {
state: history.state,
});
}
private setResponseStatus(status: number): void {
if (this.responseInit) {
this.responseInit.status = status;
}
}
private readRouteParam(name: string): string | null {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}
}

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@angular/core';
import { LanguageService } from '../../../core/services/language.service';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export interface ShopProductRouteRef {
id: string | null | undefined;
name: string | null | undefined;
slug?: string | null | undefined;
}
export interface ShopProductLookup {
idPrefix: string | null;
slugHint: string | null;
publicPath?: string | null | undefined;
localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined;
}
@Injectable({
@@ -26,11 +25,21 @@ export class ShopRouteService {
}
productCommands(product: ShopProductRouteRef): string[] {
const localizedPath = this.localizedProductPath(product);
if (localizedPath) {
return ['/', ...localizedPath.split('/').filter(Boolean)];
}
const lang = this.languageService.currentLang();
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
}
productPathSegment(product: ShopProductRouteRef): string {
const publicPath = String(product.publicPath ?? '').trim();
if (publicPath) {
return publicPath;
}
const idPrefix = this.productIdPrefix(product.id);
const tail =
this.slugify(product.name) || this.slugify(product.slug) || 'product';
@@ -38,41 +47,6 @@ export class ShopRouteService {
return idPrefix ? `${idPrefix}-${tail}` : tail;
}
resolveProductLookup(
productPathSegment: string | null | undefined,
): ShopProductLookup {
const normalized = String(productPathSegment ?? '')
.trim()
.toLowerCase();
if (!normalized) {
return {
idPrefix: null,
slugHint: null,
};
}
const bareUuidMatch = normalized.match(/^([0-9a-f]{8})$/);
if (bareUuidMatch) {
return {
idPrefix: bareUuidMatch[1],
slugHint: null,
};
}
const publicSlugMatch = normalized.match(/^([0-9a-f]{8})-(.+)$/);
if (publicSlugMatch) {
return {
idPrefix: publicSlugMatch[1],
slugHint: this.slugify(publicSlugMatch[2]) || null,
};
}
return {
idPrefix: null,
slugHint: normalized,
};
}
isCatalogUrl(url: string | null | undefined): boolean {
if (!url) {
return false;
@@ -92,6 +66,12 @@ export class ShopRouteService {
.replace(/-{2,}/g, '-');
}
private localizedProductPath(product: ShopProductRouteRef): string | null {
const lang = this.languageService.currentLang();
const localizedPath = String(product.localizedPaths?.[lang] ?? '').trim();
return localizedPath.startsWith('/') ? localizedPath : null;
}
private productIdPrefix(productId: string | null | undefined): string {
const normalized = String(productId ?? '')
.trim()

View File

@@ -5,7 +5,6 @@ import {
} from '@angular/common/http/testing';
import {
ShopCartResponse,
ShopProductCatalogResponse,
ShopProductDetail,
ShopService,
} from './shop.service';
@@ -90,32 +89,6 @@ describe('ShopService', () => {
grandTotalChf: 36.8,
});
const buildCatalog = (): ShopProductCatalogResponse => ({
categorySlug: null,
featuredOnly: false,
category: null,
products: [
{
id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip',
name: 'Supporto cavo scrivania',
excerpt: 'Accessorio tecnico',
isFeatured: true,
sortOrder: 0,
category: {
id: 'category-1',
slug: 'accessori',
name: 'Accessori',
},
priceFromChf: 9.9,
priceToChf: 12.5,
defaultVariant: null,
primaryImage: null,
model3d: null,
},
],
});
const buildProduct = (): ShopProductDetail => ({
id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip',
@@ -142,6 +115,13 @@ describe('ShopService', () => {
primaryImage: null,
images: [],
model3d: null,
publicPath: '12345678-supporto-cavo-scrivania',
localizedPaths: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
fr: '/fr/shop/p/12345678-support-cable-bureau',
},
});
beforeEach(() => {
@@ -212,86 +192,61 @@ describe('ShopService', () => {
response = product;
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
request.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
expect(response?.name).toBe('Supporto cavo scrivania');
});
it('resolves product detail from uuid prefix even when slug tail does not match', () => {
let response: ShopProductDetail | undefined;
it('rejects product paths whose slug tail does not match the canonical path', () => {
let errorResponse: { status?: number } | undefined;
service
.getProductByPublicPath('12345678-qualunque-nome')
.subscribe((product) => {
response = product;
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
next: () => fail('Expected canonical path mismatch to return 404'),
error: (error) => {
errorResponse = error;
},
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
request.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
});
it('resolves product detail from bare uuid prefix without slug tail', () => {
let response: ShopProductDetail | undefined;
it('rejects bare uuid product paths without the localized slug tail', () => {
let errorResponse: { status?: number } | undefined;
service.getProductByPublicPath('12345678').subscribe((product) => {
response = product;
service.getProductByPublicPath('12345678').subscribe({
next: () => fail('Expected bare uuid path to return 404'),
error: (error) => {
errorResponse = error;
},
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
'http://localhost:8000/api/shop/products/by-path/12345678' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
request.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
});
});

View File

@@ -1,13 +1,15 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable, switchMap, tap, throwError } from 'rxjs';
import { map, Observable, tap, throwError } from 'rxjs';
import { environment } from '../../../../environments/environment';
import {
PublicMediaUsageDto,
PublicMediaVariantDto,
} from '../../../core/services/public-media.service';
import { LanguageService } from '../../../core/services/language.service';
import { ShopRouteService } from './shop-route.service';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedPathMap = Partial<Record<SupportedLang, string>>;
export interface ShopCategoryRef {
id: string;
@@ -55,6 +57,7 @@ export interface ShopProductVariantOption {
sku: string | null;
variantLabel: string | null;
colorName: string | null;
colorLabel: string | null;
colorHex: string | null;
priceChf: number;
isDefault: boolean;
@@ -83,6 +86,8 @@ export interface ShopProductSummary {
defaultVariant: ShopProductVariantOption | null;
primaryImage: PublicMediaUsageDto | null;
model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
}
export interface ShopProductDetail {
@@ -107,6 +112,8 @@ export interface ShopProductDetail {
primaryImage: PublicMediaUsageDto | null;
images: PublicMediaUsageDto[];
model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
}
export interface ShopProductCatalogResponse {
@@ -138,6 +145,10 @@ export interface ShopCartItem {
shopProductName: string | null;
shopVariantLabel: string | null;
shopVariantColorName: string | null;
shopVariantColorLabelIt?: string | null;
shopVariantColorLabelEn?: string | null;
shopVariantColorLabelDe?: string | null;
shopVariantColorLabelFr?: string | null;
shopVariantColorHex: string | null;
materialCode: string | null;
quality: string | null;
@@ -180,7 +191,6 @@ export interface ShopCategoryNavNode {
export class ShopService {
private readonly http = inject(HttpClient);
private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
readonly cart = signal<ShopCartResponse | null>(null);
@@ -273,29 +283,27 @@ export class ShopService {
getProductByPublicPath(
productPathSegment: string,
): Observable<ShopProductDetail> {
const lookup =
this.shopRouteService.resolveProductLookup(productPathSegment);
if (!lookup.idPrefix && lookup.slugHint) {
return this.getProduct(lookup.slugHint);
const normalizedPath = this.normalizePublicPath(productPathSegment);
if (!normalizedPath) {
return throwError(() => ({
status: 404,
}));
}
return this.getProductCatalog().pipe(
map((catalog) =>
catalog.products.find((product) =>
product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
),
),
switchMap((product) => {
if (!product) {
return throwError(() => ({
status: 404,
}));
}
return this.getProduct(product.slug);
}),
return this.http.get<ShopProductDetail>(
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
{
params: this.buildLangParams(),
},
);
}
private normalizePublicPath(value: string | null | undefined): string {
return String(value ?? '')
.trim()
.toLowerCase();
}
loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true);
return this.http

View File

@@ -1,15 +1,7 @@
<section class="shop-page">
<div class="container ui-simple-hero shop-hero">
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
<p class="ui-simple-hero__subtitle">
{{
selectedCategory()
? selectedCategory()?.description ||
("SHOP.CATEGORY_META"
| translate: { count: selectedCategory()?.productCount || 0 })
: ("SHOP.SUBTITLE" | translate)
}}
</p>
<p class="ui-simple-hero__subtitle">{{ heroSubtitle() }}</p>
</div>
<div class="container shop-layout">
@@ -84,7 +76,9 @@
<div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant }}</span>
<span class="cart-line-meta">{{
variant | translate
}}</span>
}
@if (cartItemColor(item); as color) {
<span class="cart-line-color">
@@ -92,7 +86,7 @@
class="color-dot"
[style.background-color]="cartItemColorHex(item)"
></span>
<span>{{ color }}</span>
<span>{{ color | translate }}</span>
</span>
}
</div>
@@ -179,17 +173,9 @@
<div class="section-head catalog-head">
<div>
<p class="ui-eyebrow ui-eyebrow--compact">
{{
selectedCategory()
? ("SHOP.SELECTED_CATEGORY" | translate)
: ("SHOP.CATALOG_LABEL" | translate)
}}
{{ catalogEyebrow() }}
</p>
<h2 class="section-title">
{{
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
}}
</h2>
<h2 class="section-title">{{ catalogTitle() }}</h2>
</div>
<span class="catalog-counter">
{{ products().length }}
@@ -197,7 +183,7 @@
</span>
</div>
@if (loading()) {
@if (loading() || softFallbackActive()) {
<div class="product-grid skeleton-grid">
@for (ghost of [1, 2, 3, 4]; track ghost) {
<div class="skeleton-card"></div>
@@ -237,7 +223,10 @@
</h2>
</div>
<app-button variant="primary" routerLink="/contact">
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/contact')"
>
{{ "NAV.CONTACT" | translate }}
</app-button>
</div>

View File

@@ -332,6 +332,10 @@
}
@media (max-width: 760px) {
.cart-card {
display: none;
}
.product-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,219 @@
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import {
ShopCategoryDetail,
ShopCategoryTree,
ShopProductCatalogResponse,
ShopService,
} from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
import { ShopPageComponent } from './shop-page.component';
describe('ShopPageComponent', () => {
function buildCategory(
overrides: Partial<ShopCategoryDetail> = {},
): ShopCategoryDetail {
return {
id: 'cat-1',
slug: 'compatible-with-garmin',
name: 'Compatible with Garmin',
description: 'Accessories compatible with Garmin devices.',
seoTitle: null,
seoDescription: null,
ogTitle: null,
ogDescription: null,
indexable: true,
sortOrder: 0,
productCount: 3,
breadcrumbs: [],
primaryImage: null,
images: [],
children: [],
...overrides,
};
}
function buildCatalog(
overrides: Partial<ShopProductCatalogResponse> = {},
): ShopProductCatalogResponse {
return {
categorySlug: null,
featuredOnly: null,
category: null,
products: [],
...overrides,
};
}
function createComponent(routerUrl = '/de/shop') {
const responseInit: { status?: number } = {};
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
'applyResolvedSeo',
'applyPageSeo',
]);
const translate = jasmine.createSpyObj<TranslateService>(
'TranslateService',
['instant'],
);
translate.instant.and.callFake((key: string, params?: { count?: number }) => {
const translations: Record<string, string> = {
'SHOP.TITLE': 'Technische Lösungen',
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
'SHOP.CATALOG_TITLE': 'Alle Produkte',
'SHOP.CATALOG_LABEL': 'Katalog',
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
'SHOP.CATALOG_META_DESCRIPTION':
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
};
if (key === 'SHOP.CATEGORY_META') {
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
}
return translations[key] ?? key;
});
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
const languageService = {
currentLang,
selectedLang: () => currentLang(),
};
const shopService = {
cart: signal(null),
cartLoading: signal(false),
cartLoaded: signal(false),
cartItemCount: signal(0),
cartSessionId: signal<string | null>(null),
getCategories: jasmine
.createSpy('getCategories')
.and.returnValue(of([] as ShopCategoryTree[])),
getProductCatalog: jasmine
.createSpy('getProductCatalog')
.and.returnValue(of(buildCatalog())),
flattenCategoryTree: jasmine
.createSpy('flattenCategoryTree')
.and.returnValue([]),
quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0),
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)),
updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)),
};
const router = {
url: routerUrl,
navigate: jasmine.createSpy('navigate'),
} as unknown as Router;
const activatedRoute = {
paramMap: of(convertToParamMap({})),
snapshot: {
paramMap: convertToParamMap({}),
},
} as unknown as ActivatedRoute;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [ShopPageComponent],
providers: [
{ provide: SeoService, useValue: seoService },
{ provide: TranslateService, useValue: translate },
{ provide: LanguageService, useValue: languageService },
{ provide: ShopService, useValue: shopService },
{
provide: ShopRouteService,
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
'shopRootCommands',
]),
},
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: RESPONSE_INIT, useValue: responseInit },
{ provide: PLATFORM_ID, useValue: 'server' },
],
});
const fixture: ComponentFixture<ShopPageComponent> =
TestBed.createComponent(ShopPageComponent);
return {
component: fixture.componentInstance,
seoService,
responseInit,
};
}
it('keeps index follow on the public shop root', () => {
const { component, seoService } = createComponent();
(component as any).applyDefaultSeo();
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
title: 'Technische Lösungen | 3D fab',
robots: 'index, follow',
}),
);
});
it('keeps noindex for categories explicitly marked as non-indexable', () => {
const { component, seoService } = createComponent('/de/shop/compatible-with-garmin');
(component as any).applySeo(buildCategory({ indexable: false }));
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
robots: 'noindex, nofollow',
}),
);
});
it('uses a soft SSR fallback for non-404 category load errors', () => {
const { component, seoService, responseInit } = createComponent(
'/de/shop/compatible-with-garmin',
);
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
(component as any).setResponseStatus(200);
(component as any).applySoftFallbackSeo('compatible-with-garmin');
expect(responseInit.status).toBe(200);
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
title: 'Compatible With Garmin | Technische Lösungen | 3D fab',
description:
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
robots: 'index, follow',
canonicalPath: '/de/shop/compatible-with-garmin',
alternates: null,
xDefault: null,
}),
);
});
it('keeps hard 404 noindex behavior for missing categories', () => {
const { component, seoService, responseInit } = createComponent(
'/de/shop/compatible-with-garmin',
);
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
(component as any).setResponseStatus(404);
(component as any).applyHardErrorSeo();
expect(responseInit.status).toBe(404);
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
jasmine.objectContaining({
robots: 'noindex, nofollow',
alternates: null,
xDefault: null,
}),
);
});
});

View File

@@ -1,27 +1,35 @@
import { CommonModule } from '@angular/common';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import {
PLATFORM_ID,
RESPONSE_INIT,
afterNextRender,
Component,
DestroyRef,
Injector,
computed,
inject,
input,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
catchError,
combineLatest,
distinctUntilChanged,
finalize,
forkJoin,
map,
of,
switchMap,
tap,
} from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { ProductCardComponent } from './components/product-card/product-card.component';
@@ -34,6 +42,7 @@ import {
ShopService,
} from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
@Component({
selector: 'app-shop-page',
@@ -52,16 +61,23 @@ import { ShopRouteService } from './services/shop-route.service';
export class ShopPageComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>();
readonly routeCategorySlug = signal<string | null>(
this.readRouteParam('categorySlug'),
);
readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly softFallbackCategoryLabel = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly categories = signal<ShopCategoryTree[]>([]);
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
@@ -75,7 +91,7 @@ export class ShopPageComponent {
readonly cartLoading = this.shopService.cartLoading;
readonly cartItemCount = this.shopService.cartItemCount;
readonly currentCategorySlug = computed(
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
() => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
);
readonly cartItems = computed(() =>
(this.cart()?.items ?? []).filter(
@@ -83,49 +99,103 @@ export class ShopPageComponent {
),
);
readonly cartHasItems = computed(() => this.cartItems().length > 0);
readonly heroSubtitle = computed(() => {
this.languageService.currentLang();
constructor() {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
const category = this.selectedCategory();
if (category) {
return (
category.description ||
this.translate.instant('SHOP.CATEGORY_META', {
count: category.productCount || 0,
})
);
}
if (this.softFallbackActive() && this.routeCategorySlug()) {
return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
}
return this.translate.instant('SHOP.SUBTITLE');
});
readonly catalogEyebrow = computed(() => {
this.languageService.currentLang();
return this.selectedCategory() || this.softFallbackCategoryLabel()
? this.translate.instant('SHOP.SELECTED_CATEGORY')
: this.translate.instant('SHOP.CATALOG_LABEL');
});
readonly catalogTitle = computed(() => {
this.languageService.currentLang();
return (
this.selectedCategory()?.name ||
this.softFallbackCategoryLabel() ||
this.translate.instant('SHOP.CATALOG_TITLE')
);
});
constructor() {
afterNextRender(() => {
this.scheduleCartWarmup();
});
combineLatest([
toObservable(this.categorySlug, { injector: this.injector }),
this.route.paramMap.pipe(
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
distinctUntilChanged(),
),
toObservable(this.languageService.currentLang, {
injector: this.injector,
}),
}).pipe(distinctUntilChanged()),
])
.pipe(
tap(() => {
this.loading.set(true);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.error.set(null);
}),
switchMap(([categorySlug]) =>
forkJoin({
switchMap(([categorySlug]) => {
this.routeCategorySlug.set(categorySlug);
return forkJoin({
categories: this.shopService.getCategories(),
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
}).pipe(
catchError((error) => {
const isNotFound = error?.status === 404;
this.categories.set([]);
this.categoryNodes.set([]);
this.selectedCategory.set(null);
this.products.set([]);
this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
);
this.applyDefaultSeo();
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardErrorSeo();
return of(null);
}
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.softFallbackCategoryLabel.set(
categorySlug ? humanizeShopSlug(categorySlug) : null,
);
this.setResponseStatus(200);
this.applySoftFallbackSeo(categorySlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null);
}),
finalize(() => this.loading.set(false)),
),
),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((result) => {
@@ -137,12 +207,54 @@ export class ShopPageComponent {
this.categoryNodes.set(
this.shopService.flattenCategoryTree(
result.categories,
result.catalog.category?.slug ?? this.categorySlug() ?? null,
result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
),
);
this.selectedCategory.set(result.catalog.category ?? null);
this.products.set(result.catalog.products);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.applySeo(result.catalog.category ?? null);
this.restoreCatalogScrollIfNeeded();
});
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
@@ -157,15 +269,30 @@ export class ShopPageComponent {
}
cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null;
return item.shopVariantLabel || this.cartItemColor(item);
}
cartItemColor(item: ShopCartItem): string | null {
return item.shopVariantColorName || item.colorCode || null;
return (
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: item.shopVariantColorName ?? item.colorCode,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
}) ??
item.shopVariantColorName ??
item.colorCode
);
}
cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6';
return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
}
navigateToCategory(slug?: string | null): void {
@@ -223,11 +350,14 @@ export class ShopPageComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
queryParams: {
session: sessionId,
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: {
session: sessionId,
},
},
});
);
}
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
@@ -298,4 +428,110 @@ export class ShopPageComponent {
ogDescription: description,
});
}
private applyHardErrorSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyResolvedSeo({
title,
description,
robots: 'noindex, nofollow',
ogTitle: title,
ogDescription: description,
canonicalPath: null,
alternates: null,
xDefault: null,
});
}
private applySoftFallbackSeo(categorySlug: string | null): void {
if (!categorySlug) {
this.applyDefaultSeo();
return;
}
const title = this.buildSoftFallbackCategoryTitle(categorySlug);
const description = this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
ogTitle: title,
ogDescription: description,
canonicalPath: this.currentPath(),
alternates: null,
xDefault: null,
});
}
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
return !this.isBrowser && error?.status !== 404;
}
private buildSoftFallbackCategoryTitle(categorySlug: string): string {
const shopTitle = this.translate.instant('SHOP.TITLE');
const humanized = humanizeShopSlug(categorySlug);
if (humanized) {
return `${humanized} | ${shopTitle} | 3D fab`;
}
return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_TITLE',
`${shopTitle} | 3D fab`,
);
}
private resolveTranslatedText(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: fallback;
}
private currentPath(): string {
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
return path.startsWith('/') ? path : `/${path}`;
}
private setResponseStatus(status: number): void {
if (this.responseInit) {
this.responseInit.status = status;
}
}
private restoreCatalogScrollIfNeeded(): void {
if (typeof window === 'undefined') {
return;
}
const scrollY = Number(history.state?.shopRestoreScrollY);
if (!Number.isFinite(scrollY) || scrollY < 0) {
return;
}
const { shopRestoreScrollY: _ignored, ...nextState } = history.state ?? {};
const restore = () => window.scrollTo({ left: 0, top: scrollY });
history.replaceState(nextState, '');
window.requestAnimationFrame(() => {
restore();
window.setTimeout(restore, 60);
});
}
private readRouteParam(name: string): string | null {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}
}

View File

@@ -0,0 +1,72 @@
const PRODUCT_ID_PREFIX_PATTERN = /^[0-9a-f]{8}-(?=[a-z0-9])/i;
const UPPERCASE_TOKENS = new Set([
'3d',
'abs',
'asa',
'cad',
'cf',
'gf',
'pa',
'pc',
'petg',
'pla',
'pp',
'tpu',
'uv',
]);
export function humanizeShopSlug(
value: string | null | undefined,
options?: {
stripProductIdPrefix?: boolean;
},
): string {
const normalized = normalizeShopSlug(value, options?.stripProductIdPrefix);
if (!normalized) {
return '';
}
return normalized
.split('-')
.filter(Boolean)
.map(formatSlugToken)
.join(' ')
.trim();
}
function normalizeShopSlug(
value: string | null | undefined,
stripProductIdPrefix = false,
): string {
const normalized = String(value ?? '')
.trim()
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean)
.at(-1)
?.toLowerCase();
if (!normalized) {
return '';
}
return stripProductIdPrefix
? normalized.replace(PRODUCT_ID_PREFIX_PATTERN, '')
: normalized;
}
function formatSlugToken(token: string): string {
if (!token) {
return '';
}
if (/^\d+$/.test(token)) {
return token;
}
if (UPPERCASE_TOKENS.has(token)) {
return token.toUpperCase();
}
return `${token.charAt(0).toUpperCase()}${token.slice(1)}`;
}

View File

@@ -7,30 +7,32 @@ export const SHOP_ROUTES: Routes = [
path: '',
component: ShopPageComponent,
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.',
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
},
},
{
path: 'p/:productSlug',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
},
},
{
path: ':categorySlug/:productSlug',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
},
},
{
path: ':categorySlug',
component: ShopPageComponent,
data: {
seoTitle: 'Categoria Shop | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.CATEGORY_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
},
},
];

View File

@@ -12,7 +12,7 @@ import { TranslateModule } from '@ngx-translate/core';
export class AppDropzoneComponent {
label = input<string>('DROPZONE.DEFAULT_LABEL');
subtext = input<string>('DROPZONE.DEFAULT_SUBTEXT');
accept = input<string>('.stl,.3mf,.step,.stp');
accept = input<string>('.stl,.3mf');
multiple = input<boolean>(true);
filesDropped = output<File[]>();

View File

@@ -27,7 +27,10 @@
</div>
<div class="actions">
<a routerLink="/contact" class="contact-btn">
<a
[routerLink]="languageService.localizedPath('/contact')"
class="contact-btn"
>
{{ "LOCATIONS.CONTACT_US" | translate }}
</a>
</div>

View File

@@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router';
import { LanguageService } from '../../../core/services/language.service';
import {
AppToggleSelectorComponent,
ToggleOption,
@@ -20,6 +21,7 @@ import {
styleUrl: './app-locations.component.scss',
})
export class AppLocationsComponent {
readonly languageService = inject(LanguageService);
selectedLocation: 'ticino' | 'bienne' = 'ticino';
locationOptions: ToggleOption[] = [

View File

@@ -0,0 +1,17 @@
<div
class="brand-animation"
[attr.data-variant]="variant()"
role="img"
[attr.aria-hidden]="decorative() ? 'true' : null"
[attr.aria-label]="decorative() ? null : ariaLabel()"
>
@for (letter of resolvedLetters(); track letter.key) {
<img
class="brand-animation__letter"
[src]="letter.src"
alt=""
[attr.data-letter]="letter.key"
[style.--word-x]="letter.wordX"
/>
}
</div>

View File

@@ -0,0 +1,172 @@
:host {
display: block;
width: 100%;
}
.brand-animation {
--three-anchor-x: -9.4rem;
--bee-anchor-x: 10.2rem;
--word-scale: var(--brand-animation-scale, 1);
--word-spacing-factor: var(--brand-animation-word-spacing, 1);
--loader-group-scale-x: 0.94;
--loader-group-scale-y: 1.05;
--loader-exit-shift: 1.6rem;
position: relative;
width: min(100%, var(--brand-animation-width, 26rem));
height: var(--brand-animation-height, 8rem);
margin-inline: auto;
}
.brand-animation__letter {
--word-x: 0rem;
position: absolute;
top: 50%;
left: 50%;
width: var(--brand-animation-letter-width, clamp(2.7rem, 6vw, 4rem));
height: auto;
transform: translate(-50%, -50%);
transform-origin: center center;
will-change: transform;
}
.brand-animation[data-variant="site-intro"] .brand-animation__letter {
animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s)
linear 1 forwards;
}
.brand-animation[data-variant="calculator-loader"] .brand-animation__letter {
animation: calculator-loader-loop
var(--brand-animation-loader-loop-duration, 2.65s) infinite;
}
@keyframes site-intro-preview {
0% {
transform: translate(-50%, -50%) translateX(0) scale(0.92);
}
20% {
transform: translate(-50%, -50%) translateX(0) scale(0.92);
}
80% {
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
100% {
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
}
@keyframes calculator-loader-loop {
0%,
5% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
animation-timing-function: cubic-bezier(0.22, 0.82, 0.28, 1);
}
38%,
56% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
56% {
animation-timing-function: cubic-bezier(0.38, 0, 0.72, 1);
}
82%,
88% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
88% {
animation-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
94% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
(var(--bee-anchor-x) + var(--loader-exit-shift)) * var(--word-scale) *
var(--word-spacing-factor)
)
)
scaleX(0.98) scaleY(1.02);
}
94.01%,
100% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
}
@media (max-width: 640px) {
.brand-animation {
width: min(100%, var(--brand-animation-width-mobile, 19rem));
height: var(--brand-animation-height-mobile, 6rem);
--word-scale: var(--brand-animation-scale-mobile, 0.74);
}
.brand-animation__letter {
width: var(--brand-animation-letter-width-mobile, 2.8rem);
}
}
@media (prefers-reduced-motion: reduce) {
.brand-animation__letter {
animation: none !important;
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
}

View File

@@ -0,0 +1,69 @@
import { Component, computed, input } from '@angular/core';
export type BrandAnimationVariant = 'site-intro' | 'calculator-loader';
interface AnimationLetter {
key: string;
darkSrc: string;
yellowSrc: string;
wordX: string;
}
interface ResolvedAnimationLetter {
key: string;
src: string;
wordX: string;
}
const LETTERS: readonly AnimationLetter[] = [
{
key: '3',
darkSrc: '/assets/images/animation/31200.svg',
yellowSrc: '/assets/images/animation/3g1200.svg',
wordX: '-9.4rem',
},
{
key: 'd',
darkSrc: '/assets/images/animation/d1200.svg',
yellowSrc: '/assets/images/animation/Dg1200.svg',
wordX: '-4.9rem',
},
{
key: 'F',
darkSrc: '/assets/images/animation/F1200.svg',
yellowSrc: '/assets/images/animation/Fg1200.svg',
wordX: '1rem',
},
{
key: 'A',
darkSrc: '/assets/images/animation/A1200.svg',
yellowSrc: '/assets/images/animation/Ag1200.svg',
wordX: '5.6rem',
},
{
key: 'B',
darkSrc: '/assets/images/animation/B1200.svg',
yellowSrc: '/assets/images/animation/Bg1200.svg',
wordX: '10.2rem',
},
] as const;
@Component({
selector: 'app-brand-animation-logo',
standalone: true,
templateUrl: './brand-animation-logo.component.html',
styleUrl: './brand-animation-logo.component.scss',
})
export class BrandAnimationLogoComponent {
readonly variant = input<BrandAnimationVariant>('site-intro');
readonly decorative = input(true);
readonly ariaLabel = input('3D fab animated logo');
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
LETTERS.map((letter) => ({
key: letter.key,
src: this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
wordX: letter.wordX,
})),
);
}

View File

@@ -1,4 +1,11 @@
import { Component, input, output, signal, computed } from '@angular/core';
import {
Component,
input,
output,
signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -6,8 +13,10 @@ import {
getColorHex,
ColorCategory,
ColorOption,
resolveLocalizedColorLabel,
} from '../../../core/constants/colors.const';
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
import { LanguageService } from '../../../core/services/language.service';
@Component({
selector: 'app-color-selector',
@@ -17,6 +26,7 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
styleUrl: './color-selector.component.scss',
})
export class ColorSelectorComponent {
private readonly languageService = inject(LanguageService);
selectedColor = input<string>('Black');
selectedVariantId = input<number | null>(null);
variants = input<VariantOption[]>([]);
@@ -32,7 +42,14 @@ export class ColorSelectorComponent {
const finish = v.finishType || 'AVAILABLE_COLORS';
const bucket = byFinish.get(finish) || [];
bucket.push({
label: v.colorName,
label:
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: v.colorName,
it: v.colorLabelIt,
en: v.colorLabelEn,
de: v.colorLabelDe,
fr: v.colorLabelFr,
}) ?? v.colorName,
value: v.colorName,
hex: v.hexColor,
variantId: v.id,

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Rechner",
"SHOP": "Shop",
"ABOUT": "Über uns",
"MATERIALS": "Qualität & Materialien",
"CONTACT": "Kontakt",
"LANGUAGE_SELECTOR": "Sprachauswahl"
},
@@ -38,16 +39,82 @@
"TERMS": "AGB",
"CONTACT": "Kontakt"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | 3D-Druck nach Maß",
"DESCRIPTION": "3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien."
},
"ROUTES": {
"HOME": {
"TITLE": "3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D fab",
"DESCRIPTION": "3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien."
},
"CALCULATOR": {
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
"BASIC": {
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab",
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow."
},
"ADVANCED": {
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab",
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing."
}
},
"SHOP": {
"TITLE": "3D fab Shop",
"DESCRIPTION": "Katalog mit 3D-gedruckten Produkten, technischem Zubehör und sofort einsatzbereiten Lösungen.",
"CATEGORY_TITLE": "Shop-Kategorie | 3D fab",
"CATEGORY_DESCRIPTION": "Entdecken Sie Produkte dieser Kategorie, verfügbare Varianten und technische 3D-Druck-Lösungen.",
"PRODUCT_TITLE": "Produkt | 3D fab",
"PRODUCT_DESCRIPTION": "Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit des ausgewählten Produkts im 3D fab Shop."
},
"MATERIALS": {
"TITLE": "Qualität und Materialien | 3D fab",
"DESCRIPTION": "Vergleichen Sie 3D-Druckmaterialien mit interaktiven Radar-Charts, technischen Eigenschaften und referenzierten Quellen."
},
"ABOUT": {
"TITLE": "Über uns | 3D fab",
"DESCRIPTION": "Lernen Sie das Team von 3D fab und unser 3D-Druck-Labor für Prototypen, Ersatzteile und maßgeschneiderte Produktionen kennen."
},
"CONTACT": {
"TITLE": "Kontakt | 3D fab",
"DESCRIPTION": "Fordern Sie Informationen, individuelle Angebote oder technischen Support für Ihr 3D-Druck-Projekt an."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Datenschutz | 3D fab",
"DESCRIPTION": "Datenschutzerklärung von 3D fab: Datenverarbeitung, Zwecke und Kontaktangaben."
},
"TERMS": {
"TITLE": "AGB | 3D fab",
"DESCRIPTION": "Allgemeine Geschäftsbedingungen für den 3D-Druck-Service und den Angebotsrechner."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Schließen Sie Ihre Anfrage ab und bestätigen Sie die Daten Ihrer 3D-Druck-Bestellung."
},
"ORDER": {
"TITLE": "Bestellung | 3D fab",
"DESCRIPTION": "Prüfen Sie die Zusammenfassung Ihrer Bestellung und den Status Ihrer 3D-Druck-Anfrage."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Geschützter Administrationsbereich von 3D fab."
}
}
},
"CALC": {
"TITLE": "3D-Angebot berechnen",
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
"CTA_START": "Jetzt starten",
"BUSINESS": "Unternehmen",
"PRIVATE": "Privat",
"MODE_EASY": "Basis",
"MODE_ADVANCED": "Erweitert",
"UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher",
"UPLOAD_SUB": "Wir unterstützen STL, 3MF, STEP bis 50MB",
"UPLOAD_SUB": "Wir unterstützen STL, 3MF bis 50MB",
"MATERIAL": "Material",
"QUALITY": "Qualität",
"QUANTITY": "Menge",
@@ -74,11 +141,12 @@
"BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle",
"BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt",
"ERR_FILE_REQUIRED": "Die Datei ist erforderlich.",
"STEP_WARNING": "Die 3D-Ansicht ist mit STEP- und 3MF-Dateien nicht kompatibel",
"STEP_WARNING": "Die 3D-Vorschau ist nur für STL-Dateien verfügbar.",
"REMOVE_FILE": "Datei entfernen",
"FALLBACK_MATERIAL": "PLA (Fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.",
"ERR_INVALID_FILE_TYPE": "Sie können nur Dateien vom Typ .stl oder .3mf hochladen.",
"PRINT_SPEED": "Druckgeschwindigkeit",
"COLOR": "Farbe",
"ANALYZING_TITLE": "Analyse läuft...",
@@ -98,6 +166,7 @@
"SHOP": {
"TITLE": "Technische Lösungen",
"SUBTITLE": "Fertige Produkte, die praktische Probleme lösen",
"HERO_EYEBROW": "Technischer Shop",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop im Aufbau",
"WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!",
@@ -108,6 +177,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.",
"ADD_CART": "In den Warenkorb",
"ADDING": "Wird hinzugefügt",
"ADD_SUCCESS": "Produkt zum Warenkorb hinzugefügt.",
"BACK": "Zurück zum Shop",
"NOT_FOUND": "Produkt nicht gefunden.",
"DETAILS": "Details",
@@ -115,13 +185,39 @@
"SUCCESS_TITLE": "Zum Warenkorb hinzugefügt",
"SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.",
"CONTINUE": "Weiter",
"VIEW_ALL": "Gesamten Shop ansehen",
"CATALOG_LABEL": "Katalog",
"CATALOG_TITLE": "Alle Produkte",
"CATALOG_META_DESCRIPTION": "Entdecken Sie 3D-gedruckte Produkte, technisches Zubehör und einsatzbereite Lösungen mit demselben Checkout wie im Rechner.",
"CUSTOM_PART_CTA": "Nicht gefunden, was Sie suchen? Fordern Sie ein individuelles Teil an.",
"CATEGORY_META": "{{count}} Produkte in dieser Kategorie verfügbar",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Kategorien",
"SELECTED_CATEGORY": "Ausgewählte Kategorie",
"ITEMS_FOUND": "Produkte",
"EMPTY_CATEGORY": "Derzeit sind in dieser Kategorie keine Produkte verfügbar.",
"FEATURED_KICKER": "Empfohlen",
"FEATURED_TITLE": "Produkte, die sich lohnen",
"FEATURED_BADGE": "Empfohlen",
"HIGHLIGHT_PRODUCTS": "Produkte",
"HIGHLIGHT_CART": "Im Warenkorb",
"HIGHLIGHT_READY": "Vorschau",
"PRICE_FROM": "Preis ab",
"MODEL_OPEN": "3D-Ansicht öffnen",
"MODEL_CLOSE": "3D-Ansicht schließen",
"MODEL_3D": "3D-Vorschau",
"MODEL_TITLE": "Modellvorschau",
"MODEL_LOADING": "Das 3D-Modell wird geladen.",
"MODEL_UNAVAILABLE": "3D-Vorschau nicht verfügbar.",
"PREVIOUS_IMAGE": "Vorheriges Bild",
"NEXT_IMAGE": "Nächstes Bild",
"BREADCRUMB_ROOT": "Shop",
"PRICE_LABEL": "Preis",
"EXCERPT_FALLBACK": "Produktseite in Vorbereitung.",
"SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Farbe",
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
"VARIANT": "Variante",
"PROPERTY_UV": "UV-Beständigkeit",
"PROPERTY_WEATHER": "Außeneinsatz",
"PROPERTY_RIGIDITY": "Steifigkeit",
@@ -130,6 +226,22 @@
"PROPERTY_LOW": "Niedrig",
"PROPERTY_RIGID": "Steif",
"PROPERTY_FLEXIBLE": "Flexibel",
"QUANTITY": "Menge",
"GO_TO_CHECKOUT": "Zum Checkout",
"IN_CART_SHORT": "Im Warenkorb x{{count}}",
"IN_CART_LONG": "Bereits im Warenkorb x{{count}}",
"DESCRIPTION_TITLE": "Beschreibung",
"CART_TITLE": "Warenkorb",
"CART_SUMMARY_TITLE": "Aktuelle Übersicht",
"CART_LOADING": "Warenkorb wird geladen.",
"CART_EMPTY": "Der Warenkorb ist leer. Fügen Sie ein Produkt hinzu.",
"CART_SUBTOTAL": "Zwischensumme Produkte",
"CART_SHIPPING": "Versand",
"CART_TOTAL": "Geschätzte Gesamtsumme",
"CLEAR_CART": "Leeren",
"REMOVE": "Entfernen",
"CART_UPDATE_ERROR": "Der Warenkorb konnte nicht aktualisiert werden. Bitte erneut versuchen.",
"ALL_CATEGORIES": "Alle Kategorien",
"CATEGORIES": {
"FILAMENTS": "Filamente",
"ACCESSORIES": "Zubehör"
@@ -397,6 +509,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.",
"CAD_SERVICE": "CAD-Service",
"CAD_REQUEST_REF": "bezogen auf Kontaktanfrage #{{id}}",
"CONTACT_INFO": "Kontaktinformationen",
"BILLING_ADDR": "Rechnungsadresse",
"SHIPPING_ADDR": "Lieferadresse",
@@ -499,12 +613,19 @@
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
"HERO_SWISS_TITLE": "Mit Sitz in der Schweiz",
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zürich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
"BTN_CALCULATE": "Angebot berechnen",
"BTN_SHOP": "Zum Shop",
"BTN_CONTACT": "Mit uns sprechen",
"SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden",
"SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.",
"SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF, STEP",
"SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF",
"CARD_CALC_EYEBROW": "Automatische Berechnung",
"CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick",
"CARD_CALC_TAG": "Ohne Registrierung",
@@ -543,11 +664,18 @@
"ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.",
"ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.",
"ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.",
"CAD_SERVICE": "CAD-Service ({{hours}}h)",
"ITEMS_TITLE": "Bestellartikel",
"ORDER_TYPE_LABEL": "Bestelltyp",
"ITEM_COUNT": "Positionen",
"TYPE_SHOP": "Shop",
"TYPE_CALCULATOR": "Rechner",
"TYPE_MIXED": "Gemischt",
"NOT_AVAILABLE": "N/V"
},
"DROPZONE": {
"DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen",
"DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF, .STEP"
"DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF"
},
"COLOR": {
"AVAILABLE_COLORS": "Verfügbare Farben",
@@ -560,6 +688,13 @@
"BLUE": "Blau",
"GREEN": "Grün",
"YELLOW": "Gelb",
"ORANGE": "Orange",
"GRAY": "Grau",
"LIGHT_GRAY": "Hellgrau",
"DARK_GRAY": "Dunkelgrau",
"PURPLE": "Lila",
"BEIGE": "Beige",
"SAND_BEIGE": "Sandbeige",
"MATTE_BLACK": "Matt Schwarz",
"MATTE_WHITE": "Matt Weiß",
"MATTE_GRAY": "Matt Grau"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calculator",
"SHOP": "Shop",
"ABOUT": "About Us",
"MATERIALS": "Quality & Materials",
"CONTACT": "Contact Us",
"LANGUAGE_SELECTOR": "Language selector"
},
@@ -38,16 +39,82 @@
"TERMS": "Terms & Conditions",
"CONTACT": "Contact Us"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Custom 3D Printing",
"DESCRIPTION": "Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs."
},
"ROUTES": {
"HOME": {
"TITLE": "Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D fab",
"DESCRIPTION": "Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files."
},
"CALCULATOR": {
"TITLE": "3D Printing Quote Calculator | 3D fab",
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
"BASIC": {
"TITLE": "Basic 3D Printing Calculator | 3D fab",
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow."
},
"ADVANCED": {
"TITLE": "Advanced 3D Printing Calculator | 3D fab",
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing."
}
},
"SHOP": {
"TITLE": "3D fab Shop",
"DESCRIPTION": "Catalog of 3D printed products, technical accessories and practical ready-to-use solutions.",
"CATEGORY_TITLE": "Shop Category | 3D fab",
"CATEGORY_DESCRIPTION": "Browse products in this category, available variants and technical 3D printed solutions.",
"PRODUCT_TITLE": "Product | 3D fab",
"PRODUCT_DESCRIPTION": "Discover details, materials, variants and availability for the selected product in the 3D fab shop."
},
"MATERIALS": {
"TITLE": "Quality and Materials | 3D fab",
"DESCRIPTION": "Compare 3D printing materials with interactive radar charts, technical properties and cited sources."
},
"ABOUT": {
"TITLE": "About Us | 3D fab",
"DESCRIPTION": "Learn more about the 3D fab team and our 3D printing lab for prototypes, spare parts and custom production."
},
"CONTACT": {
"TITLE": "Contact | 3D fab",
"DESCRIPTION": "Request information, custom quotes or technical support for your 3D printing project."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Privacy Policy | 3D fab",
"DESCRIPTION": "3D fab privacy policy: data processing, purposes and contact details."
},
"TERMS": {
"TITLE": "Terms and Conditions | 3D fab",
"DESCRIPTION": "Terms and conditions for the 3D printing service and instant quote calculator."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Complete your request and confirm the details for your 3D printing order."
},
"ORDER": {
"TITLE": "Order | 3D fab",
"DESCRIPTION": "Review your order summary and the status of your 3D printing request."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Restricted 3D fab administration area."
}
}
},
"CALC": {
"TITLE": "3D Print Calculator",
"SUBTITLE": "Upload your 3D file (STL, 3MF, STEP...) and get an instant estimate of costs and print time.",
"SUBTITLE": "Upload your 3D file (STL, 3MF) and get an instant estimate of costs and print time.",
"CTA_START": "Start Now",
"BUSINESS": "Business",
"PRIVATE": "Private",
"MODE_EASY": "Quick",
"MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP up to 50MB",
"UPLOAD_SUB": "Supports STL, 3MF up to 50MB",
"MATERIAL": "Material",
"QUALITY": "Quality",
"QUANTITY": "Quantity",
@@ -74,11 +141,12 @@
"BENEFITS_2": "Selected materials and quality control",
"BENEFITS_3": "CAD consultation if file needs modifications",
"ERR_FILE_REQUIRED": "File is required.",
"STEP_WARNING": "3D preview is not available for STEP files, but the calculator works perfectly. You can proceed with the quotation.",
"STEP_WARNING": "3D preview is available only for STL files.",
"REMOVE_FILE": "Remove file",
"FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.",
"ERR_INVALID_FILE_TYPE": "You can upload only .stl or .3mf files.",
"PRINT_SPEED": "Print speed",
"COLOR": "Color",
"ANALYZING_TITLE": "Analysis in progress...",
@@ -98,6 +166,7 @@
"SHOP": {
"TITLE": "Technical solutions",
"SUBTITLE": "Ready-made products solving practical problems",
"HERO_EYEBROW": "Technical shop",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop under construction",
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
@@ -108,6 +177,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.",
"ADD_CART": "Add to Cart",
"ADDING": "Adding to cart",
"ADD_SUCCESS": "Product added to cart.",
"BACK": "Back to Shop",
"NOT_FOUND": "Product not found.",
"DETAILS": "Details",
@@ -115,13 +185,39 @@
"SUCCESS_TITLE": "Added to cart",
"SUCCESS_DESC": "The product has been added to the cart.",
"CONTINUE": "Continue",
"VIEW_ALL": "View the full shop",
"CATALOG_LABEL": "Catalog",
"CATALOG_TITLE": "All products",
"CATALOG_META_DESCRIPTION": "Discover 3D printed products, technical accessories, and ready-to-use solutions with the same checkout as the calculator.",
"CUSTOM_PART_CTA": "Can't find what you're looking for? Request a custom part.",
"CATEGORY_META": "{{count}} products available in this category",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Categories",
"SELECTED_CATEGORY": "Selected category",
"ITEMS_FOUND": "products",
"EMPTY_CATEGORY": "No products are currently available in this category.",
"FEATURED_KICKER": "Featured",
"FEATURED_TITLE": "Products worth watching",
"FEATURED_BADGE": "Featured",
"HIGHLIGHT_PRODUCTS": "Products",
"HIGHLIGHT_CART": "In cart",
"HIGHLIGHT_READY": "Preview",
"PRICE_FROM": "Price from",
"MODEL_OPEN": "Open 3D view",
"MODEL_CLOSE": "Close 3D view",
"MODEL_3D": "3D preview",
"MODEL_TITLE": "Model preview",
"MODEL_LOADING": "Loading the 3D model.",
"MODEL_UNAVAILABLE": "3D preview unavailable.",
"PREVIOUS_IMAGE": "Previous image",
"NEXT_IMAGE": "Next image",
"BREADCRUMB_ROOT": "Shop",
"PRICE_LABEL": "Price",
"EXCERPT_FALLBACK": "Product page coming soon.",
"SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Color",
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
"VARIANT": "Variant",
"PROPERTY_UV": "UV resistance",
"PROPERTY_WEATHER": "Outdoor use",
"PROPERTY_RIGIDITY": "Rigidity",
@@ -130,6 +226,22 @@
"PROPERTY_LOW": "Low",
"PROPERTY_RIGID": "Rigid",
"PROPERTY_FLEXIBLE": "Flexible",
"QUANTITY": "Quantity",
"GO_TO_CHECKOUT": "Go to checkout",
"IN_CART_SHORT": "In cart x{{count}}",
"IN_CART_LONG": "Already in cart x{{count}}",
"DESCRIPTION_TITLE": "Description",
"CART_TITLE": "Cart",
"CART_SUMMARY_TITLE": "Current summary",
"CART_LOADING": "Loading cart.",
"CART_EMPTY": "Your cart is empty. Add a product.",
"CART_SUBTOTAL": "Products subtotal",
"CART_SHIPPING": "Shipping",
"CART_TOTAL": "Estimated total",
"CLEAR_CART": "Clear",
"REMOVE": "Remove",
"CART_UPDATE_ERROR": "We couldn't update the cart. Please try again.",
"ALL_CATEGORIES": "All categories",
"CATEGORIES": {
"FILAMENTS": "Filaments",
"ACCESSORIES": "Accessories"
@@ -397,6 +509,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
"CAD_SERVICE": "CAD service",
"CAD_REQUEST_REF": "related to contact request #{{id}}",
"CONTACT_INFO": "Contact Information",
"BILLING_ADDR": "Billing Address",
"SHIPPING_ADDR": "Shipping Address",
@@ -499,12 +613,19 @@
"HERO_TITLE": "3D printing service.<br>From file to finished part.",
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
"HERO_SUBTITLE": "We also offer CAD services for custom parts!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_COPY": "Swiss production and support.",
"HERO_SWISS_LOCATIONS_LABEL": "Locations",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Serving customers across Switzerland.",
"BTN_CALCULATE": "Calculate Quote",
"BTN_SHOP": "Go to shop",
"BTN_CONTACT": "Talk to us",
"SEC_CALC_TITLE": "Accurate pricing in a few seconds",
"SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.",
"SEC_CALC_LIST_1": "Supported formats: STL, 3MF, STEP",
"SEC_CALC_LIST_1": "Supported formats: STL, 3MF",
"CARD_CALC_EYEBROW": "Automatic calculation",
"CARD_CALC_TITLE": "Price and lead time in one click",
"CARD_CALC_TAG": "No registration",
@@ -543,11 +664,18 @@
"ERR_ID_NOT_FOUND": "Order ID not found.",
"ERR_LOAD_ORDER": "Failed to load order details.",
"ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.",
"CAD_SERVICE": "CAD service ({{hours}}h)",
"ITEMS_TITLE": "Order items",
"ORDER_TYPE_LABEL": "Order type",
"ITEM_COUNT": "Lines",
"TYPE_SHOP": "Shop",
"TYPE_CALCULATOR": "Calculator",
"TYPE_MIXED": "Mixed",
"NOT_AVAILABLE": "N/A"
},
"DROPZONE": {
"DEFAULT_LABEL": "Drop files here or click to upload",
"DEFAULT_SUBTEXT": "Supports .stl, .3mf, .step"
"DEFAULT_SUBTEXT": "Supports .stl, .3mf"
},
"COLOR": {
"AVAILABLE_COLORS": "Available colors",
@@ -560,6 +688,13 @@
"BLUE": "Blue",
"GREEN": "Green",
"YELLOW": "Yellow",
"ORANGE": "Orange",
"GRAY": "Gray",
"LIGHT_GRAY": "Light Gray",
"DARK_GRAY": "Dark Gray",
"PURPLE": "Purple",
"BEIGE": "Beige",
"SAND_BEIGE": "Sand Beige",
"MATTE_BLACK": "Matte Black",
"MATTE_WHITE": "Matte White",
"MATTE_GRAY": "Matte Gray"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calculateur",
"SHOP": "Boutique",
"ABOUT": "Qui sommes-nous",
"MATERIALS": "Qualité & matériaux",
"CONTACT": "Contactez-nous",
"LANGUAGE_SELECTOR": "Sélecteur de langue"
},
@@ -12,17 +13,90 @@
"TERMS": "Conditions générales",
"CONTACT": "Contactez-nous"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Impression 3D sur mesure",
"DESCRIPTION": "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries."
},
"ROUTES": {
"HOME": {
"TITLE": "Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D fab",
"DESCRIPTION": "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL."
},
"CALCULATOR": {
"TITLE": "Calculateur de devis impression 3D | 3D fab",
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
"BASIC": {
"TITLE": "Calculateur impression 3D de base | 3D fab",
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base."
},
"ADVANCED": {
"TITLE": "Calculateur impression 3D avancé | 3D fab",
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing."
}
},
"SHOP": {
"TITLE": "Boutique 3D fab",
"DESCRIPTION": "Catalogue de produits imprimés en 3D, accessoires techniques et solutions pratiques prêtes à l'emploi.",
"CATEGORY_TITLE": "Catégorie boutique | 3D fab",
"CATEGORY_DESCRIPTION": "Parcourez les produits de cette catégorie, les variantes disponibles et les solutions techniques imprimées en 3D.",
"PRODUCT_TITLE": "Produit | 3D fab",
"PRODUCT_DESCRIPTION": "Découvrez les détails, matériaux, variantes et disponibilités du produit sélectionné dans la boutique 3D fab."
},
"MATERIALS": {
"TITLE": "Qualité et matériaux | 3D fab",
"DESCRIPTION": "Comparez les matériaux d'impression 3D avec des radar charts interactifs, des propriétés techniques et des sources citées."
},
"ABOUT": {
"TITLE": "Qui sommes-nous | 3D fab",
"DESCRIPTION": "Découvrez l'équipe de 3D fab et notre atelier d'impression 3D pour prototypes, pièces et productions sur mesure."
},
"CONTACT": {
"TITLE": "Contact | 3D fab",
"DESCRIPTION": "Demandez des informations, des devis personnalisés ou un support technique pour votre projet d'impression 3D."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Politique de confidentialité | 3D fab",
"DESCRIPTION": "Politique de confidentialité de 3D fab : traitement des données, finalités et contacts."
},
"TERMS": {
"TITLE": "Conditions générales | 3D fab",
"DESCRIPTION": "Conditions générales du service d'impression 3D et du calculateur de devis."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Finalisez votre demande et confirmez les détails de votre commande d'impression 3D."
},
"ORDER": {
"TITLE": "Commande | 3D fab",
"DESCRIPTION": "Consultez le récapitulatif de votre commande et l'état de votre demande d'impression 3D."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Espace d'administration restreint de 3D fab."
}
}
},
"HOME": {
"HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers",
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
"HERO_SWISS_TITLE": "Basés en Suisse",
"HERO_SWISS_COPY": "Production et support en Suisse.",
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Actifs dans toute la Suisse.",
"BTN_CALCULATE": "Calculer un devis",
"BTN_SHOP": "Aller à la boutique",
"BTN_CONTACT": "Parlez avec nous",
"SEC_CALC_TITLE": "Prix correct en quelques secondes",
"SEC_CALC_SUBTITLE": "Aucune inscription requise. L'estimation est effectuée via un vrai slicing.",
"SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF, STEP",
"SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF",
"CARD_CALC_EYEBROW": "Calcul automatique",
"CARD_CALC_TITLE": "Prix et délais en un clic",
"CARD_CALC_TAG": "Sans inscription",
@@ -65,14 +139,14 @@
},
"CALC": {
"TITLE": "Calculer un devis 3D",
"SUBTITLE": "Chargez votre fichier 3D (STL, 3MF, STEP), réglez la qualité et la couleur puis calculez immédiatement prix et délais.",
"SUBTITLE": "Chargez votre fichier 3D (STL, 3MF), réglez la qualité et la couleur puis calculez immédiatement prix et délais.",
"CTA_START": "Commencer maintenant",
"BUSINESS": "Entreprises",
"PRIVATE": "Particuliers",
"MODE_EASY": "Base",
"MODE_ADVANCED": "Avancée",
"UPLOAD_LABEL": "Glissez votre fichier 3D ici",
"UPLOAD_SUB": "Nous prenons en charge STL, 3MF, STEP jusqu'à 50MB",
"UPLOAD_SUB": "Nous prenons en charge STL, 3MF jusqu'à 50MB",
"MATERIAL": "Matériau",
"QUALITY": "Qualité",
"PRINT_SPEED": "Vitesse d'impression",
@@ -111,11 +185,12 @@
"NOTES_PLACEHOLDER": "Instructions spécifiques...",
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
"SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante",
"STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF",
"STEP_WARNING": "La prévisualisation 3D est disponible uniquement pour les fichiers STL.",
"REMOVE_FILE": "Supprimer le fichier",
"FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.",
"ERR_INVALID_FILE_TYPE": "Vous pouvez téléverser uniquement des fichiers .stl ou .3mf.",
"ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.",
"ZERO_RESULT_TITLE": "Résultat invalide",
"ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation."
@@ -155,6 +230,7 @@
"SHOP": {
"TITLE": "Solutions techniques",
"SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques",
"HERO_EYEBROW": "Boutique technique",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Boutique en préparation",
"WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !",
@@ -165,6 +241,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.",
"ADD_CART": "Ajouter au panier",
"ADDING": "Ajout en cours",
"ADD_SUCCESS": "Produit ajouté au panier.",
"BACK": "Retour à la boutique",
"NOT_FOUND": "Produit introuvable.",
"DETAILS": "Détails",
@@ -172,13 +249,39 @@
"SUCCESS_TITLE": "Ajouté au panier",
"SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.",
"CONTINUE": "Continuer",
"VIEW_ALL": "Voir toute la boutique",
"CATALOG_LABEL": "Catalogue",
"CATALOG_TITLE": "Tous les produits",
"CATALOG_META_DESCRIPTION": "Découvrez des produits imprimés en 3D, des accessoires techniques et des solutions prêtes à l'emploi avec le même checkout que le calculateur.",
"CUSTOM_PART_CTA": "Vous ne trouvez pas ce que vous cherchez ? Demandez une pièce personnalisée.",
"CATEGORY_META": "{{count}} produits disponibles dans cette catégorie",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Catégories",
"SELECTED_CATEGORY": "Catégorie sélectionnée",
"ITEMS_FOUND": "produits",
"EMPTY_CATEGORY": "Aucun produit n'est disponible dans cette catégorie pour le moment.",
"FEATURED_KICKER": "À la une",
"FEATURED_TITLE": "Produits à surveiller",
"FEATURED_BADGE": "À la une",
"HIGHLIGHT_PRODUCTS": "Produits",
"HIGHLIGHT_CART": "Dans le panier",
"HIGHLIGHT_READY": "Aperçu",
"PRICE_FROM": "Prix à partir de",
"MODEL_OPEN": "Ouvrir la vue 3D",
"MODEL_CLOSE": "Fermer la vue 3D",
"MODEL_3D": "Aperçu 3D",
"MODEL_TITLE": "Aperçu du modèle",
"MODEL_LOADING": "Chargement du modèle 3D.",
"MODEL_UNAVAILABLE": "Aperçu 3D indisponible.",
"PREVIOUS_IMAGE": "Image précédente",
"NEXT_IMAGE": "Image suivante",
"BREADCRUMB_ROOT": "Boutique",
"PRICE_LABEL": "Prix",
"EXCERPT_FALLBACK": "Fiche produit en préparation.",
"SELECT_MATERIAL": "Matériau",
"SELECT_COLOR": "Couleur",
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
"VARIANT": "Variante",
"PROPERTY_UV": "Résistance UV",
"PROPERTY_WEATHER": "Usage extérieur",
"PROPERTY_RIGIDITY": "Rigidité",
@@ -187,6 +290,22 @@
"PROPERTY_LOW": "Faible",
"PROPERTY_RIGID": "Rigide",
"PROPERTY_FLEXIBLE": "Flexible",
"QUANTITY": "Quantité",
"GO_TO_CHECKOUT": "Aller au checkout",
"IN_CART_SHORT": "Dans le panier x{{count}}",
"IN_CART_LONG": "Déjà dans le panier x{{count}}",
"DESCRIPTION_TITLE": "Description",
"CART_TITLE": "Panier",
"CART_SUMMARY_TITLE": "Récapitulatif actuel",
"CART_LOADING": "Chargement du panier.",
"CART_EMPTY": "Le panier est vide. Ajoutez un produit.",
"CART_SUBTOTAL": "Sous-total produits",
"CART_SHIPPING": "Expédition",
"CART_TOTAL": "Total estimé",
"CLEAR_CART": "Vider",
"REMOVE": "Supprimer",
"CART_UPDATE_ERROR": "Nous n'avons pas réussi à mettre à jour le panier. Réessayez.",
"ALL_CATEGORIES": "Toutes les catégories",
"CATEGORIES": {
"FILAMENTS": "Filaments",
"ACCESSORIES": "Accessoires"
@@ -454,6 +573,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.",
"CAD_SERVICE": "Service CAD",
"CAD_REQUEST_REF": "lié à la demande de contact #{{id}}",
"CONTACT_INFO": "Informations de contact",
"BILLING_ADDR": "Adresse de facturation",
"SHIPPING_ADDR": "Adresse de livraison",
@@ -549,11 +670,18 @@
"ERR_ID_NOT_FOUND": "ID de commande introuvable.",
"ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.",
"ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.",
"CAD_SERVICE": "Service CAD ({{hours}}h)",
"ITEMS_TITLE": "Articles de la commande",
"ORDER_TYPE_LABEL": "Type de commande",
"ITEM_COUNT": "Lignes",
"TYPE_SHOP": "Boutique",
"TYPE_CALCULATOR": "Calculateur",
"TYPE_MIXED": "Mixte",
"NOT_AVAILABLE": "N/D"
},
"DROPZONE": {
"DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser",
"DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF, .STEP"
"DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF"
},
"COLOR": {
"AVAILABLE_COLORS": "Couleurs disponibles",
@@ -566,6 +694,13 @@
"BLUE": "Bleu",
"GREEN": "Vert",
"YELLOW": "Jaune",
"ORANGE": "Orange",
"GRAY": "Gris",
"LIGHT_GRAY": "Gris clair",
"DARK_GRAY": "Gris foncé",
"PURPLE": "Violet",
"BEIGE": "Beige",
"SAND_BEIGE": "Beige sable",
"MATTE_BLACK": "Noir mat",
"MATTE_WHITE": "Blanc mat",
"MATTE_GRAY": "Gris mat"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calcolatore",
"SHOP": "Shop",
"ABOUT": "Chi Siamo",
"MATERIALS": "Qualita e Materiali",
"CONTACT": "Contattaci",
"LANGUAGE_SELECTOR": "Selettore lingua"
},
@@ -12,17 +13,90 @@
"TERMS": "Termini & Condizioni",
"CONTACT": "Contattaci"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Stampa 3D su misura",
"DESCRIPTION": "Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie."
},
"ROUTES": {
"HOME": {
"TITLE": "Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D fab",
"DESCRIPTION": "Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL."
},
"CALCULATOR": {
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
"BASIC": {
"TITLE": "Calcolatore stampa 3D base | 3D fab",
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base."
},
"ADVANCED": {
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab",
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale."
}
},
"SHOP": {
"TITLE": "Shop 3D fab",
"DESCRIPTION": "Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.",
"CATEGORY_TITLE": "Categoria Shop | 3D fab",
"CATEGORY_DESCRIPTION": "Esplora i prodotti di questa categoria, le varianti disponibili e le soluzioni tecniche stampate in 3D.",
"PRODUCT_TITLE": "Prodotto | 3D fab",
"PRODUCT_DESCRIPTION": "Scopri dettagli, materiali, varianti e disponibilita del prodotto selezionato nello shop 3D fab."
},
"MATERIALS": {
"TITLE": "Qualita e Materiali | 3D fab",
"DESCRIPTION": "Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate."
},
"ABOUT": {
"TITLE": "Chi siamo | 3D fab",
"DESCRIPTION": "Scopri il team 3D fab e il laboratorio di stampa 3D per prototipi, ricambi e produzioni su misura."
},
"CONTACT": {
"TITLE": "Contatti | 3D fab",
"DESCRIPTION": "Richiedi informazioni, preventivi personalizzati o supporto tecnico per il tuo progetto di stampa 3D."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Privacy Policy | 3D fab",
"DESCRIPTION": "Informativa privacy di 3D fab: trattamento dati, finalita e contatti."
},
"TERMS": {
"TITLE": "Termini e condizioni | 3D fab",
"DESCRIPTION": "Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Completa la richiesta e conferma i dati del tuo ordine di stampa 3D."
},
"ORDER": {
"TITLE": "Ordine | 3D fab",
"DESCRIPTION": "Consulta il riepilogo del tuo ordine e lo stato della richiesta di stampa 3D."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Area amministrativa riservata di 3D fab."
}
}
},
"HOME": {
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
"HERO_SWISS_TITLE": "Con sede in Svizzera",
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurigo",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
"BTN_CALCULATE": "Calcola Preventivo",
"BTN_SHOP": "Vai allo shop",
"BTN_CONTACT": "Parla con noi",
"SEC_CALC_TITLE": "Prezzo corretto in pochi secondi",
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.",
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP",
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF",
"CARD_CALC_EYEBROW": "Calcolo automatico",
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
"CARD_CALC_TAG": "Senza registrazione",
@@ -65,14 +139,14 @@
},
"CALC": {
"TITLE": "Calcola Preventivo 3D",
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.",
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.",
"CTA_START": "Inizia Ora",
"BUSINESS": "Aziende",
"PRIVATE": "Privati",
"MODE_EASY": "Base",
"MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP fino a 50MB",
"UPLOAD_SUB": "Supportiamo STL, 3MF fino a 50MB",
"MATERIAL": "Materiale",
"QUALITY": "Qualità",
"PRINT_SPEED": "Velocità di Stampa",
@@ -111,11 +185,12 @@
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} come costo di setup",
"SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo",
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf",
"STEP_WARNING": "La visualizzazione 3D è disponibile solo per i file STL",
"REMOVE_FILE": "Rimuovi file",
"FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.",
"ERR_INVALID_FILE_TYPE": "Puoi caricare solo file .stl o .3mf.",
"ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.",
"ZERO_RESULT_TITLE": "Risultato non valido",
"ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza."
@@ -193,6 +268,7 @@
"HIGHLIGHT_CART": "Nel carrello",
"HIGHLIGHT_READY": "Preview",
"PRICE_FROM": "Prezzo da",
"PRICE_LABEL": "Prezzo",
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
"MODEL_3D": "3D preview",
"MODEL_TITLE": "Anteprima del modello",
@@ -497,6 +573,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
"CAD_SERVICE": "Servizio CAD",
"CAD_REQUEST_REF": "riferito alla richiesta contatto #{{id}}",
"CONTACT_INFO": "Informazioni di Contatto",
"BILLING_ADDR": "Indirizzo di Fatturazione",
"SHIPPING_ADDR": "Indirizzo di Spedizione",
@@ -592,6 +670,7 @@
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
"CAD_SERVICE": "Servizio CAD ({{hours}}h)",
"ITEMS_TITLE": "Articoli dell'ordine",
"ORDER_TYPE_LABEL": "Tipo ordine",
"ITEM_COUNT": "Righe",
@@ -602,7 +681,7 @@
},
"DROPZONE": {
"DEFAULT_LABEL": "Trascina i file qui o clicca per caricare",
"DEFAULT_SUBTEXT": "Supporta .STL, .3MF, .STEP"
"DEFAULT_SUBTEXT": "Supporta .STL, .3MF"
},
"COLOR": {
"AVAILABLE_COLORS": "Colori disponibili",
@@ -615,6 +694,13 @@
"BLUE": "Blu",
"GREEN": "Verde",
"YELLOW": "Giallo",
"ORANGE": "Arancione",
"GRAY": "Grigio",
"LIGHT_GRAY": "Grigio chiaro",
"DARK_GRAY": "Grigio scuro",
"PURPLE": "Viola",
"BEIGE": "Beige",
"SAND_BEIGE": "Beige sabbia",
"MATTE_BLACK": "Nero opaco",
"MATTE_WHITE": "Bianco opaco",
"MATTE_GRAY": "Grigio opaco"

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.82" y="131.82" width="5.02" height="5.25"/>
<rect x="110.82" y="121.37" width="5.02" height="5.25"/>
<rect x="110.82" y="110.91" width="5.02" height="5.25"/>
<rect x="110.82" y="100.46" width="5.02" height="5.21"/>
<rect x="110.82" y="89.97" width="5.02" height="5.25"/>
<rect x="110.82" y="79.51" width="5.02" height="5.25"/>
<rect x="110.82" y="69.06" width="5.02" height="5.25"/>
<rect x="110.82" y="58.6" width="5.02" height="5.21"/>
<rect x="110.82" y="48.15" width="5.02" height="5.21"/>
<rect x="110.82" y="37.66" width="5.02" height="5.25"/>
<rect x="110.82" y="27.2" width="5.02" height="5.25"/>
<rect x="110.82" y="16.75" width="5.02" height="5.21"/>
<rect x="105.79" y="142.31" width="5.02" height="5.21"/>
<rect x="105.79" y="131.82" width="5.02" height="5.25"/>
<rect x="105.79" y="121.37" width="5.02" height="5.25"/>
<rect x="105.79" y="110.91" width="5.02" height="5.25"/>
<rect x="105.79" y="100.46" width="5.02" height="5.21"/>
<rect x="105.79" y="89.97" width="5.02" height="5.25"/>
<rect x="105.79" y="79.51" width="5.02" height="5.25"/>
<rect x="105.79" y="69.06" width="5.02" height="5.25"/>
<rect x="105.79" y="58.6" width="5.02" height="5.21"/>
<rect x="105.79" y="48.15" width="5.02" height="5.21"/>
<rect x="105.79" y="37.66" width="5.02" height="5.25"/>
<rect x="105.79" y="27.2" width="5.02" height="5.25"/>
<rect x="105.79" y="16.75" width="5.02" height="5.21"/>
<rect x="105.79" y="6.29" width="5.02" height="5.21"/>
<rect x="100.77" y="142.31" width="5.02" height="5.21"/>
<rect x="100.77" y="131.82" width="5.02" height="5.25"/>
<rect x="100.77" y="121.37" width="5.02" height="5.25"/>
<rect x="100.77" y="110.91" width="5.02" height="5.25"/>
<rect x="100.77" y="100.46" width="5.02" height="5.21"/>
<rect x="100.77" y="89.97" width="5.02" height="5.25"/>
<rect x="100.77" y="79.51" width="5.02" height="5.25"/>
<rect x="100.77" y="69.06" width="5.02" height="5.25"/>
<rect x="100.77" y="58.6" width="5.02" height="5.21"/>
<rect x="100.77" y="48.15" width="5.02" height="5.21"/>
<rect x="100.77" y="37.66" width="5.02" height="5.25"/>
<rect x="100.77" y="27.2" width="5.02" height="5.25"/>
<rect x="100.77" y="16.75" width="5.02" height="5.21"/>
<rect x="100.77" y="6.29" width="5.02" height="5.21"/>
<rect x="95.75" y="142.31" width="5.02" height="5.21"/>
<rect x="95.75" y="131.82" width="5.02" height="5.25"/>
<rect x="95.75" y="121.37" width="5.02" height="5.25"/>
<rect x="95.75" y="110.91" width="5.02" height="5.25"/>
<rect x="95.75" y="100.46" width="5.02" height="5.21"/>
<rect x="95.75" y="89.97" width="5.02" height="5.25"/>
<rect x="95.75" y="79.51" width="5.02" height="5.25"/>
<rect x="95.75" y="69.06" width="5.02" height="5.25"/>
<rect x="95.75" y="58.6" width="5.02" height="5.21"/>
<rect x="95.75" y="48.15" width="5.02" height="5.21"/>
<rect x="95.75" y="37.66" width="5.02" height="5.25"/>
<rect x="95.75" y="27.2" width="5.02" height="5.25"/>
<rect x="95.75" y="16.75" width="5.02" height="5.21"/>
<rect x="95.75" y="6.29" width="5.02" height="5.21"/>
<rect x="90.72" y="142.31" width="5.02" height="5.21"/>
<rect x="90.72" y="131.82" width="5.02" height="5.25"/>
<rect x="90.72" y="121.37" width="5.02" height="5.25"/>
<rect x="90.72" y="110.91" width="5.02" height="5.25"/>
<rect x="90.72" y="100.46" width="5.02" height="5.21"/>
<rect x="90.72" y="89.97" width="5.02" height="5.25"/>
<rect x="90.72" y="79.51" width="5.02" height="5.25"/>
<rect x="90.72" y="69.06" width="5.02" height="5.25"/>
<rect x="90.72" y="58.6" width="5.02" height="5.21"/>
<rect x="90.72" y="48.15" width="5.02" height="5.21"/>
<rect x="90.72" y="37.66" width="5.02" height="5.25"/>
<rect x="90.72" y="27.2" width="5.02" height="5.25"/>
<rect x="90.72" y="16.75" width="5.02" height="5.21"/>
<rect x="90.72" y="6.29" width="5.02" height="5.21"/>
<rect x="85.7" y="142.31" width="5.02" height="5.21"/>
<rect x="85.7" y="131.82" width="5.02" height="5.25"/>
<rect x="85.7" y="121.37" width="5.02" height="5.25"/>
<rect x="85.7" y="110.91" width="5.02" height="5.25"/>
<rect x="85.7" y="100.46" width="5.02" height="5.21"/>
<rect x="85.7" y="89.97" width="5.02" height="5.25"/>
<rect x="85.7" y="79.51" width="5.02" height="5.25"/>
<rect x="85.7" y="69.06" width="5.02" height="5.25"/>
<rect x="85.7" y="58.6" width="5.02" height="5.21"/>
<rect x="85.7" y="48.15" width="5.02" height="5.21"/>
<rect x="85.7" y="37.66" width="5.02" height="5.25"/>
<rect x="85.7" y="27.2" width="5.02" height="5.25"/>
<rect x="85.7" y="16.75" width="5.02" height="5.21"/>
<rect x="85.7" y="6.29" width="5.02" height="5.21"/>
<rect x="80.67" y="142.31" width="5.02" height="5.21"/>
<rect x="80.67" y="131.82" width="5.02" height="5.25"/>
<rect x="80.67" y="121.37" width="5.02" height="5.25"/>
<rect x="80.67" y="79.51" width="5.02" height="5.25"/>
<rect x="80.67" y="69.06" width="5.02" height="5.25"/>
<rect x="80.67" y="27.2" width="5.02" height="5.25"/>
<rect x="80.67" y="16.75" width="5.02" height="5.21"/>
<rect x="80.67" y="6.29" width="5.02" height="5.21"/>
<rect x="75.65" y="142.31" width="5.02" height="5.21"/>
<rect x="75.65" y="131.82" width="5.02" height="5.25"/>
<rect x="75.65" y="121.37" width="5.02" height="5.25"/>
<rect x="75.65" y="79.51" width="5.02" height="5.25"/>
<rect x="75.65" y="69.06" width="5.02" height="5.25"/>
<rect x="75.65" y="27.2" width="5.02" height="5.25"/>
<rect x="75.65" y="16.75" width="5.02" height="5.21"/>
<rect x="75.65" y="6.29" width="5.02" height="5.21"/>
<rect x="70.63" y="142.31" width="5.02" height="5.21"/>
<rect x="70.63" y="131.82" width="5.02" height="5.25"/>
<rect x="70.63" y="121.37" width="5.02" height="5.25"/>
<rect x="70.63" y="79.51" width="5.02" height="5.25"/>
<rect x="70.63" y="69.06" width="5.02" height="5.25"/>
<rect x="70.63" y="27.2" width="5.02" height="5.25"/>
<rect x="70.63" y="16.75" width="5.02" height="5.21"/>
<rect x="70.63" y="6.29" width="5.02" height="5.21"/>
<rect x="65.6" y="142.31" width="5.02" height="5.21"/>
<rect x="65.6" y="131.82" width="5.02" height="5.25"/>
<rect x="65.6" y="121.37" width="5.02" height="5.25"/>
<rect x="65.6" y="79.51" width="5.02" height="5.25"/>
<rect x="65.6" y="69.06" width="5.02" height="5.25"/>
<rect x="65.6" y="27.2" width="5.02" height="5.25"/>
<rect x="65.6" y="16.75" width="5.02" height="5.21"/>
<rect x="65.6" y="6.29" width="5.02" height="5.21"/>
<rect x="60.58" y="142.31" width="5.02" height="5.21"/>
<rect x="60.58" y="131.82" width="5.02" height="5.25"/>
<rect x="60.58" y="121.37" width="5.02" height="5.25"/>
<rect x="60.58" y="79.51" width="5.02" height="5.25"/>
<rect x="60.58" y="69.06" width="5.02" height="5.25"/>
<rect x="60.58" y="27.2" width="5.02" height="5.25"/>
<rect x="60.58" y="16.75" width="5.02" height="5.21"/>
<rect x="60.58" y="6.29" width="5.02" height="5.21"/>
<rect x="55.55" y="142.31" width="5.02" height="5.21"/>
<rect x="55.55" y="131.82" width="5.02" height="5.25"/>
<rect x="55.55" y="121.37" width="5.02" height="5.25"/>
<rect x="55.55" y="79.51" width="5.02" height="5.25"/>
<rect x="55.55" y="69.06" width="5.02" height="5.25"/>
<rect x="55.55" y="27.2" width="5.02" height="5.25"/>
<rect x="55.55" y="16.75" width="5.02" height="5.21"/>
<rect x="55.55" y="6.29" width="5.02" height="5.21"/>
<rect x="50.53" y="142.31" width="5.02" height="5.21"/>
<rect x="50.53" y="131.82" width="5.02" height="5.25"/>
<rect x="50.53" y="121.37" width="5.02" height="5.25"/>
<rect x="50.53" y="79.51" width="5.02" height="5.25"/>
<rect x="50.53" y="69.06" width="5.02" height="5.25"/>
<rect x="50.53" y="27.2" width="5.02" height="5.25"/>
<rect x="50.53" y="16.75" width="5.02" height="5.21"/>
<rect x="50.53" y="6.29" width="5.02" height="5.21"/>
<rect x="45.51" y="142.31" width="5.02" height="5.21"/>
<rect x="45.51" y="131.82" width="5.02" height="5.25"/>
<rect x="45.51" y="121.37" width="5.02" height="5.25"/>
<rect x="45.51" y="27.2" width="5.02" height="5.25"/>
<rect x="45.51" y="16.75" width="5.02" height="5.21"/>
<rect x="45.51" y="6.29" width="5.02" height="5.21"/>
<rect x="40.44" y="142.31" width="5.06" height="5.21"/>
<rect x="40.44" y="131.82" width="5.06" height="5.25"/>
<rect x="40.44" y="121.37" width="5.06" height="5.25"/>
<rect x="40.44" y="27.2" width="5.06" height="5.25"/>
<rect x="40.44" y="16.75" width="5.06" height="5.21"/>
<rect x="40.44" y="6.29" width="5.06" height="5.21"/>
<rect x="35.42" y="142.31" width="5.02" height="5.21"/>
<rect x="35.42" y="131.82" width="5.02" height="5.25"/>
<rect x="35.42" y="121.37" width="5.02" height="5.25"/>
<rect x="35.42" y="27.2" width="5.02" height="5.25"/>
<rect x="35.42" y="16.75" width="5.02" height="5.21"/>
<rect x="35.42" y="6.29" width="5.02" height="5.21"/>
<rect x="30.4" y="142.31" width="5.02" height="5.21"/>
<rect x="30.4" y="131.82" width="5.02" height="5.25"/>
<rect x="30.4" y="121.37" width="5.02" height="5.25"/>
<rect x="30.4" y="27.2" width="5.02" height="5.25"/>
<rect x="30.4" y="16.75" width="5.02" height="5.21"/>
<rect x="30.4" y="6.29" width="5.02" height="5.21"/>
<rect x="25.37" y="142.31" width="5.02" height="5.21"/>
<rect x="25.37" y="131.82" width="5.02" height="5.25"/>
<rect x="25.37" y="121.37" width="5.02" height="5.25"/>
<rect x="25.37" y="27.2" width="5.02" height="5.25"/>
<rect x="25.37" y="16.75" width="5.02" height="5.21"/>
<rect x="25.37" y="6.29" width="5.02" height="5.21"/>
<rect x="20.35" y="142.31" width="5.02" height="5.21"/>
<rect x="20.35" y="131.82" width="5.02" height="5.25"/>
<rect x="20.35" y="121.37" width="5.02" height="5.25"/>
<rect x="20.35" y="27.2" width="5.02" height="5.25"/>
<rect x="20.35" y="16.75" width="5.02" height="5.21"/>
<rect x="20.35" y="6.29" width="5.02" height="5.21"/>
<rect x="15.32" y="142.31" width="5.02" height="5.21"/>
<rect x="15.32" y="131.82" width="5.02" height="5.25"/>
<rect x="15.32" y="121.37" width="5.02" height="5.25"/>
<rect x="15.32" y="27.2" width="5.02" height="5.25"/>
<rect x="15.32" y="16.75" width="5.02" height="5.21"/>
<rect x="15.32" y="6.29" width="5.02" height="5.21"/>
<rect x="10.3" y="131.82" width="5.02" height="5.25"/>
<rect x="10.3" y="16.75" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<defs>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.83" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.69" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.69" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.66" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.66" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.64" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.64" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.58" y="142.31" width="5.06" height="5.21"/>
<rect class="cls-1" x="65.58" y="131.82" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="121.37" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="79.51" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="69.06" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="27.2" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="16.75" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="6.29" width="5.06" height="5.21"/>
<rect class="cls-1" x="60.55" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.55" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.53" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.53" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.5" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.5" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.48" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.48" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.46" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.46" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.43" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.43" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.41" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.41" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.38" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.38" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.36" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.36" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.34" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.34" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.31" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.31" y="16.75" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,225 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="115.73" y="142.22" width="3.4" height="5.28"/>
<rect x="112.33" y="142.22" width="3.4" height="5.28"/>
<rect x="112.33" y="131.72" width="3.4" height="5.25"/>
<rect x="108.93" y="142.22" width="3.4" height="5.28"/>
<rect x="108.93" y="131.72" width="3.4" height="5.25"/>
<rect x="108.93" y="121.23" width="3.4" height="5.25"/>
<rect x="105.53" y="142.22" width="3.4" height="5.28"/>
<rect x="105.53" y="131.72" width="3.4" height="5.25"/>
<rect x="105.53" y="121.23" width="3.4" height="5.25"/>
<rect x="105.53" y="110.74" width="3.4" height="5.25"/>
<rect x="102.14" y="142.22" width="3.4" height="5.28"/>
<rect x="102.14" y="131.72" width="3.4" height="5.25"/>
<rect x="102.14" y="121.23" width="3.4" height="5.25"/>
<rect x="102.14" y="110.74" width="3.4" height="5.25"/>
<rect x="102.14" y="100.25" width="3.4" height="5.25"/>
<rect x="98.74" y="142.22" width="3.4" height="5.28"/>
<rect x="98.74" y="131.72" width="3.4" height="5.25"/>
<rect x="98.74" y="121.23" width="3.4" height="5.25"/>
<rect x="98.74" y="110.74" width="3.4" height="5.25"/>
<rect x="98.74" y="100.25" width="3.4" height="5.25"/>
<rect x="98.74" y="89.76" width="3.4" height="5.25"/>
<rect x="95.34" y="142.22" width="3.4" height="5.28"/>
<rect x="95.34" y="131.72" width="3.4" height="5.25"/>
<rect x="95.34" y="121.23" width="3.4" height="5.25"/>
<rect x="95.34" y="110.74" width="3.4" height="5.25"/>
<rect x="95.34" y="100.25" width="3.4" height="5.25"/>
<rect x="95.34" y="89.76" width="3.4" height="5.25"/>
<rect x="95.34" y="79.27" width="3.4" height="5.25"/>
<rect x="91.94" y="131.72" width="3.4" height="5.25"/>
<rect x="91.94" y="121.23" width="3.4" height="5.25"/>
<rect x="91.94" y="110.74" width="3.4" height="5.25"/>
<rect x="91.94" y="100.25" width="3.4" height="5.25"/>
<rect x="91.94" y="89.76" width="3.4" height="5.25"/>
<rect x="91.94" y="79.27" width="3.4" height="5.25"/>
<rect x="91.94" y="68.74" width="3.4" height="5.28"/>
<rect x="88.54" y="121.23" width="3.4" height="5.25"/>
<rect x="88.54" y="110.74" width="3.4" height="5.25"/>
<rect x="88.54" y="100.25" width="3.4" height="5.25"/>
<rect x="88.54" y="89.76" width="3.4" height="5.25"/>
<rect x="88.54" y="79.27" width="3.4" height="5.25"/>
<rect x="88.54" y="68.74" width="3.4" height="5.28"/>
<rect x="88.54" y="58.25" width="3.4" height="5.25"/>
<rect x="85.14" y="110.74" width="3.4" height="5.25"/>
<rect x="85.14" y="100.25" width="3.4" height="5.25"/>
<rect x="85.14" y="89.76" width="3.4" height="5.25"/>
<rect x="85.14" y="79.27" width="3.4" height="5.25"/>
<rect x="85.14" y="68.74" width="3.4" height="5.28"/>
<rect x="85.14" y="58.25" width="3.4" height="5.25"/>
<rect x="85.14" y="47.75" width="3.4" height="5.25"/>
<rect x="81.74" y="100.25" width="3.4" height="5.25"/>
<rect x="81.74" y="89.76" width="3.4" height="5.25"/>
<rect x="81.74" y="79.27" width="3.4" height="5.25"/>
<rect x="81.74" y="68.74" width="3.4" height="5.28"/>
<rect x="81.74" y="58.25" width="3.4" height="5.25"/>
<rect x="81.74" y="47.75" width="3.4" height="5.25"/>
<rect x="81.74" y="37.26" width="3.4" height="5.25"/>
<rect x="78.38" y="100.25" width="3.36" height="5.25"/>
<rect x="78.38" y="89.76" width="3.36" height="5.25"/>
<rect x="78.38" y="79.27" width="3.36" height="5.25"/>
<rect x="78.38" y="68.74" width="3.36" height="5.28"/>
<rect x="78.38" y="58.25" width="3.36" height="5.25"/>
<rect x="78.38" y="47.75" width="3.36" height="5.25"/>
<rect x="78.38" y="37.26" width="3.36" height="5.25"/>
<rect x="78.38" y="26.77" width="3.36" height="5.25"/>
<rect x="74.98" y="100.25" width="3.4" height="5.25"/>
<rect x="74.98" y="89.76" width="3.4" height="5.25"/>
<rect x="74.98" y="79.27" width="3.4" height="5.25"/>
<rect x="74.98" y="68.74" width="3.4" height="5.28"/>
<rect x="74.98" y="58.25" width="3.4" height="5.25"/>
<rect x="74.98" y="47.75" width="3.4" height="5.25"/>
<rect x="74.98" y="37.26" width="3.4" height="5.25"/>
<rect x="74.98" y="26.77" width="3.4" height="5.25"/>
<rect x="74.98" y="16.28" width="3.4" height="5.25"/>
<rect x="71.58" y="100.25" width="3.4" height="5.25"/>
<rect x="71.58" y="89.76" width="3.4" height="5.25"/>
<rect x="71.58" y="68.74" width="3.4" height="5.28"/>
<rect x="71.58" y="58.25" width="3.4" height="5.25"/>
<rect x="71.58" y="47.75" width="3.4" height="5.25"/>
<rect x="71.58" y="37.26" width="3.4" height="5.25"/>
<rect x="71.58" y="26.77" width="3.4" height="5.25"/>
<rect x="71.58" y="16.28" width="3.4" height="5.25"/>
<rect x="71.58" y="5.79" width="3.4" height="5.25"/>
<rect x="68.19" y="100.25" width="3.4" height="5.25"/>
<rect x="68.19" y="89.76" width="3.4" height="5.25"/>
<rect x="68.19" y="58.25" width="3.4" height="5.25"/>
<rect x="68.19" y="47.75" width="3.4" height="5.25"/>
<rect x="68.19" y="37.26" width="3.4" height="5.25"/>
<rect x="68.19" y="26.77" width="3.4" height="5.25"/>
<rect x="68.19" y="16.28" width="3.4" height="5.25"/>
<rect x="68.19" y="5.79" width="3.4" height="5.25"/>
<rect x="64.79" y="100.25" width="3.4" height="5.25"/>
<rect x="64.79" y="89.76" width="3.4" height="5.25"/>
<rect x="64.79" y="47.75" width="3.4" height="5.25"/>
<rect x="64.79" y="37.26" width="3.4" height="5.25"/>
<rect x="64.79" y="26.77" width="3.4" height="5.25"/>
<rect x="64.79" y="16.28" width="3.4" height="5.25"/>
<rect x="64.79" y="5.79" width="3.4" height="5.25"/>
<rect x="61.39" y="100.25" width="3.4" height="5.25"/>
<rect x="61.39" y="89.76" width="3.4" height="5.25"/>
<rect x="61.39" y="37.26" width="3.4" height="5.25"/>
<rect x="61.39" y="26.77" width="3.4" height="5.25"/>
<rect x="61.39" y="16.28" width="3.4" height="5.25"/>
<rect x="61.39" y="5.79" width="3.4" height="5.25"/>
<rect x="57.99" y="100.25" width="3.4" height="5.25"/>
<rect x="57.99" y="89.76" width="3.4" height="5.25"/>
<rect x="57.99" y="47.75" width="3.4" height="5.25"/>
<rect x="57.99" y="37.26" width="3.4" height="5.25"/>
<rect x="57.99" y="26.77" width="3.4" height="5.25"/>
<rect x="57.99" y="16.28" width="3.4" height="5.25"/>
<rect x="57.99" y="5.79" width="3.4" height="5.25"/>
<rect x="54.59" y="100.25" width="3.4" height="5.25"/>
<rect x="54.59" y="89.76" width="3.4" height="5.25"/>
<rect x="54.59" y="58.25" width="3.4" height="5.25"/>
<rect x="54.59" y="47.75" width="3.4" height="5.25"/>
<rect x="54.59" y="37.26" width="3.4" height="5.25"/>
<rect x="54.59" y="26.77" width="3.4" height="5.25"/>
<rect x="54.59" y="16.28" width="3.4" height="5.25"/>
<rect x="54.59" y="5.79" width="3.4" height="5.25"/>
<rect x="51.19" y="100.25" width="3.4" height="5.25"/>
<rect x="51.19" y="89.76" width="3.4" height="5.25"/>
<rect x="51.19" y="68.74" width="3.4" height="5.28"/>
<rect x="51.19" y="58.25" width="3.4" height="5.25"/>
<rect x="51.19" y="47.75" width="3.4" height="5.25"/>
<rect x="51.19" y="37.26" width="3.4" height="5.25"/>
<rect x="51.19" y="26.77" width="3.4" height="5.25"/>
<rect x="51.19" y="16.28" width="3.4" height="5.25"/>
<rect x="51.19" y="5.79" width="3.4" height="5.25"/>
<rect x="47.79" y="100.25" width="3.4" height="5.25"/>
<rect x="47.79" y="89.76" width="3.4" height="5.25"/>
<rect x="47.79" y="79.27" width="3.4" height="5.25"/>
<rect x="47.79" y="68.74" width="3.4" height="5.28"/>
<rect x="47.79" y="58.25" width="3.4" height="5.25"/>
<rect x="47.79" y="47.75" width="3.4" height="5.25"/>
<rect x="47.79" y="37.26" width="3.4" height="5.25"/>
<rect x="47.79" y="26.77" width="3.4" height="5.25"/>
<rect x="47.79" y="16.28" width="3.4" height="5.25"/>
<rect x="44.39" y="100.25" width="3.4" height="5.25"/>
<rect x="44.39" y="89.76" width="3.4" height="5.25"/>
<rect x="44.39" y="79.27" width="3.4" height="5.25"/>
<rect x="44.39" y="68.74" width="3.4" height="5.28"/>
<rect x="44.39" y="58.25" width="3.4" height="5.25"/>
<rect x="44.39" y="47.75" width="3.4" height="5.25"/>
<rect x="44.39" y="37.26" width="3.4" height="5.25"/>
<rect x="44.39" y="26.77" width="3.4" height="5.25"/>
<rect x="41" y="100.25" width="3.4" height="5.25"/>
<rect x="41" y="89.76" width="3.4" height="5.25"/>
<rect x="41" y="79.27" width="3.4" height="5.25"/>
<rect x="41" y="68.74" width="3.4" height="5.28"/>
<rect x="41" y="58.25" width="3.4" height="5.25"/>
<rect x="41" y="47.75" width="3.4" height="5.25"/>
<rect x="41" y="37.26" width="3.4" height="5.25"/>
<rect x="37.6" y="110.74" width="3.4" height="5.25"/>
<rect x="37.6" y="100.25" width="3.4" height="5.25"/>
<rect x="37.6" y="89.76" width="3.4" height="5.25"/>
<rect x="37.6" y="79.27" width="3.4" height="5.25"/>
<rect x="37.6" y="68.74" width="3.4" height="5.28"/>
<rect x="37.6" y="58.25" width="3.4" height="5.25"/>
<rect x="37.6" y="47.75" width="3.4" height="5.25"/>
<rect x="34.2" y="121.23" width="3.4" height="5.25"/>
<rect x="34.2" y="110.74" width="3.4" height="5.25"/>
<rect x="34.2" y="100.25" width="3.4" height="5.25"/>
<rect x="34.2" y="89.76" width="3.4" height="5.25"/>
<rect x="34.2" y="79.27" width="3.4" height="5.25"/>
<rect x="34.2" y="68.74" width="3.4" height="5.28"/>
<rect x="34.2" y="58.25" width="3.4" height="5.25"/>
<rect x="30.8" y="131.72" width="3.4" height="5.25"/>
<rect x="30.8" y="121.23" width="3.4" height="5.25"/>
<rect x="30.8" y="110.74" width="3.4" height="5.25"/>
<rect x="30.8" y="100.25" width="3.4" height="5.25"/>
<rect x="30.8" y="89.76" width="3.4" height="5.25"/>
<rect x="30.8" y="79.27" width="3.4" height="5.25"/>
<rect x="30.8" y="68.74" width="3.4" height="5.28"/>
<rect x="27.4" y="142.22" width="3.4" height="5.28"/>
<rect x="27.4" y="131.72" width="3.4" height="5.25"/>
<rect x="27.4" y="121.23" width="3.4" height="5.25"/>
<rect x="27.4" y="110.74" width="3.4" height="5.25"/>
<rect x="27.4" y="100.25" width="3.4" height="5.25"/>
<rect x="27.4" y="89.76" width="3.4" height="5.25"/>
<rect x="27.4" y="79.27" width="3.4" height="5.25"/>
<rect x="24.04" y="142.22" width="3.36" height="5.28"/>
<rect x="24.04" y="131.72" width="3.36" height="5.25"/>
<rect x="24.04" y="121.23" width="3.36" height="5.25"/>
<rect x="24.04" y="110.74" width="3.36" height="5.25"/>
<rect x="24.04" y="100.25" width="3.36" height="5.25"/>
<rect x="24.04" y="89.76" width="3.36" height="5.25"/>
<rect x="20.64" y="142.22" width="3.4" height="5.28"/>
<rect x="20.64" y="131.72" width="3.4" height="5.25"/>
<rect x="20.64" y="121.23" width="3.4" height="5.25"/>
<rect x="20.64" y="110.74" width="3.4" height="5.25"/>
<rect x="20.64" y="100.25" width="3.4" height="5.25"/>
<rect x="17.24" y="142.22" width="3.4" height="5.28"/>
<rect x="17.24" y="131.72" width="3.4" height="5.25"/>
<rect x="17.24" y="121.23" width="3.4" height="5.25"/>
<rect x="17.24" y="110.74" width="3.4" height="5.25"/>
<rect x="13.84" y="142.22" width="3.4" height="5.28"/>
<rect x="13.84" y="131.72" width="3.4" height="5.25"/>
<rect x="13.84" y="121.23" width="3.4" height="5.25"/>
<rect x="10.45" y="142.22" width="3.4" height="5.28"/>
<rect x="10.45" y="131.72" width="3.4" height="5.25"/>
<rect x="7.05" y="142.22" width="3.4" height="5.28"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 164">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="115.71" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="112.34" y="142.77" width="3.36" height="5.25"/>
<rect class="cls-1" x="112.34" y="132.24" width="3.36" height="5.25"/>
<rect class="cls-1" x="108.94" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="108.94" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="108.94" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="61.4" y="100.77" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="90.28" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="37.78" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="27.29" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="16.8" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="6.31" width="3.36" height="5.25"/>
<rect class="cls-1" x="58" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="10.42" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="10.42" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="7.06" y="142.77" width="3.36" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,232 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129.61 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect y="157.08" width="125.66" height="1.05"/>
<rect y="146.6" width="125.66" height="1.05"/>
<rect y="136.13" width="125.66" height="1.05"/>
<rect y="125.66" width="125.66" height="1.05"/>
<rect y="115.19" width="125.66" height="1.05"/>
<rect y="104.72" width="125.66" height="1.05"/>
<rect y="94.25" width="125.66" height="1.05"/>
<rect y="83.77" width="125.66" height="1.05"/>
<rect y="73.3" width="125.66" height="1.05"/>
<rect y="62.83" width="125.66" height="1.05"/>
<rect y="52.36" width="125.66" height="1.05"/>
<rect y="41.89" width="125.66" height="1.05"/>
<rect y="31.42" width="125.66" height="1.05"/>
<rect y="20.94" width="125.66" height="1.05"/>
<rect y="10.47" width="125.66" height="1.05"/>
<rect width="125.66" height="1.05"/>
</g>
<g>
<rect x="114.29" y="121.36" width="5.02" height="5.25"/>
<rect x="114.29" y="110.9" width="5.02" height="5.25"/>
<rect x="114.29" y="100.45" width="5.02" height="5.25"/>
<rect x="109.27" y="131.85" width="5.02" height="5.21"/>
<rect x="109.27" y="121.36" width="5.02" height="5.25"/>
<rect x="109.27" y="110.9" width="5.02" height="5.25"/>
<rect x="109.27" y="100.45" width="5.02" height="5.25"/>
<rect x="109.27" y="89.99" width="5.02" height="5.21"/>
<rect x="109.27" y="48.14" width="5.02" height="5.21"/>
<rect x="109.27" y="37.68" width="5.02" height="5.21"/>
<rect x="109.27" y="27.19" width="5.02" height="5.25"/>
<rect x="104.21" y="131.85" width="5.06" height="5.21"/>
<rect x="104.21" y="121.36" width="5.06" height="5.25"/>
<rect x="104.21" y="110.9" width="5.06" height="5.25"/>
<rect x="104.21" y="100.45" width="5.06" height="5.25"/>
<rect x="104.21" y="89.99" width="5.06" height="5.21"/>
<rect x="104.21" y="58.59" width="5.06" height="5.25"/>
<rect x="104.21" y="48.14" width="5.06" height="5.21"/>
<rect x="104.21" y="37.68" width="5.06" height="5.21"/>
<rect x="104.21" y="27.19" width="5.06" height="5.25"/>
<rect x="104.21" y="16.74" width="5.06" height="5.25"/>
<rect x="99.18" y="142.3" width="5.02" height="5.25"/>
<rect x="99.18" y="131.85" width="5.02" height="5.21"/>
<rect x="99.18" y="121.36" width="5.02" height="5.25"/>
<rect x="99.18" y="110.9" width="5.02" height="5.25"/>
<rect x="99.18" y="100.45" width="5.02" height="5.25"/>
<rect x="99.18" y="89.99" width="5.02" height="5.21"/>
<rect x="99.18" y="79.54" width="5.02" height="5.21"/>
<rect x="99.18" y="58.59" width="5.02" height="5.25"/>
<rect x="99.18" y="48.14" width="5.02" height="5.21"/>
<rect x="99.18" y="37.68" width="5.02" height="5.21"/>
<rect x="99.18" y="27.19" width="5.02" height="5.25"/>
<rect x="99.18" y="16.74" width="5.02" height="5.25"/>
<rect x="94.16" y="142.3" width="5.02" height="5.25"/>
<rect x="94.16" y="131.85" width="5.02" height="5.21"/>
<rect x="94.16" y="121.36" width="5.02" height="5.25"/>
<rect x="94.16" y="110.9" width="5.02" height="5.25"/>
<rect x="94.16" y="100.45" width="5.02" height="5.25"/>
<rect x="94.16" y="89.99" width="5.02" height="5.21"/>
<rect x="94.16" y="79.54" width="5.02" height="5.21"/>
<rect x="94.16" y="69.05" width="5.02" height="5.25"/>
<rect x="94.16" y="58.59" width="5.02" height="5.25"/>
<rect x="94.16" y="48.14" width="5.02" height="5.21"/>
<rect x="94.16" y="37.68" width="5.02" height="5.21"/>
<rect x="94.16" y="27.19" width="5.02" height="5.25"/>
<rect x="94.16" y="16.74" width="5.02" height="5.25"/>
<rect x="94.16" y="6.28" width="5.02" height="5.25"/>
<rect x="89.13" y="142.3" width="5.02" height="5.25"/>
<rect x="89.13" y="131.85" width="5.02" height="5.21"/>
<rect x="89.13" y="121.36" width="5.02" height="5.25"/>
<rect x="89.13" y="110.9" width="5.02" height="5.25"/>
<rect x="89.13" y="100.45" width="5.02" height="5.25"/>
<rect x="89.13" y="89.99" width="5.02" height="5.21"/>
<rect x="89.13" y="79.54" width="5.02" height="5.21"/>
<rect x="89.13" y="69.05" width="5.02" height="5.25"/>
<rect x="89.13" y="58.59" width="5.02" height="5.25"/>
<rect x="89.13" y="48.14" width="5.02" height="5.21"/>
<rect x="89.13" y="37.68" width="5.02" height="5.21"/>
<rect x="89.13" y="27.19" width="5.02" height="5.25"/>
<rect x="89.13" y="16.74" width="5.02" height="5.25"/>
<rect x="89.13" y="6.28" width="5.02" height="5.25"/>
<rect x="84.11" y="142.3" width="5.02" height="5.25"/>
<rect x="84.11" y="131.85" width="5.02" height="5.21"/>
<rect x="84.11" y="121.36" width="5.02" height="5.25"/>
<rect x="84.11" y="89.99" width="5.02" height="5.21"/>
<rect x="84.11" y="79.54" width="5.02" height="5.21"/>
<rect x="84.11" y="69.05" width="5.02" height="5.25"/>
<rect x="84.11" y="58.59" width="5.02" height="5.25"/>
<rect x="84.11" y="48.14" width="5.02" height="5.21"/>
<rect x="84.11" y="37.68" width="5.02" height="5.21"/>
<rect x="84.11" y="27.19" width="5.02" height="5.25"/>
<rect x="84.11" y="16.74" width="5.02" height="5.25"/>
<rect x="84.11" y="6.28" width="5.02" height="5.25"/>
<rect x="79.09" y="142.3" width="5.02" height="5.25"/>
<rect x="79.09" y="131.85" width="5.02" height="5.21"/>
<rect x="79.09" y="79.54" width="5.02" height="5.21"/>
<rect x="79.09" y="69.05" width="5.02" height="5.25"/>
<rect x="79.09" y="58.59" width="5.02" height="5.25"/>
<rect x="79.09" y="27.19" width="5.02" height="5.25"/>
<rect x="79.09" y="16.74" width="5.02" height="5.25"/>
<rect x="79.09" y="6.28" width="5.02" height="5.25"/>
<rect x="74.06" y="142.3" width="5.02" height="5.25"/>
<rect x="74.06" y="131.85" width="5.02" height="5.21"/>
<rect x="74.06" y="79.54" width="5.02" height="5.21"/>
<rect x="74.06" y="69.05" width="5.02" height="5.25"/>
<rect x="74.06" y="16.74" width="5.02" height="5.25"/>
<rect x="74.06" y="6.28" width="5.02" height="5.25"/>
<rect x="69.04" y="142.3" width="5.02" height="5.25"/>
<rect x="69.04" y="131.85" width="5.02" height="5.21"/>
<rect x="69.04" y="79.54" width="5.02" height="5.21"/>
<rect x="69.04" y="69.05" width="5.02" height="5.25"/>
<rect x="69.04" y="16.74" width="5.02" height="5.25"/>
<rect x="69.04" y="6.28" width="5.02" height="5.25"/>
<rect x="64.01" y="142.3" width="5.02" height="5.25"/>
<rect x="64.01" y="131.85" width="5.02" height="5.21"/>
<rect x="64.01" y="79.54" width="5.02" height="5.21"/>
<rect x="64.01" y="69.05" width="5.02" height="5.25"/>
<rect x="64.01" y="16.74" width="5.02" height="5.25"/>
<rect x="64.01" y="6.28" width="5.02" height="5.25"/>
<rect x="58.99" y="142.3" width="5.02" height="5.25"/>
<rect x="58.99" y="131.85" width="5.02" height="5.21"/>
<rect x="58.99" y="79.54" width="5.02" height="5.21"/>
<rect x="58.99" y="69.05" width="5.02" height="5.25"/>
<rect x="58.99" y="16.74" width="5.02" height="5.25"/>
<rect x="58.99" y="6.28" width="5.02" height="5.25"/>
<rect x="53.96" y="142.3" width="5.02" height="5.25"/>
<rect x="53.96" y="131.85" width="5.02" height="5.21"/>
<rect x="53.96" y="79.54" width="5.02" height="5.21"/>
<rect x="53.96" y="69.05" width="5.02" height="5.25"/>
<rect x="53.96" y="16.74" width="5.02" height="5.25"/>
<rect x="53.96" y="6.28" width="5.02" height="5.25"/>
<rect x="48.94" y="142.3" width="5.02" height="5.25"/>
<rect x="48.94" y="131.85" width="5.02" height="5.21"/>
<rect x="48.94" y="79.54" width="5.02" height="5.21"/>
<rect x="48.94" y="69.05" width="5.02" height="5.25"/>
<rect x="48.94" y="16.74" width="5.02" height="5.25"/>
<rect x="48.94" y="6.28" width="5.02" height="5.25"/>
<rect x="43.92" y="142.3" width="5.02" height="5.25"/>
<rect x="43.92" y="131.85" width="5.02" height="5.21"/>
<rect x="43.92" y="79.54" width="5.02" height="5.21"/>
<rect x="43.92" y="69.05" width="5.02" height="5.25"/>
<rect x="43.92" y="16.74" width="5.02" height="5.25"/>
<rect x="43.92" y="6.28" width="5.02" height="5.25"/>
<rect x="38.89" y="142.3" width="5.02" height="5.25"/>
<rect x="38.89" y="131.85" width="5.02" height="5.21"/>
<rect x="38.89" y="121.36" width="5.02" height="5.25"/>
<rect x="38.89" y="110.9" width="5.02" height="5.25"/>
<rect x="38.89" y="100.45" width="5.02" height="5.25"/>
<rect x="38.89" y="89.99" width="5.02" height="5.21"/>
<rect x="38.89" y="79.54" width="5.02" height="5.21"/>
<rect x="38.89" y="69.05" width="5.02" height="5.25"/>
<rect x="38.89" y="58.59" width="5.02" height="5.25"/>
<rect x="38.89" y="48.14" width="5.02" height="5.21"/>
<rect x="38.89" y="37.68" width="5.02" height="5.21"/>
<rect x="38.89" y="27.19" width="5.02" height="5.25"/>
<rect x="38.89" y="16.74" width="5.02" height="5.25"/>
<rect x="38.89" y="6.28" width="5.02" height="5.25"/>
<rect x="33.87" y="142.3" width="5.02" height="5.25"/>
<rect x="33.87" y="131.85" width="5.02" height="5.21"/>
<rect x="33.87" y="121.36" width="5.02" height="5.25"/>
<rect x="33.87" y="110.9" width="5.02" height="5.25"/>
<rect x="33.87" y="100.45" width="5.02" height="5.25"/>
<rect x="33.87" y="89.99" width="5.02" height="5.21"/>
<rect x="33.87" y="79.54" width="5.02" height="5.21"/>
<rect x="33.87" y="69.05" width="5.02" height="5.25"/>
<rect x="33.87" y="58.59" width="5.02" height="5.25"/>
<rect x="33.87" y="48.14" width="5.02" height="5.21"/>
<rect x="33.87" y="37.68" width="5.02" height="5.21"/>
<rect x="33.87" y="27.19" width="5.02" height="5.25"/>
<rect x="33.87" y="16.74" width="5.02" height="5.25"/>
<rect x="33.87" y="6.28" width="5.02" height="5.25"/>
<rect x="28.84" y="142.3" width="5.02" height="5.25"/>
<rect x="28.84" y="131.85" width="5.02" height="5.21"/>
<rect x="28.84" y="121.36" width="5.02" height="5.25"/>
<rect x="28.84" y="110.9" width="5.02" height="5.25"/>
<rect x="28.84" y="100.45" width="5.02" height="5.25"/>
<rect x="28.84" y="89.99" width="5.02" height="5.21"/>
<rect x="28.84" y="79.54" width="5.02" height="5.21"/>
<rect x="28.84" y="69.05" width="5.02" height="5.25"/>
<rect x="28.84" y="58.59" width="5.02" height="5.25"/>
<rect x="28.84" y="48.14" width="5.02" height="5.21"/>
<rect x="28.84" y="37.68" width="5.02" height="5.21"/>
<rect x="28.84" y="27.19" width="5.02" height="5.25"/>
<rect x="28.84" y="16.74" width="5.02" height="5.25"/>
<rect x="28.84" y="6.28" width="5.02" height="5.25"/>
<rect x="23.78" y="142.3" width="5.06" height="5.25"/>
<rect x="23.78" y="131.85" width="5.06" height="5.21"/>
<rect x="23.78" y="121.36" width="5.06" height="5.25"/>
<rect x="23.78" y="110.9" width="5.06" height="5.25"/>
<rect x="23.78" y="100.45" width="5.06" height="5.25"/>
<rect x="23.78" y="89.99" width="5.06" height="5.21"/>
<rect x="23.78" y="79.54" width="5.06" height="5.21"/>
<rect x="23.78" y="69.05" width="5.06" height="5.25"/>
<rect x="23.78" y="58.59" width="5.06" height="5.25"/>
<rect x="23.78" y="48.14" width="5.06" height="5.21"/>
<rect x="23.78" y="37.68" width="5.06" height="5.21"/>
<rect x="23.78" y="27.19" width="5.06" height="5.25"/>
<rect x="23.78" y="16.74" width="5.06" height="5.25"/>
<rect x="23.78" y="6.28" width="5.06" height="5.25"/>
<rect x="18.76" y="142.3" width="5.02" height="5.25"/>
<rect x="18.76" y="131.85" width="5.02" height="5.21"/>
<rect x="18.76" y="121.36" width="5.02" height="5.25"/>
<rect x="18.76" y="110.9" width="5.02" height="5.25"/>
<rect x="18.76" y="100.45" width="5.02" height="5.25"/>
<rect x="18.76" y="89.99" width="5.02" height="5.21"/>
<rect x="18.76" y="79.54" width="5.02" height="5.21"/>
<rect x="18.76" y="69.05" width="5.02" height="5.25"/>
<rect x="18.76" y="58.59" width="5.02" height="5.25"/>
<rect x="18.76" y="48.14" width="5.02" height="5.21"/>
<rect x="18.76" y="37.68" width="5.02" height="5.21"/>
<rect x="18.76" y="27.19" width="5.02" height="5.25"/>
<rect x="18.76" y="16.74" width="5.02" height="5.25"/>
<rect x="18.76" y="6.28" width="5.02" height="5.25"/>
<rect x="13.73" y="142.3" width="5.02" height="5.25"/>
<rect x="13.73" y="131.85" width="5.02" height="5.21"/>
<rect x="13.73" y="121.36" width="5.02" height="5.25"/>
<rect x="13.73" y="110.9" width="5.02" height="5.25"/>
<rect x="13.73" y="100.45" width="5.02" height="5.25"/>
<rect x="13.73" y="89.99" width="5.02" height="5.21"/>
<rect x="13.73" y="79.54" width="5.02" height="5.21"/>
<rect x="13.73" y="69.05" width="5.02" height="5.25"/>
<rect x="13.73" y="58.59" width="5.02" height="5.25"/>
<rect x="13.73" y="48.14" width="5.02" height="5.21"/>
<rect x="13.73" y="37.68" width="5.02" height="5.21"/>
<rect x="13.73" y="27.19" width="5.02" height="5.25"/>
<rect x="13.73" y="16.74" width="5.02" height="5.25"/>
<rect x="13.73" y="6.28" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,243 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129.61 163.63">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="114.27" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="114.27" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="114.27" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="79.1" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="79.1" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="74.07" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="74.07" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="69.05" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="69.05" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="64.03" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="64.03" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="59" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="59" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="53.98" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="53.98" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="48.92" y="142.45" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="131.99" width="5.06" height="5.21"/>
<rect class="cls-1" x="48.92" y="79.68" width="5.06" height="5.21"/>
<rect class="cls-1" x="48.92" y="69.19" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="16.88" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="6.43" width="5.06" height="5.25"/>
<rect class="cls-1" x="43.89" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="43.89" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="43.89" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="6.43" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,252 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.84" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.68" y="142.31" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="131.82" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="121.37" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="110.91" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="100.46" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="90" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="79.51" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="69.06" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="58.6" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="48.15" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="37.65" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="27.2" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="16.75" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="6.29" width="5.06" height="5.21"/>
<rect class="cls-1" x="80.66" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.66" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.63" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.63" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.61" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.61" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.59" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.59" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.56" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.56" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.54" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.54" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.51" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.51" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.49" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.49" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.47" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.47" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="6.29" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.85" y="27.19" width="5.02" height="5.25"/>
<rect x="110.85" y="16.73" width="5.02" height="5.25"/>
<rect x="110.85" y="6.28" width="5.02" height="5.25"/>
<rect x="105.82" y="27.19" width="5.02" height="5.25"/>
<rect x="105.82" y="16.73" width="5.02" height="5.25"/>
<rect x="105.82" y="6.28" width="5.02" height="5.25"/>
<rect x="100.8" y="27.19" width="5.02" height="5.25"/>
<rect x="100.8" y="16.73" width="5.02" height="5.25"/>
<rect x="100.8" y="6.28" width="5.02" height="5.25"/>
<rect x="95.74" y="27.19" width="5.06" height="5.25"/>
<rect x="95.74" y="16.73" width="5.06" height="5.25"/>
<rect x="95.74" y="6.28" width="5.06" height="5.25"/>
<rect x="90.71" y="27.19" width="5.02" height="5.25"/>
<rect x="90.71" y="16.73" width="5.02" height="5.25"/>
<rect x="90.71" y="6.28" width="5.02" height="5.25"/>
<rect x="85.69" y="27.19" width="5.02" height="5.25"/>
<rect x="85.69" y="16.73" width="5.02" height="5.25"/>
<rect x="85.69" y="6.28" width="5.02" height="5.25"/>
<rect x="80.66" y="27.19" width="5.02" height="5.25"/>
<rect x="80.66" y="16.73" width="5.02" height="5.25"/>
<rect x="80.66" y="6.28" width="5.02" height="5.25"/>
<rect x="75.64" y="69.04" width="5.02" height="5.25"/>
<rect x="75.64" y="58.59" width="5.02" height="5.25"/>
<rect x="75.64" y="27.19" width="5.02" height="5.25"/>
<rect x="75.64" y="16.73" width="5.02" height="5.25"/>
<rect x="75.64" y="6.28" width="5.02" height="5.25"/>
<rect x="70.62" y="69.04" width="5.02" height="5.25"/>
<rect x="70.62" y="58.59" width="5.02" height="5.25"/>
<rect x="70.62" y="27.19" width="5.02" height="5.25"/>
<rect x="70.62" y="16.73" width="5.02" height="5.25"/>
<rect x="70.62" y="6.28" width="5.02" height="5.25"/>
<rect x="65.59" y="69.04" width="5.02" height="5.25"/>
<rect x="65.59" y="58.59" width="5.02" height="5.25"/>
<rect x="65.59" y="27.19" width="5.02" height="5.25"/>
<rect x="65.59" y="16.73" width="5.02" height="5.25"/>
<rect x="65.59" y="6.28" width="5.02" height="5.25"/>
<rect x="60.57" y="69.04" width="5.02" height="5.25"/>
<rect x="60.57" y="58.59" width="5.02" height="5.25"/>
<rect x="60.57" y="27.19" width="5.02" height="5.25"/>
<rect x="60.57" y="16.73" width="5.02" height="5.25"/>
<rect x="60.57" y="6.28" width="5.02" height="5.25"/>
<rect x="55.54" y="69.04" width="5.02" height="5.25"/>
<rect x="55.54" y="58.59" width="5.02" height="5.25"/>
<rect x="55.54" y="27.19" width="5.02" height="5.25"/>
<rect x="55.54" y="16.73" width="5.02" height="5.25"/>
<rect x="55.54" y="6.28" width="5.02" height="5.25"/>
<rect x="50.52" y="69.04" width="5.02" height="5.25"/>
<rect x="50.52" y="58.59" width="5.02" height="5.25"/>
<rect x="50.52" y="27.19" width="5.02" height="5.25"/>
<rect x="50.52" y="16.73" width="5.02" height="5.25"/>
<rect x="50.52" y="6.28" width="5.02" height="5.25"/>
<rect x="45.49" y="69.04" width="5.02" height="5.25"/>
<rect x="45.49" y="58.59" width="5.02" height="5.25"/>
<rect x="45.49" y="27.19" width="5.02" height="5.25"/>
<rect x="45.49" y="16.73" width="5.02" height="5.25"/>
<rect x="45.49" y="6.28" width="5.02" height="5.25"/>
<rect x="40.47" y="69.04" width="5.02" height="5.25"/>
<rect x="40.47" y="58.59" width="5.02" height="5.25"/>
<rect x="40.47" y="27.19" width="5.02" height="5.25"/>
<rect x="40.47" y="16.73" width="5.02" height="5.25"/>
<rect x="40.47" y="6.28" width="5.02" height="5.25"/>
<rect x="35.45" y="142.3" width="5.02" height="5.25"/>
<rect x="35.45" y="131.85" width="5.02" height="5.21"/>
<rect x="35.45" y="121.39" width="5.02" height="5.21"/>
<rect x="35.45" y="110.9" width="5.02" height="5.25"/>
<rect x="35.45" y="100.44" width="5.02" height="5.25"/>
<rect x="35.45" y="89.99" width="5.02" height="5.21"/>
<rect x="35.45" y="79.54" width="5.02" height="5.21"/>
<rect x="35.45" y="69.04" width="5.02" height="5.25"/>
<rect x="35.45" y="58.59" width="5.02" height="5.25"/>
<rect x="35.45" y="48.13" width="5.02" height="5.25"/>
<rect x="35.45" y="37.68" width="5.02" height="5.21"/>
<rect x="35.45" y="27.19" width="5.02" height="5.25"/>
<rect x="35.45" y="16.73" width="5.02" height="5.25"/>
<rect x="35.45" y="6.28" width="5.02" height="5.25"/>
<rect x="30.42" y="142.3" width="5.02" height="5.25"/>
<rect x="30.42" y="131.85" width="5.02" height="5.21"/>
<rect x="30.42" y="121.39" width="5.02" height="5.21"/>
<rect x="30.42" y="110.9" width="5.02" height="5.25"/>
<rect x="30.42" y="100.44" width="5.02" height="5.25"/>
<rect x="30.42" y="89.99" width="5.02" height="5.21"/>
<rect x="30.42" y="79.54" width="5.02" height="5.21"/>
<rect x="30.42" y="69.04" width="5.02" height="5.25"/>
<rect x="30.42" y="58.59" width="5.02" height="5.25"/>
<rect x="30.42" y="48.13" width="5.02" height="5.25"/>
<rect x="30.42" y="37.68" width="5.02" height="5.21"/>
<rect x="30.42" y="27.19" width="5.02" height="5.25"/>
<rect x="30.42" y="16.73" width="5.02" height="5.25"/>
<rect x="30.42" y="6.28" width="5.02" height="5.25"/>
<rect x="25.4" y="142.3" width="5.02" height="5.25"/>
<rect x="25.4" y="131.85" width="5.02" height="5.21"/>
<rect x="25.4" y="121.39" width="5.02" height="5.21"/>
<rect x="25.4" y="110.9" width="5.02" height="5.25"/>
<rect x="25.4" y="100.44" width="5.02" height="5.25"/>
<rect x="25.4" y="89.99" width="5.02" height="5.21"/>
<rect x="25.4" y="79.54" width="5.02" height="5.21"/>
<rect x="25.4" y="69.04" width="5.02" height="5.25"/>
<rect x="25.4" y="58.59" width="5.02" height="5.25"/>
<rect x="25.4" y="48.13" width="5.02" height="5.25"/>
<rect x="25.4" y="37.68" width="5.02" height="5.21"/>
<rect x="25.4" y="27.19" width="5.02" height="5.25"/>
<rect x="25.4" y="16.73" width="5.02" height="5.25"/>
<rect x="25.4" y="6.28" width="5.02" height="5.25"/>
<rect x="20.34" y="142.3" width="5.06" height="5.25"/>
<rect x="20.34" y="131.85" width="5.06" height="5.21"/>
<rect x="20.34" y="121.39" width="5.06" height="5.21"/>
<rect x="20.34" y="110.9" width="5.06" height="5.25"/>
<rect x="20.34" y="100.44" width="5.06" height="5.25"/>
<rect x="20.34" y="89.99" width="5.06" height="5.21"/>
<rect x="20.34" y="79.54" width="5.06" height="5.21"/>
<rect x="20.34" y="69.04" width="5.06" height="5.25"/>
<rect x="20.34" y="58.59" width="5.06" height="5.25"/>
<rect x="20.34" y="48.13" width="5.06" height="5.25"/>
<rect x="20.34" y="37.68" width="5.06" height="5.21"/>
<rect x="20.34" y="27.19" width="5.06" height="5.25"/>
<rect x="20.34" y="16.73" width="5.06" height="5.25"/>
<rect x="20.34" y="6.28" width="5.06" height="5.25"/>
<rect x="15.31" y="142.3" width="5.02" height="5.25"/>
<rect x="15.31" y="131.85" width="5.02" height="5.21"/>
<rect x="15.31" y="121.39" width="5.02" height="5.21"/>
<rect x="15.31" y="110.9" width="5.02" height="5.25"/>
<rect x="15.31" y="100.44" width="5.02" height="5.25"/>
<rect x="15.31" y="89.99" width="5.02" height="5.21"/>
<rect x="15.31" y="79.54" width="5.02" height="5.21"/>
<rect x="15.31" y="69.04" width="5.02" height="5.25"/>
<rect x="15.31" y="58.59" width="5.02" height="5.25"/>
<rect x="15.31" y="48.13" width="5.02" height="5.25"/>
<rect x="15.31" y="37.68" width="5.02" height="5.21"/>
<rect x="15.31" y="27.19" width="5.02" height="5.25"/>
<rect x="15.31" y="16.73" width="5.02" height="5.25"/>
<rect x="15.31" y="6.28" width="5.02" height="5.25"/>
<rect x="10.29" y="142.3" width="5.02" height="5.25"/>
<rect x="10.29" y="131.85" width="5.02" height="5.21"/>
<rect x="10.29" y="121.39" width="5.02" height="5.21"/>
<rect x="10.29" y="110.9" width="5.02" height="5.25"/>
<rect x="10.29" y="100.44" width="5.02" height="5.25"/>
<rect x="10.29" y="89.99" width="5.02" height="5.21"/>
<rect x="10.29" y="79.54" width="5.02" height="5.21"/>
<rect x="10.29" y="69.04" width="5.02" height="5.25"/>
<rect x="10.29" y="58.59" width="5.02" height="5.25"/>
<rect x="10.29" y="48.13" width="5.02" height="5.25"/>
<rect x="10.29" y="37.68" width="5.02" height="5.21"/>
<rect x="10.29" y="27.19" width="5.02" height="5.25"/>
<rect x="10.29" y="16.73" width="5.02" height="5.25"/>
<rect x="10.29" y="6.28" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,181 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.62">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".27" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.82" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.82" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.82" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.8" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.8" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.8" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.77" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.77" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.77" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.75" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.75" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.75" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.72" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.72" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.72" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.7" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.7" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.7" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.68" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.68" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.68" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.65" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.63" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.6" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.58" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.56" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.53" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.47" y="69.18" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="58.73" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="27.36" width="5.06" height="5.21"/>
<rect class="cls-1" x="45.47" y="16.87" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="6.42" width="5.06" height="5.25"/>
<rect class="cls-1" x="40.45" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.45" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="6.42" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.83" y="121.37" width="5.02" height="5.25"/>
<rect x="110.83" y="110.91" width="5.02" height="5.25"/>
<rect x="110.83" y="100.46" width="5.02" height="5.21"/>
<rect x="110.83" y="89.97" width="5.02" height="5.25"/>
<rect x="110.83" y="79.51" width="5.02" height="5.25"/>
<rect x="110.83" y="69.06" width="5.02" height="5.25"/>
<rect x="110.83" y="58.6" width="5.02" height="5.21"/>
<rect x="110.83" y="48.15" width="5.02" height="5.21"/>
<rect x="110.83" y="37.66" width="5.02" height="5.25"/>
<rect x="110.83" y="27.2" width="5.02" height="5.25"/>
<rect x="105.8" y="131.82" width="5.02" height="5.25"/>
<rect x="105.8" y="121.37" width="5.02" height="5.25"/>
<rect x="105.8" y="110.91" width="5.02" height="5.25"/>
<rect x="105.8" y="100.46" width="5.02" height="5.21"/>
<rect x="105.8" y="89.97" width="5.02" height="5.25"/>
<rect x="105.8" y="79.51" width="5.02" height="5.25"/>
<rect x="105.8" y="69.06" width="5.02" height="5.25"/>
<rect x="105.8" y="58.6" width="5.02" height="5.21"/>
<rect x="105.8" y="48.15" width="5.02" height="5.21"/>
<rect x="105.8" y="37.66" width="5.02" height="5.25"/>
<rect x="105.8" y="27.2" width="5.02" height="5.25"/>
<rect x="105.8" y="16.75" width="5.02" height="5.21"/>
<rect x="100.78" y="131.82" width="5.02" height="5.25"/>
<rect x="100.78" y="121.37" width="5.02" height="5.25"/>
<rect x="100.78" y="110.91" width="5.02" height="5.25"/>
<rect x="100.78" y="100.46" width="5.02" height="5.21"/>
<rect x="100.78" y="89.97" width="5.02" height="5.25"/>
<rect x="100.78" y="79.51" width="5.02" height="5.25"/>
<rect x="100.78" y="69.06" width="5.02" height="5.25"/>
<rect x="100.78" y="58.6" width="5.02" height="5.21"/>
<rect x="100.78" y="48.15" width="5.02" height="5.21"/>
<rect x="100.78" y="37.66" width="5.02" height="5.25"/>
<rect x="100.78" y="27.2" width="5.02" height="5.25"/>
<rect x="100.78" y="16.75" width="5.02" height="5.21"/>
<rect x="95.76" y="142.31" width="5.02" height="5.21"/>
<rect x="95.76" y="131.82" width="5.02" height="5.25"/>
<rect x="95.76" y="121.37" width="5.02" height="5.25"/>
<rect x="95.76" y="110.91" width="5.02" height="5.25"/>
<rect x="95.76" y="100.46" width="5.02" height="5.21"/>
<rect x="95.76" y="89.97" width="5.02" height="5.25"/>
<rect x="95.76" y="79.51" width="5.02" height="5.25"/>
<rect x="95.76" y="69.06" width="5.02" height="5.25"/>
<rect x="95.76" y="58.6" width="5.02" height="5.21"/>
<rect x="95.76" y="48.15" width="5.02" height="5.21"/>
<rect x="95.76" y="37.66" width="5.02" height="5.25"/>
<rect x="95.76" y="27.2" width="5.02" height="5.25"/>
<rect x="95.76" y="16.75" width="5.02" height="5.21"/>
<rect x="95.76" y="6.29" width="5.02" height="5.21"/>
<rect x="90.73" y="142.31" width="5.02" height="5.21"/>
<rect x="90.73" y="131.82" width="5.02" height="5.25"/>
<rect x="90.73" y="121.37" width="5.02" height="5.25"/>
<rect x="90.73" y="110.91" width="5.02" height="5.25"/>
<rect x="90.73" y="100.46" width="5.02" height="5.21"/>
<rect x="90.73" y="89.97" width="5.02" height="5.25"/>
<rect x="90.73" y="79.51" width="5.02" height="5.25"/>
<rect x="90.73" y="69.06" width="5.02" height="5.25"/>
<rect x="90.73" y="58.6" width="5.02" height="5.21"/>
<rect x="90.73" y="48.15" width="5.02" height="5.21"/>
<rect x="90.73" y="37.66" width="5.02" height="5.25"/>
<rect x="90.73" y="27.2" width="5.02" height="5.25"/>
<rect x="90.73" y="16.75" width="5.02" height="5.21"/>
<rect x="90.73" y="6.29" width="5.02" height="5.21"/>
<rect x="85.71" y="142.31" width="5.02" height="5.21"/>
<rect x="85.71" y="131.82" width="5.02" height="5.25"/>
<rect x="85.71" y="121.37" width="5.02" height="5.25"/>
<rect x="85.71" y="110.91" width="5.02" height="5.25"/>
<rect x="85.71" y="100.46" width="5.02" height="5.21"/>
<rect x="85.71" y="89.97" width="5.02" height="5.25"/>
<rect x="85.71" y="79.51" width="5.02" height="5.25"/>
<rect x="85.71" y="69.06" width="5.02" height="5.25"/>
<rect x="85.71" y="58.6" width="5.02" height="5.21"/>
<rect x="85.71" y="48.15" width="5.02" height="5.21"/>
<rect x="85.71" y="37.66" width="5.02" height="5.25"/>
<rect x="85.71" y="27.2" width="5.02" height="5.25"/>
<rect x="85.71" y="16.75" width="5.02" height="5.21"/>
<rect x="85.71" y="6.29" width="5.02" height="5.21"/>
<rect x="80.68" y="142.31" width="5.02" height="5.21"/>
<rect x="80.68" y="131.82" width="5.02" height="5.25"/>
<rect x="80.68" y="121.37" width="5.02" height="5.25"/>
<rect x="80.68" y="110.91" width="5.02" height="5.25"/>
<rect x="80.68" y="37.66" width="5.02" height="5.25"/>
<rect x="80.68" y="27.2" width="5.02" height="5.25"/>
<rect x="80.68" y="16.75" width="5.02" height="5.21"/>
<rect x="80.68" y="6.29" width="5.02" height="5.21"/>
<rect x="75.66" y="142.31" width="5.02" height="5.21"/>
<rect x="75.66" y="131.82" width="5.02" height="5.25"/>
<rect x="75.66" y="121.37" width="5.02" height="5.25"/>
<rect x="75.66" y="27.2" width="5.02" height="5.25"/>
<rect x="75.66" y="16.75" width="5.02" height="5.21"/>
<rect x="75.66" y="6.29" width="5.02" height="5.21"/>
<rect x="70.64" y="142.31" width="5.02" height="5.21"/>
<rect x="70.64" y="131.82" width="5.02" height="5.25"/>
<rect x="70.64" y="121.37" width="5.02" height="5.25"/>
<rect x="70.64" y="27.2" width="5.02" height="5.25"/>
<rect x="70.64" y="16.75" width="5.02" height="5.21"/>
<rect x="70.64" y="6.29" width="5.02" height="5.21"/>
<rect x="65.61" y="142.31" width="5.02" height="5.21"/>
<rect x="65.61" y="131.82" width="5.02" height="5.25"/>
<rect x="65.61" y="121.37" width="5.02" height="5.25"/>
<rect x="65.61" y="27.2" width="5.02" height="5.25"/>
<rect x="65.61" y="16.75" width="5.02" height="5.21"/>
<rect x="65.61" y="6.29" width="5.02" height="5.21"/>
<rect x="60.55" y="142.31" width="5.06" height="5.21"/>
<rect x="60.55" y="131.82" width="5.06" height="5.25"/>
<rect x="60.55" y="121.37" width="5.06" height="5.25"/>
<rect x="60.55" y="27.2" width="5.06" height="5.25"/>
<rect x="60.55" y="16.75" width="5.06" height="5.21"/>
<rect x="60.55" y="6.29" width="5.06" height="5.21"/>
<rect x="55.53" y="142.31" width="5.02" height="5.21"/>
<rect x="55.53" y="131.82" width="5.02" height="5.25"/>
<rect x="55.53" y="121.37" width="5.02" height="5.25"/>
<rect x="55.53" y="27.2" width="5.02" height="5.25"/>
<rect x="55.53" y="16.75" width="5.02" height="5.21"/>
<rect x="55.53" y="6.29" width="5.02" height="5.21"/>
<rect x="50.5" y="142.31" width="5.02" height="5.21"/>
<rect x="50.5" y="131.82" width="5.02" height="5.25"/>
<rect x="50.5" y="121.37" width="5.02" height="5.25"/>
<rect x="50.5" y="27.2" width="5.02" height="5.25"/>
<rect x="50.5" y="16.75" width="5.02" height="5.21"/>
<rect x="50.5" y="6.29" width="5.02" height="5.21"/>
<rect x="45.48" y="142.31" width="5.02" height="5.21"/>
<rect x="45.48" y="131.82" width="5.02" height="5.25"/>
<rect x="45.48" y="121.37" width="5.02" height="5.25"/>
<rect x="45.48" y="27.2" width="5.02" height="5.25"/>
<rect x="45.48" y="16.75" width="5.02" height="5.21"/>
<rect x="45.48" y="6.29" width="5.02" height="5.21"/>
<rect x="40.45" y="142.31" width="5.02" height="5.21"/>
<rect x="40.45" y="131.82" width="5.02" height="5.25"/>
<rect x="40.45" y="121.37" width="5.02" height="5.25"/>
<rect x="40.45" y="27.2" width="5.02" height="5.25"/>
<rect x="40.45" y="16.75" width="5.02" height="5.21"/>
<rect x="40.45" y="6.29" width="5.02" height="5.21"/>
<rect x="35.43" y="142.31" width="5.02" height="5.21"/>
<rect x="35.43" y="131.82" width="5.02" height="5.25"/>
<rect x="35.43" y="121.37" width="5.02" height="5.25"/>
<rect x="35.43" y="110.91" width="5.02" height="5.25"/>
<rect x="35.43" y="100.46" width="5.02" height="5.21"/>
<rect x="35.43" y="89.97" width="5.02" height="5.25"/>
<rect x="35.43" y="79.51" width="5.02" height="5.25"/>
<rect x="35.43" y="69.06" width="5.02" height="5.25"/>
<rect x="35.43" y="58.6" width="5.02" height="5.21"/>
<rect x="35.43" y="48.15" width="5.02" height="5.21"/>
<rect x="35.43" y="37.66" width="5.02" height="5.25"/>
<rect x="35.43" y="27.2" width="5.02" height="5.25"/>
<rect x="35.43" y="16.75" width="5.02" height="5.21"/>
<rect x="35.43" y="6.29" width="5.02" height="5.21"/>
<rect x="30.41" y="142.31" width="5.02" height="5.21"/>
<rect x="30.41" y="131.82" width="5.02" height="5.25"/>
<rect x="30.41" y="121.37" width="5.02" height="5.25"/>
<rect x="30.41" y="110.91" width="5.02" height="5.25"/>
<rect x="30.41" y="100.46" width="5.02" height="5.21"/>
<rect x="30.41" y="89.97" width="5.02" height="5.25"/>
<rect x="30.41" y="79.51" width="5.02" height="5.25"/>
<rect x="30.41" y="69.06" width="5.02" height="5.25"/>
<rect x="30.41" y="58.6" width="5.02" height="5.21"/>
<rect x="30.41" y="48.15" width="5.02" height="5.21"/>
<rect x="30.41" y="37.66" width="5.02" height="5.25"/>
<rect x="30.41" y="27.2" width="5.02" height="5.25"/>
<rect x="30.41" y="16.75" width="5.02" height="5.21"/>
<rect x="30.41" y="6.29" width="5.02" height="5.21"/>
<rect x="25.38" y="142.31" width="5.02" height="5.21"/>
<rect x="25.38" y="131.82" width="5.02" height="5.25"/>
<rect x="25.38" y="121.37" width="5.02" height="5.25"/>
<rect x="25.38" y="110.91" width="5.02" height="5.25"/>
<rect x="25.38" y="100.46" width="5.02" height="5.21"/>
<rect x="25.38" y="89.97" width="5.02" height="5.25"/>
<rect x="25.38" y="79.51" width="5.02" height="5.25"/>
<rect x="25.38" y="69.06" width="5.02" height="5.25"/>
<rect x="25.38" y="58.6" width="5.02" height="5.21"/>
<rect x="25.38" y="48.15" width="5.02" height="5.21"/>
<rect x="25.38" y="37.66" width="5.02" height="5.25"/>
<rect x="25.38" y="27.2" width="5.02" height="5.25"/>
<rect x="25.38" y="16.75" width="5.02" height="5.21"/>
<rect x="25.38" y="6.29" width="5.02" height="5.21"/>
<rect x="20.36" y="142.31" width="5.02" height="5.21"/>
<rect x="20.36" y="131.82" width="5.02" height="5.25"/>
<rect x="20.36" y="121.37" width="5.02" height="5.25"/>
<rect x="20.36" y="110.91" width="5.02" height="5.25"/>
<rect x="20.36" y="100.46" width="5.02" height="5.21"/>
<rect x="20.36" y="89.97" width="5.02" height="5.25"/>
<rect x="20.36" y="79.51" width="5.02" height="5.25"/>
<rect x="20.36" y="69.06" width="5.02" height="5.25"/>
<rect x="20.36" y="58.6" width="5.02" height="5.21"/>
<rect x="20.36" y="48.15" width="5.02" height="5.21"/>
<rect x="20.36" y="37.66" width="5.02" height="5.25"/>
<rect x="20.36" y="27.2" width="5.02" height="5.25"/>
<rect x="20.36" y="16.75" width="5.02" height="5.21"/>
<rect x="20.36" y="6.29" width="5.02" height="5.21"/>
<rect x="15.33" y="142.31" width="5.02" height="5.21"/>
<rect x="15.33" y="131.82" width="5.02" height="5.25"/>
<rect x="15.33" y="121.37" width="5.02" height="5.25"/>
<rect x="15.33" y="110.91" width="5.02" height="5.25"/>
<rect x="15.33" y="100.46" width="5.02" height="5.21"/>
<rect x="15.33" y="89.97" width="5.02" height="5.25"/>
<rect x="15.33" y="79.51" width="5.02" height="5.25"/>
<rect x="15.33" y="69.06" width="5.02" height="5.25"/>
<rect x="15.33" y="58.6" width="5.02" height="5.21"/>
<rect x="15.33" y="48.15" width="5.02" height="5.21"/>
<rect x="15.33" y="37.66" width="5.02" height="5.25"/>
<rect x="15.33" y="27.2" width="5.02" height="5.25"/>
<rect x="15.33" y="16.75" width="5.02" height="5.21"/>
<rect x="15.33" y="6.29" width="5.02" height="5.21"/>
<rect x="10.31" y="142.31" width="5.02" height="5.21"/>
<rect x="10.31" y="131.82" width="5.02" height="5.25"/>
<rect x="10.31" y="121.37" width="5.02" height="5.25"/>
<rect x="10.31" y="110.91" width="5.02" height="5.25"/>
<rect x="10.31" y="100.46" width="5.02" height="5.21"/>
<rect x="10.31" y="89.97" width="5.02" height="5.25"/>
<rect x="10.31" y="79.51" width="5.02" height="5.25"/>
<rect x="10.31" y="69.06" width="5.02" height="5.25"/>
<rect x="10.31" y="58.6" width="5.02" height="5.21"/>
<rect x="10.31" y="48.15" width="5.02" height="5.21"/>
<rect x="10.31" y="37.66" width="5.02" height="5.25"/>
<rect x="10.31" y="27.2" width="5.02" height="5.25"/>
<rect x="10.31" y="16.75" width="5.02" height="5.21"/>
<rect x="10.31" y="6.29" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Some files were not shown because too many files have changed in this diff Show More