53 Commits

Author SHA1 Message Date
c95096ddf0 Merge branch 'dev' into feat/material-page
# Conflicts:
#	frontend/src/app/core/layout/navbar.component.html
2026-03-24 13:18:08 +01:00
c8913af660 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / build-and-push (push) Successful in 31s
2026-03-24 13:17:30 +01:00
9611049e01 fix(front-end): new test 2026-03-24 13:17:25 +01:00
bad5947fb5 Merge branch 'main' into dev
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 31s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-backend (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 10s
2026-03-24 13:17:10 +01:00
d27558a3ee fix(front-end): fix no index product 3 hope the last one
Some checks failed
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-24 12:59:09 +01:00
81f6f78c49 fix(front-end): fix no index product 2
All checks were successful
Build and Deploy / test-backend (push) Successful in 32s
Build and Deploy / test-frontend (push) Successful in 1m9s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 19:11:26 +01:00
bf593445bd fix(front-end): fix no index product
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 1m16s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 18:07:07 +01:00
aa032c0140 Merge pull request 'fix(front-end): fix no index in products' (#53) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #53
2026-03-23 17:36:11 +01:00
printcalc-ci
95e60692c0 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / security-sast (pull_request) Successful in 37s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-23 16:31:33 +00:00
fda2cdbecb Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 15s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m5s
2026-03-23 17:29:38 +01:00
a1cc9f18c4 fix(front-end): fix no index in products
Some checks failed
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-23 17:27:18 +01:00
084d35d605 Merge pull request 'fix(front-end): seo improvemnts' (#52) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #52
2026-03-23 16:21:00 +01:00
printcalc-ci
02aac24a09 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-23 15:15:15 +00:00
51c2bf6985 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 29s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 21s
2026-03-23 16:14:18 +01:00
4e99d12be1 fix(front-end): seo improvemnts
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / prettier-autofix (pull_request) Failing after 12s
Build and Deploy / test-frontend (push) Has been cancelled
2026-03-23 16:14:04 +01:00
8b5d8f92e0 Merge pull request 'dev' (#51) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m7s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #51
2026-03-22 23:06:02 +01:00
d3c9dd6eb9 Merge branch 'main' into dev
All checks were successful
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 23:03:08 +01:00
254ff36c50 fix(front-end): seo improvemnts
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-22 23:02:59 +01:00
b317196217 fix(front-end): redirect
All checks were successful
Build and Deploy / test-backend (push) Successful in 40s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m19s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 22:41:12 +01:00
cc343ee27c fix(back-end): fix csrm and cors 2026-03-22 21:11:48 +01:00
74d1b16b7c fix(back-end): fix load product 2026-03-22 21:11:33 +01:00
adf6889712 Merge pull request 'dev' (#49) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #49
2026-03-21 18:57:57 +01:00
653082868a Merge pull request 'feat/brand-logo' (#50) from feat/brand-logo into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m21s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #50
2026-03-20 13:17:03 +01:00
997e770256 Merge remote-tracking branch 'origin/feat/brand-logo' into feat/brand-logo
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-20 11:45:15 +01:00
fb1a6456e6 fix(back-end) base url fix 2026-03-20 11:45:10 +01:00
43cd80600e Merge branch 'dev' into feat/brand-logo
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Failing after 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-20 11:28:10 +01:00
printcalc-ci
23e1abdbbb style: apply prettier formatting 2026-03-20 09:37:56 +00:00
e575021f53 feat(front-end): new logo edited
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 22s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Failing after 34s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-20 10:36:50 +01:00
7e8c89ce45 feat(front-end): new logo edited 2026-03-19 14:33:29 +01:00
a40a8df894 feat(animation logo) 2026-03-18 17:30:53 +01:00
printcalc-ci
41f36ed18a style: apply prettier formatting 2026-03-17 08:03:30 +00:00
e04189bbfe Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 22s
2026-03-17 09:01:34 +01:00
20988e425a fix(front-end): set fallback lang
Some checks failed
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / test-backend (push) Successful in 36s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / prettier-autofix (pull_request) Failing after 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-14 20:53:50 +01:00
df63937406 feat(front-end): faster load
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 19s
2026-03-14 19:28:30 +01:00
4ba408859d Merge pull request 'dev' (#48) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #48
2026-03-14 19:18:15 +01:00
996e95f93c fix(back-end): quote line items
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 32s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m25s
Build and Deploy / deploy (push) Successful in 21s
2026-03-14 19:15:44 +01:00
printcalc-ci
c4bd0b5a67 style: apply prettier formatting 2026-03-14 17:58:02 +00:00
5c43873ede Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 18s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 27s
2026-03-14 18:56:07 +01:00
249645619e fix(deploy): common..env
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 22s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-14 18:52:18 +01:00
be9f303b37 fix(deploy): common..env
Some checks failed
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Failing after 6s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-14 18:42:53 +01:00
6da8b3b6e4 feat(back-end): new translation api with openai
Some checks failed
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 1m20s
Build and Deploy / deploy (push) Failing after 6s
2026-03-14 18:33:51 +01:00
a3cd451575 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #47
2026-03-14 16:13:37 +01:00
printcalc-ci
6f700c923a style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-14 14:15:10 +00:00
46fd59ed71 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 22s
2026-03-14 15:14:12 +01:00
ba49463ee7 fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-14 15:13:54 +01:00
576380e9a0 fix(front-end): seo translated 2026-03-14 15:02:00 +01:00
cac534ccbb Merge pull request 'dev' (#46) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 18s
Reviewed-on: #46
2026-03-13 17:44:20 +01:00
2e68105da4 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 24s
2026-03-13 17:41:55 +01:00
ed7ed6636d fix(front-end): al categories translated 2026-03-13 17:41:25 +01:00
e190359041 Merge pull request 'dev' (#45) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #45
2026-03-13 16:36:42 +01:00
printcalc-ci
bed94790d4 style: apply prettier formatting
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
2026-03-13 15:30:28 +00:00
9fc1fc97fa feat(front-end): nav bar 2026-03-12 14:50:15 +01:00
7010a81596 feat(front-end): material page 2026-03-12 14:49:50 +01:00
149 changed files with 22209 additions and 846 deletions

View File

@@ -217,9 +217,12 @@ jobs:
ADMIN_TTL="${ADMIN_TTL:-480}" ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
fi
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env

View File

@@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A
### Database connection ### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
### Deploy e traduzioni OpenAI
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.

View File

@@ -0,0 +1,88 @@
package com.printcalculator.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
public class AllowedOriginService {
private final List<String> allowedOrigins;
public AllowedOriginService(
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
) {
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
}
if (configuredOrigins.isEmpty()) {
throw new IllegalStateException("At least one allowed origin must be configured.");
}
this.allowedOrigins = List.copyOf(configuredOrigins);
}
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public boolean isAllowed(String rawOriginOrUrl) {
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
}
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
return;
}
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
if (normalizedOrigin == null) {
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
}
configuredOrigins.add(normalizedOrigin);
}
private String normalizeRequestOrigin(String rawOriginOrUrl) {
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
return null;
}
try {
URI uri = URI.create(rawOriginOrUrl.trim());
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
return null;
}
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
return null;
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
int port = uri.getPort();
if (isDefaultPort(normalizedScheme, port) || port < 0) {
return normalizedScheme + "://" + normalizedHost;
}
return normalizedScheme + "://" + normalizedHost + ":" + port;
} catch (IllegalArgumentException ignored) {
return null;
}
}
private boolean isDefaultPort(String scheme, int port) {
return ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
}
}

View File

@@ -1,27 +1,27 @@
package com.printcalculator.config; package com.printcalculator.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig {
@Override @Bean
public void addCorsMappings(CorsRegistry registry) { public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
registry.addMapping("/**") CorsConfiguration configuration = new CorsConfiguration();
.allowedOrigins( configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
"http://localhost", configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
"http://localhost:4200", configuration.setAllowedHeaders(List.of("*"));
"http://localhost:80", configuration.setAllowCredentials(true);
"http://127.0.0.1", configuration.setMaxAge(3600L);
"https://dev.3d-fab.ch",
"https://int.3d-fab.ch", UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
"https://3d-fab.ch" source.registerCorsConfiguration("/**", configuration);
) return source;
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
} }
} }

View File

@@ -1,5 +1,6 @@
package com.printcalculator.config; package com.printcalculator.config;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -18,6 +19,7 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
HttpSecurity http, HttpSecurity http,
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception { ) throws Exception {
http http
@@ -40,7 +42,8 @@ public class SecurityConfig {
response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
})) }))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
return http.build(); return http.build();
} }

View File

@@ -56,6 +56,12 @@ public class PublicShopController {
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
} }
@GetMapping("/products/by-path/{publicPath}")
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
}
@GetMapping("/products/{slug}/model") @GetMapping("/products/{slug}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException { public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);

View File

@@ -18,6 +18,7 @@ import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
@@ -124,6 +125,9 @@ public class QuoteController {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (!isSupportedInputFile(file)) {
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
// Scan for virus // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
@@ -153,4 +157,14 @@ public class QuoteController {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
} }
} }
private boolean isSupportedInputFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
return false;
}
String normalized = originalFilename.toLowerCase(Locale.ROOT);
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
}
} }

View File

@@ -130,6 +130,7 @@ public class QuoteSessionController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@Transactional(readOnly = true)
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) { public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));

View File

@@ -1,8 +1,11 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopProductDto; import com.printcalculator.dto.AdminShopProductDto;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.dto.AdminUpsertShopProductRequest; import com.printcalculator.dto.AdminUpsertShopProductRequest;
import com.printcalculator.service.admin.AdminShopProductControllerService; import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -29,9 +32,12 @@ import java.util.UUID;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminShopProductController { public class AdminShopProductController {
private final AdminShopProductControllerService adminShopProductControllerService; private final AdminShopProductControllerService adminShopProductControllerService;
private final AdminShopProductTranslationService adminShopProductTranslationService;
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) { public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
AdminShopProductTranslationService adminShopProductTranslationService) {
this.adminShopProductControllerService = adminShopProductControllerService; this.adminShopProductControllerService = adminShopProductControllerService;
this.adminShopProductTranslationService = adminShopProductTranslationService;
} }
@GetMapping @GetMapping
@@ -50,6 +56,11 @@ public class AdminShopProductController {
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload)); return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
} }
@PostMapping("/translate")
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
}
@PutMapping("/{productId}") @PutMapping("/{productId}")
@Transactional @Transactional
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId, public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,

View File

@@ -0,0 +1,89 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class AdminTranslateShopProductRequest {
private UUID categoryId;
private String sourceLanguage;
private Boolean overwriteExisting;
private List<String> materialCodes;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public Boolean getOverwriteExisting() {
return overwriteExisting;
}
public void setOverwriteExisting(Boolean overwriteExisting) {
this.overwriteExisting = overwriteExisting;
}
public List<String> getMaterialCodes() {
return materialCodes;
}
public void setMaterialCodes(List<String> materialCodes) {
this.materialCodes = materialCodes;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,70 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
public class AdminTranslateShopProductResponse {
private String sourceLanguage;
private List<String> targetLanguages;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public List<String> getTargetLanguages() {
return targetLanguages;
}
public void setTargetLanguages(List<String> targetLanguages) {
this.targetLanguages = targetLanguages;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -2,6 +2,7 @@ package com.printcalculator.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public record ShopProductDetailDto( public record ShopProductDetailDto(
@@ -25,6 +26,8 @@ public record ShopProductDetailDto(
List<ShopProductVariantOptionDto> variants, List<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage, PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images, List<PublicMediaUsageDto> images,
ShopProductModelDto model3d ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) { ) {
} }

View File

@@ -1,6 +1,7 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public record ShopProductSummaryDto( public record ShopProductSummaryDto(
@@ -15,6 +16,8 @@ public record ShopProductSummaryDto(
BigDecimal priceToChf, BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant, ShopProductVariantOptionDto defaultVariant,
PublicMediaUsageDto primaryImage, PublicMediaUsageDto primaryImage,
ShopProductModelDto model3d ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) { ) {
} }

View File

@@ -223,10 +223,15 @@ public class OrderEmailListener {
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
); );
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
return templateData; return templateData;
} }
private String buildLogoUrl() {
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) { private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) { return switch (language) {
case "en" -> { case "en" -> {

View File

@@ -1,6 +1,7 @@
package com.printcalculator.repository; package com.printcalculator.repository;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
@@ -8,9 +9,16 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId); Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
@EntityGraph(attributePaths = {"shopProductVariant"})
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
UUID quoteSessionId, UUID quoteSessionId,
String lineItemType, String lineItemType,

View File

@@ -0,0 +1,60 @@
package com.printcalculator.security;
import com.printcalculator.config.AllowedOriginService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Locale;
import java.util.Set;
@Component
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
private final AllowedOriginService allowedOriginService;
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
this.allowedOriginService = allowedOriginService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String origin = request.getHeader(HttpHeaders.ORIGIN);
String referer = request.getHeader(HttpHeaders.REFERER);
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
}
private String resolvePath(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
return path.substring(contextPath.length());
}
return path;
}
}

View File

@@ -0,0 +1,685 @@
package com.printcalculator.service.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.repository.ShopCategoryRepository;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
@Transactional(readOnly = true)
public class AdminShopProductTranslationService {
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "http", "https", "mailto", "tel");
private static final String DEFAULT_SHOP_CONTEXT = """
3D fab is a Swiss-based 3D printing shop and technical service.
The tone must be practical, clear, technical, and trustworthy.
Avoid hype, avoid invented claims, and avoid vague marketing filler.
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
When the source field is empty, return an empty string rather than inventing content.
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
""";
private final ShopCategoryRepository shopCategoryRepository;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String apiKey;
private final String baseUrl;
private final String model;
private final Duration timeout;
private final String promptCacheKeyPrefix;
private final String additionalBusinessContext;
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
ObjectMapper objectMapper,
@Value("${openai.translation.api-key:}") String apiKey,
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
@Value("${openai.translation.model:gpt-5.4}") String model,
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
this.shopCategoryRepository = shopCategoryRepository;
this.objectMapper = objectMapper;
this.apiKey = apiKey != null ? apiKey.trim() : "";
this.baseUrl = normalizeBaseUrl(baseUrl);
this.model = model != null ? model.trim() : "";
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
? promptCacheKeyPrefix.trim()
: "printcalc-shop-product-translation-v1";
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
.build();
}
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
ensureConfigured();
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
if (targetLanguages.isEmpty()) {
return emptyResponse(normalizedRequest.sourceLanguage());
}
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
TranslationBundle generated = callOpenAiFunction(
"generate_product_translations",
"Generate translated product copy for the requested target languages.",
buildInstructions("Generate the first-pass translations.", businessContext),
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
buildTranslationToolSchema(targetLanguages),
"generate"
);
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
TranslationBundle reviewed = callOpenAiFunction(
"review_product_translations",
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
buildInstructions("Review and correct the generated translations.", businessContext),
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
buildTranslationToolSchema(targetLanguages),
"review"
);
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
ensureRequiredTranslations(finalBundle, targetLanguages);
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
}
private void ensureConfigured() {
if (apiKey.isBlank() || model.isBlank()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"OpenAI translation is not configured on the backend"
);
}
}
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
}
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
}
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
if (names.get(sourceLanguage).isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"The active source language must have a product name before translation"
);
}
Set<String> materialCodes = new LinkedHashSet<>();
if (payload.getMaterialCodes() != null) {
for (String materialCode : payload.getMaterialCodes()) {
String normalizedCode = normalizeOptional(materialCode);
if (normalizedCode != null) {
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
}
}
}
return new NormalizedTranslationRequest(
payload.getCategoryId(),
sourceLanguage,
Boolean.TRUE.equals(payload.getOverwriteExisting()),
List.copyOf(materialCodes),
names,
excerpts,
descriptions,
seoTitles,
seoDescriptions
);
}
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
List<String> targetLanguages = new ArrayList<>();
for (String language : SUPPORTED_LANGUAGES) {
if (language.equals(request.sourceLanguage())) {
continue;
}
if (request.overwriteExisting() || needsTranslation(request, language)) {
targetLanguages.add(language);
}
}
return targetLanguages;
}
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
return request.names().get(language).isBlank()
|| request.excerpts().get(language).isBlank()
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|| request.seoTitles().get(language).isBlank()
|| request.seoDescriptions().get(language).isBlank();
}
private CategoryContext loadCategoryContext(UUID categoryId) {
if (categoryId == null) {
return null;
}
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
if (category == null) {
return null;
}
return new CategoryContext(
category.getSlug(),
Map.of(
"it", safeValue(category.getNameIt()),
"en", safeValue(category.getNameEn()),
"de", safeValue(category.getNameDe()),
"fr", safeValue(category.getNameFr())
),
Map.of(
"it", safeValue(category.getDescriptionIt()),
"en", safeValue(category.getDescriptionEn()),
"de", safeValue(category.getDescriptionDe()),
"fr", safeValue(category.getDescriptionFr())
)
);
}
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
if (!additionalBusinessContext.isBlank()) {
context.append('\n').append(additionalBusinessContext.trim());
}
if (categoryContext != null) {
context.append("\nCategory slug: ").append(categoryContext.slug());
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
}
}
if (materialCodes != null && !materialCodes.isEmpty()) {
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
}
return context.toString();
}
private String buildInstructions(String task, String businessContext) {
return """
You are a senior ecommerce localization editor.
Task: %s
Return only the function call arguments that match the provided schema.
Always preserve meaning, HTML safety, and technical precision.
Never invent specifications or marketing claims not present in the source.
If a source field is empty, return an empty string for that field.
General context:
%s
""".formatted(task, businessContext);
}
private String buildGenerationInput(NormalizedTranslationRequest request,
List<String> targetLanguages,
CategoryContext categoryContext) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.put("overwriteExisting", request.overwriteExisting());
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private String buildReviewInput(NormalizedTranslationRequest request,
TranslationBundle generated,
List<String> targetLanguages,
CategoryContext categoryContext,
List<String> validationNotes) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
ObjectNode node = objectMapper.createObjectNode();
node.put("name", request.names().get(language));
node.put("excerpt", request.excerpts().get(language));
node.put("description", request.descriptions().get(language));
node.put("seoTitle", request.seoTitles().get(language));
node.put("seoDescription", request.seoDescriptions().get(language));
return node;
}
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
ObjectNode node = objectMapper.createObjectNode();
for (String language : targetLanguages) {
node.set(language, localizedFieldNode(request, language));
}
return node;
}
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
ObjectNode root = objectMapper.createObjectNode();
root.put("type", "object");
root.put("additionalProperties", false);
ObjectNode properties = root.putObject("properties");
ObjectNode translations = properties.putObject("translations");
translations.put("type", "object");
translations.put("additionalProperties", false);
ObjectNode translationProperties = translations.putObject("properties");
ArrayNode requiredTranslations = translations.putArray("required");
for (String language : targetLanguages) {
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
requiredTranslations.add(language);
}
ArrayNode required = root.putArray("required");
required.add("translations");
return root;
}
private ObjectNode buildTranslationSchemaForLanguage(String language) {
ObjectNode languageSchema = objectMapper.createObjectNode();
languageSchema.put("type", "object");
languageSchema.put("additionalProperties", false);
languageSchema.put("description", "Localized product copy for language " + language);
ObjectNode properties = languageSchema.putObject("properties");
addSchemaString(properties, "name", "Translated product name. Never empty.");
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
ArrayNode required = languageSchema.putArray("required");
required.add("name");
required.add("excerpt");
required.add("description");
required.add("seoTitle");
required.add("seoDescription");
return languageSchema;
}
private void addSchemaString(ObjectNode properties, String name, String description) {
ObjectNode property = properties.putObject(name);
property.put("type", "string");
property.put("description", description);
}
private TranslationBundle callOpenAiFunction(String functionName,
String functionDescription,
String instructions,
String input,
ObjectNode parametersSchema,
String cacheSuffix) {
ObjectNode requestPayload = objectMapper.createObjectNode();
requestPayload.put("model", model);
requestPayload.put("instructions", instructions);
requestPayload.put("input", input);
requestPayload.put("tool_choice", "required");
requestPayload.put("temperature", 0.2);
requestPayload.put("store", false);
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
ArrayNode tools = requestPayload.putArray("tools");
ObjectNode tool = tools.addObject();
tool.put("type", "function");
tool.put("name", functionName);
tool.put("description", functionDescription);
tool.put("strict", true);
tool.set("parameters", parametersSchema);
JsonNode responseNode = postResponsesRequest(requestPayload);
JsonNode output = responseNode.path("output");
if (output.isArray()) {
for (JsonNode item : output) {
if ("function_call".equals(item.path("type").asText())) {
String arguments = item.path("arguments").asText("");
if (arguments.isBlank()) {
break;
}
try {
JsonNode argumentsNode = objectMapper.readTree(arguments);
JsonNode translationsNode = argumentsNode.path("translations");
if (!translationsNode.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned a function call without translations"
);
}
return TranslationBundle.fromJson(translationsNode);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned invalid JSON arguments",
exception
);
}
}
}
}
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return the expected function call"
);
}
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/responses"))
.timeout(timeout)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body = readJson(response.body());
if (response.statusCode() >= 400) {
String message = body.path("error").path("message").asText("").trim();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
message.isBlank() ? "OpenAI translation request failed" : message
);
}
return body;
} catch (IOException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"Unable to read the OpenAI translation response",
exception
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"The OpenAI translation request was interrupted",
exception
);
}
}
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
List<String> notes = new ArrayList<>();
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
notes.add(language + ": translated name is empty and must be fixed");
}
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
if (seoDescription.length() > 160) {
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
}
String description = bundle.descriptions().getOrDefault(language, "");
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
notes.add(language + ": description lost meaningful text during sanitization");
}
}
if (notes.isEmpty()) {
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
}
return notes;
}
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
for (String language : targetLanguages) {
names.put(language, safeValue(bundle.names().get(language)));
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
}
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
);
}
}
}
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
List<String> targetLanguages,
TranslationBundle bundle) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(targetLanguages);
response.setNames(bundle.names());
response.setExcerpts(bundle.excerpts());
response.setDescriptions(bundle.descriptions());
response.setSeoTitles(bundle.seoTitles());
response.setSeoDescriptions(bundle.seoDescriptions());
return response;
}
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(List.of());
response.setNames(Map.of());
response.setExcerpts(Map.of());
response.setDescriptions(Map.of());
response.setSeoTitles(Map.of());
response.setSeoDescriptions(Map.of());
return response;
}
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
Map<String, String> normalized = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
String value = rawValues != null ? rawValues.get(language) : null;
if (richText) {
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
} else {
normalized.put(language, safeValue(value));
}
}
return normalized;
}
private String safeValue(String value) {
return value == null ? "" : value.trim();
}
private String safeDescription(String value) {
String normalized = normalizeRichTextOptional(value);
return normalized != null ? normalized : "";
}
private String limitSeoDescription(String value) {
String normalized = safeValue(value);
if (normalized.length() <= 160) {
return normalized;
}
int lastSpace = normalized.lastIndexOf(' ', 157);
if (lastSpace >= 120) {
return normalized.substring(0, lastSpace).trim();
}
return normalized.substring(0, 160).trim();
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String normalizeRichTextOptional(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
String sanitized = Jsoup.clean(
normalized,
"",
PRODUCT_DESCRIPTION_SAFELIST,
new Document.OutputSettings().prettyPrint(false)
).trim();
if (sanitized.isBlank()) {
return null;
}
String plainText = Jsoup.parse(sanitized).text();
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
}
private String normalizeBaseUrl(String rawBaseUrl) {
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
? rawBaseUrl.trim()
: "https://api.openai.com/v1";
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Unable to serialize translation payload",
exception
);
}
}
private JsonNode readJson(String rawJson) throws IOException {
return objectMapper.readTree(rawJson);
}
private record NormalizedTranslationRequest(UUID categoryId,
String sourceLanguage,
boolean overwriteExisting,
List<String> materialCodes,
Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
}
private record CategoryContext(String slug,
Map<String, String> names,
Map<String, String> descriptions) {
}
private record TranslationBundle(Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
static TranslationBundle fromJson(JsonNode translationsNode) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
translationsNode.fieldNames().forEachRemaining(language -> {
JsonNode localizedNode = translationsNode.path(language);
names.put(language, localizedNode.path("name").asText(""));
excerpts.put(language, localizedNode.path("excerpt").asText(""));
descriptions.put(language, localizedNode.path("description").asText(""));
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
});
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
ObjectNode toJsonNode(ObjectMapper objectMapper) {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode translations = root.putObject("translations");
for (String language : names.keySet()) {
ObjectNode languageNode = translations.putObject(language);
languageNode.put("name", names.getOrDefault(language, ""));
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
languageNode.put("description", descriptions.getOrDefault(language, ""));
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
}
return root;
}
}
}

View File

@@ -72,10 +72,14 @@ public class QuoteSessionItemService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
} }
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "");
if (ext.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId()); Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
String storedFilename = UUID.randomUUID() + "." + ext; String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename); Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);

View File

@@ -54,7 +54,6 @@ public class QuoteStorageService {
return switch (ext) { return switch (ext) {
case "stl" -> "stl"; case "stl" -> "stl";
case "3mf" -> "3mf"; case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback; default -> fallback;
}; };
} }

View File

@@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService {
@Value("${app.mail.contact-request.customer.enabled:true}") @Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled; private boolean contactRequestCustomerMailEnabled;
@Value("${app.frontend.base-url:http://localhost:4200}")
private String frontendBaseUrl;
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
ContactRequestLocalizationService localizationService) { ContactRequestLocalizationService localizationService) {
this.emailNotificationService = emailNotificationService; this.emailNotificationService = emailNotificationService;
@@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail( emailNotificationService.sendEmail(
@@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
@@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService {
} }
return value; return value;
} }
private String buildLogoUrl() {
String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
? "http://localhost:4200"
: frontendBaseUrl;
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
} }

View File

@@ -126,24 +126,40 @@ public class PublicShopCatalogService {
} }
public ShopProductDetailDto getProduct(String slug, String language) { public ShopProductDetailDto getProduct(String slug, String language) {
CategoryContext categoryContext = loadCategoryContext(language); String normalizedLanguage = normalizeLanguage(language);
PublicProductContext productContext = loadPublicProductContext(categoryContext, language); CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
ProductEntry entry = requirePublicProductEntry(
productContext.entriesBySlug().get(slug),
categoryContext
);
return toProductDetailDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
normalizedLanguage
);
}
ProductEntry entry = productContext.entriesBySlug().get(slug); public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
if (entry == null) { String normalizedLanguage = normalizeLanguage(language);
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
if (normalizedPublicPath == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
} }
ShopCategory category = entry.product().getCategory(); CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); ProductEntry entry = requirePublicProductEntry(
} productContext.entriesByPublicPath().get(normalizedPublicPath),
categoryContext
);
return toProductDetailDto( return toProductDetailDto(
entry, entry,
productContext.productMediaBySlug(), productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(), productContext.variantColorHexByMaterialAndColor(),
language normalizedLanguage
); );
} }
@@ -197,6 +213,7 @@ public class PublicShopCatalogService {
} }
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
String normalizedLanguage = normalizeLanguage(language);
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet()); List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
SHOP_PRODUCT_MEDIA_USAGE_TYPE, SHOP_PRODUCT_MEDIA_USAGE_TYPE,
@@ -207,8 +224,21 @@ public class PublicShopCatalogService {
Map<String, ProductEntry> entriesBySlug = entries.stream() Map<String, ProductEntry> entriesBySlug = entries.stream()
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
.collect(Collectors.toMap(
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
entry -> entry,
(left, right) -> left,
LinkedHashMap::new
));
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor); return new PublicProductContext(
entries,
entriesBySlug,
entriesByPublicPath,
productMediaBySlug,
variantColorHexByMaterialAndColor
);
} }
private Map<String, String> buildFilamentVariantColorHexMap() { private Map<String, String> buildFilamentVariantColorHexMap() {
@@ -399,6 +429,9 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor, Map<String, String> variantColorHexByMaterialAndColor,
String language) { String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto( return new ShopProductSummaryDto(
entry.product().getId(), entry.product().getId(),
entry.product().getSlug(), entry.product().getSlug(),
@@ -415,7 +448,9 @@ public class PublicShopCatalogService {
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
toProductModelDto(entry) toProductModelDto(entry),
publicPathSegment,
localizedPaths
); );
} }
@@ -426,8 +461,10 @@ public class PublicShopCatalogService {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
return new ShopProductDetailDto( String normalizedLanguage = normalizeLanguage(language);
entry.product().getId(), String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto(entry.product().getId(),
entry.product().getSlug(), entry.product().getSlug(),
entry.product().getNameForLanguage(language), entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language), entry.product().getExcerptForLanguage(language),
@@ -453,7 +490,9 @@ public class PublicShopCatalogService {
.toList(), .toList(),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
toProductModelDto(entry) toProductModelDto(entry),
publicPathSegment,
localizedPaths
); );
} }
@@ -506,6 +545,27 @@ public class PublicShopCatalogService {
return raw.toLowerCase(Locale.ROOT); return raw.toLowerCase(Locale.ROOT);
} }
private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
if (entry == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
ShopCategory category = entry.product().getCategory();
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
return entry;
}
private String normalizePublicPathSegment(String publicPathSegment) {
String normalized = trimToNull(publicPathSegment);
if (normalized == null) {
return null;
}
return normalized.toLowerCase(Locale.ROOT);
}
private String trimToNull(String value) { private String trimToNull(String value) {
String raw = String.valueOf(value == null ? "" : value).trim(); String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) { if (raw.isEmpty()) {
@@ -514,6 +574,22 @@ public class PublicShopCatalogService {
return raw; return raw;
} }
private String normalizeLanguage(String language) {
String normalized = trimToNull(language);
if (normalized == null) {
return "it";
}
normalized = normalized.toLowerCase(Locale.ROOT);
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return switch (normalized) {
case "en", "de", "fr" -> normalized;
default -> "it";
};
}
private ShopProductModelDto toProductModelDto(ProductEntry entry) { private ShopProductModelDto toProductModelDto(ProductEntry entry) {
if (entry.modelAsset() == null) { if (entry.modelAsset() == null) {
return null; return null;
@@ -585,6 +661,7 @@ public class PublicShopCatalogService {
private record PublicProductContext( private record PublicProductContext(
List<ProductEntry> entries, List<ProductEntry> entries,
Map<String, ProductEntry> entriesBySlug, Map<String, ProductEntry> entriesBySlug,
Map<String, ProductEntry> entriesByPublicPath,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor Map<String, String> variantColorHexByMaterialAndColor
) { ) {

View File

@@ -0,0 +1,66 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopProduct;
import java.text.Normalizer;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
final class ShopPublicPathSupport {
private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/";
private ShopPublicPathSupport() {
}
static String buildProductPathSegment(ShopProduct product, String language) {
String localizedName = product.getNameForLanguage(language);
String idPrefix = productIdPrefix(product.getId());
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
}
static Map<String, String> buildLocalizedProductPaths(ShopProduct product) {
Map<String, String> localizedPaths = new LinkedHashMap<>();
for (String language : ShopProduct.SUPPORTED_LANGUAGES) {
localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language));
}
return localizedPaths;
}
static String productIdPrefix(UUID productId) {
if (productId == null) {
return "";
}
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
int dashIndex = raw.indexOf('-');
if (dashIndex > 0) {
return raw.substring(0, dashIndex);
}
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
}
static String slugify(String rawValue) {
String safeValue = rawValue == null ? "" : rawValue;
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "")
.replaceAll("-{2,}", "-");
return normalized;
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@@ -19,7 +18,6 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -31,6 +29,12 @@ public class ShopSitemapService {
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
private static final String DEFAULT_LANGUAGE = "it"; private static final String DEFAULT_LANGUAGE = "it";
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final Map<String, String> HREFLANG_BY_LANGUAGE = Map.of(
"it", "it-CH",
"en", "en-CH",
"de", "de-CH",
"fr", "fr-CH"
);
private final ShopCategoryRepository shopCategoryRepository; private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
@@ -130,7 +134,7 @@ public class ShopSitemapService {
Map<String, String> hrefByLanguage = new LinkedHashMap<>(); Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) { for (String language : SUPPORTED_LANGUAGES) {
String publicSegment = localizedProductPathSegment(product, language); String publicSegment = ShopPublicPathSupport.buildProductPathSegment(product, language);
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment)); hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
} }
@@ -169,7 +173,7 @@ public class ShopSitemapService {
continue; continue;
} }
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"") xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(language) .append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
.append("\" href=\"") .append("\" href=\"")
.append(xmlEscape(href)) .append(xmlEscape(href))
.append("\" />\n"); .append("\" />\n");
@@ -186,48 +190,6 @@ public class ShopSitemapService {
xml.append(" </url>\n"); xml.append(" </url>\n");
} }
private String localizedProductPathSegment(ShopProduct product, String language) {
String localizedName = product.getNameForLanguage(language);
String idPrefix = productIdPrefix(product.getId());
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
}
private String productIdPrefix(UUID productId) {
if (productId == null) {
return "";
}
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
int dashIndex = raw.indexOf('-');
if (dashIndex > 0) {
return raw.substring(0, dashIndex);
}
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
}
static String slugify(String rawValue) {
String safeValue = rawValue == null ? "" : rawValue;
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "")
.replaceAll("-{2,}", "-");
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private String pathEncodeSegment(String rawSegment) { private String pathEncodeSegment(String rawSegment) {
String safeSegment = rawSegment == null ? "" : rawSegment; String safeSegment = rawSegment == null ? "" : rawSegment;
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");

View File

@@ -56,7 +56,14 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch} app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600} app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
openai.translation.api-key=${OPENAI_API_KEY:}
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
# Admin back-office authentication # Admin back-office authentication
admin.password=${ADMIN_PASSWORD} admin.password=${ADMIN_PASSWORD}

View File

@@ -25,6 +25,21 @@
color: #222222; color: #222222;
} }
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
p { p {
color: #444444; color: #444444;
line-height: 1.5; line-height: 1.5;
@@ -63,7 +78,10 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1>Nuova richiesta di contatto</h1> <h1>Nuova richiesta di contatto</h1>
</div>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p> <p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table> <table>

View File

@@ -25,6 +25,21 @@
color: #222222; color: #222222;
} }
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
h2 { h2 {
margin-top: 18px; margin-top: 18px;
color: #222222; color: #222222;
@@ -69,7 +84,10 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">We received your contact request</h1> <h1 th:text="${headlineText}">We received your contact request</h1>
</div>
<p th:text="${greetingText}">Hi customer,</p> <p th:text="${greetingText}">Hi customer,</p>
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p> <p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
<p> <p>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -67,6 +76,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1> <h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1> <h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1> <h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1> <h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
</div> </div>

View File

@@ -1,7 +1,10 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig; import com.printcalculator.config.SecurityConfig;
import com.printcalculator.controller.admin.AdminAuthController; import com.printcalculator.controller.admin.AdminAuthController;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService; import com.printcalculator.security.AdminSessionService;
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = AdminAuthController.class) @WebMvcTest(controllers = AdminAuthController.class)
@Import({ @Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class, SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class, AdminSessionAuthenticationFilter.class,
AdminSessionService.class, AdminSessionService.class,
AdminLoginThrottleService.class AdminLoginThrottleService.class
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
}) })
class AdminAuthSecurityTest { class AdminAuthSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.1"); req.setRemoteAddr("10.0.0.1");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}")) .content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.2"); req.setRemoteAddr("10.0.0.2");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}")) .content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isUnauthorized())
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.3"); req.setRemoteAddr("10.0.0.3");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}")) .content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized()) .andExpect(status().isUnauthorized())
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.3"); req.setRemoteAddr("10.0.0.3");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}")) .content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isTooManyRequests()) .andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.authenticated").value(false)); .andExpect(jsonPath("$.authenticated").value(false));
} }
@Test
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.30");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
}
@Test
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
mockMvc.perform(options("/api/admin/auth/login")
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
}
@Test @Test
void adminAccessWithoutCookie_ShouldReturn401() throws Exception { void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
mockMvc.perform(get("/api/admin/auth/me")) mockMvc.perform(get("/api/admin/auth/me"))
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
req.setRemoteAddr("10.0.0.4"); req.setRemoteAddr("10.0.0.4");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}")) .content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -1,7 +1,10 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig; import com.printcalculator.config.SecurityConfig;
import com.printcalculator.service.order.AdminOrderControllerService; import com.printcalculator.service.order.AdminOrderControllerService;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService; import com.printcalculator.security.AdminSessionService;
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class}) @WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
@Import({ @Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class, SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class, AdminSessionAuthenticationFilter.class,
AdminSessionService.class, AdminSessionService.class,
AdminLoginThrottleService.class, AdminLoginThrottleService.class,
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
}) })
class AdminOrderControllerSecurityTest { class AdminOrderControllerSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
req.setRemoteAddr("10.0.0.44"); req.setRemoteAddr("10.0.0.44");
return req; return req;
}) })
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}")) .content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -0,0 +1,170 @@
package com.printcalculator.controller.admin;
import com.printcalculator.config.AllowedOriginService;
import com.printcalculator.config.CorsConfig;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
@Import({
CorsConfig.class,
AllowedOriginService.class,
SecurityConfig.class,
AdminCsrfProtectionFilter.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class,
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
})
@TestPropertySource(properties = {
"admin.password=test-admin-password",
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"admin.session.ttl-minutes=60"
})
class AdminShopProductControllerSecurityTest {
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private AdminShopProductControllerService adminShopProductControllerService;
@MockitoBean
private AdminShopProductTranslationService adminShopProductTranslationService;
@Test
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
mockMvc.perform(post("/api/admin/shop/products/translate")
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
.andExpect(status().isUnauthorized());
}
@Test
void translateProduct_withAdminCookieAndMissingOrigin_shouldReturn403() throws Exception {
mockMvc.perform(post("/api/admin/shop/products/translate")
.cookie(loginAndExtractCookie())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
}
@Test
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage("it");
response.setTargetLanguages(List.of("en", "de", "fr"));
response.setNames(Map.of("en", "Desk cable clip"));
response.setExcerpts(Map.of());
response.setDescriptions(Map.of());
response.setSeoTitles(Map.of());
response.setSeoDescriptions(Map.of());
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
.thenReturn(response);
mockMvc.perform(post("/api/admin/shop/products/translate")
.cookie(loginAndExtractCookie())
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"sourceLanguage":"it",
"overwriteExisting":false,
"materialCodes":["PLA"],
"names":{"it":"Supporto cavo"},
"excerpts":{"it":"Accessorio tecnico"},
"descriptions":{"it":"<p>Descrizione</p>"},
"seoTitles":{"it":"SEO IT"},
"seoDescriptions":{"it":"SEO description IT"}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sourceLanguage").value("it"))
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
}
private Cookie loginAndExtractCookie() throws Exception {
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.44");
return req;
})
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
.andReturn();
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
assertNotNull(setCookie);
String[] parts = setCookie.split(";", 2);
String[] keyValue = parts[0].split("=", 2);
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
}
@TestConfiguration
static class TransactionTestConfig {
@Bean
PlatformTransactionManager transactionManager() {
return new AbstractPlatformTransactionManager() {
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// No-op transaction manager for WebMvc security tests.
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// No-op transaction manager for WebMvc security tests.
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
// No-op transaction manager for WebMvc security tests.
}
};
}
}
}

View File

@@ -0,0 +1,226 @@
package com.printcalculator.service.admin;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.repository.ShopCategoryRepository;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminShopProductTranslationServiceTest {
@Mock
private ShopCategoryRepository shopCategoryRepository;
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
List<JsonNode> capturedRequests = new CopyOnWriteArrayList<>();
AtomicInteger requestCounter = new AtomicInteger();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/v1/responses", exchange -> {
capturedRequests.add(readBody(objectMapper, exchange));
int currentRequest = requestCounter.incrementAndGet();
String functionName = currentRequest == 1
? "generate_product_translations"
: "review_product_translations";
String body = functionResponse(
objectMapper,
functionName,
Map.of(
"en", localized("Desk cable clip", "Technical desk accessory", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.")
)
);
writeJsonResponse(exchange, body);
});
server.start();
when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001")))
.thenReturn(Optional.empty());
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
shopCategoryRepository,
objectMapper,
"test-key",
"http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
"gpt-5.4",
20,
"test-cache-key",
"Use concise ecommerce wording."
);
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
payload.setSourceLanguage("it");
payload.setOverwriteExisting(false);
payload.setMaterialCodes(List.of("pla", "petg"));
payload.setNames(Map.of(
"it", "Supporto cavo scrivania",
"en", "",
"de", "",
"fr", ""
));
payload.setExcerpts(Map.of(
"it", "Accessorio tecnico",
"en", "",
"de", "",
"fr", ""
));
payload.setDescriptions(Map.of(
"it", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
"en", "",
"de", "",
"fr", ""
));
payload.setSeoTitles(Map.of(
"it", "Supporto cavo scrivania | 3D fab",
"en", "",
"de", "",
"fr", ""
));
payload.setSeoDescriptions(Map.of(
"it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.",
"en", "",
"de", "",
"fr", ""
));
AdminTranslateShopProductResponse response = service.translateProduct(payload);
assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages());
assertEquals("Desk cable clip", response.getNames().get("en"));
assertTrue(response.getDescriptions().get("en").contains("<p>"));
assertEquals(2, capturedRequests.size());
assertEquals("required", capturedRequests.get(0).path("tool_choice").asText());
assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText());
assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText());
}
@Test
void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() {
ObjectMapper objectMapper = new ObjectMapper();
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
shopCategoryRepository,
objectMapper,
"test-key",
"http://127.0.0.1:65535/v1",
"gpt-5.4",
20,
"test-cache-key",
""
);
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
payload.setSourceLanguage("it");
payload.setOverwriteExisting(false);
payload.setNames(Map.of(
"it", "Supporto cavo scrivania",
"en", "Desk cable clip",
"de", "Schreibtisch-Kabelhalter",
"fr", "Support de cable de bureau"
));
payload.setExcerpts(Map.of(
"it", "Accessorio tecnico",
"en", "Technical desk accessory",
"de", "Technisches Schreibtisch-Zubehor",
"fr", "Accessoire technique de bureau"
));
payload.setDescriptions(Map.of(
"it", "<p>Descrizione</p>",
"en", "<p>Description</p>",
"de", "<p>Beschreibung</p>",
"fr", "<p>Description</p>"
));
payload.setSeoTitles(Map.of(
"it", "SEO IT",
"en", "SEO EN",
"de", "SEO DE",
"fr", "SEO FR"
));
payload.setSeoDescriptions(Map.of(
"it", "SEO description IT",
"en", "SEO description EN",
"de", "SEO description DE",
"fr", "SEO description FR"
));
AdminTranslateShopProductResponse response = service.translateProduct(payload);
assertTrue(response.getTargetLanguages().isEmpty());
}
private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException {
return objectMapper.readTree(exchange.getRequestBody());
}
private void writeJsonResponse(HttpExchange exchange, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
private String functionResponse(ObjectMapper objectMapper,
String functionName,
Map<String, Map<String, String>> translations) throws IOException {
Map<String, Object> arguments = Map.of("translations", translations);
Map<String, Object> item = Map.of(
"type", "function_call",
"name", functionName,
"arguments", objectMapper.writeValueAsString(arguments)
);
Map<String, Object> response = Map.of(
"id", "resp_test",
"output", List.of(item)
);
return objectMapper.writeValueAsString(response);
}
private Map<String, String> localized(String name,
String excerpt,
String description,
String seoTitle,
String seoDescription) {
return Map.of(
"name", name,
"excerpt", excerpt,
"description", description,
"seoTitle", seoTitle,
"seoDescription", seoDescription
);
}
}

View File

@@ -0,0 +1,176 @@
package com.printcalculator.service.shop;
import com.printcalculator.dto.ShopProductCatalogResponseDto;
import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.media.PublicMediaQueryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PublicShopCatalogServiceTest {
@Mock
private ShopCategoryRepository shopCategoryRepository;
@Mock
private ShopProductRepository shopProductRepository;
@Mock
private ShopProductVariantRepository shopProductVariantRepository;
@Mock
private ShopProductModelAssetRepository shopProductModelAssetRepository;
@Mock
private FilamentVariantRepository filamentVariantRepository;
@Mock
private PublicMediaQueryService publicMediaQueryService;
@Mock
private ShopStorageService shopStorageService;
private PublicShopCatalogService service;
@BeforeEach
void setUp() {
service = new PublicShopCatalogService(
shopCategoryRepository,
shopProductRepository,
shopProductVariantRepository,
shopProductModelAssetRepository,
filamentVariantRepository,
publicMediaQueryService,
shopStorageService
);
}
@Test
void getProductCatalog_shouldExposePublicPathAsSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
assertEquals(1, response.products().size());
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
}
@Test
void getProduct_shouldExposePublicPathAsSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
assertEquals("12345678-bike-wall-hanger", response.publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
}
@Test
void getProductByPublicPath_shouldResolveLocalizedSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
assertEquals("bike-wall-hanger", response.slug());
assertEquals("12345678-bike-wall-hanger", response.publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
}
@Test
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
);
assertEquals(404, exception.getStatusCode().value());
}
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
.thenReturn(List.of(variant));
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
}
private ShopCategory buildCategory() {
ShopCategory category = new ShopCategory();
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
category.setSlug("accessori");
category.setName("Accessori");
category.setNameIt("Accessori");
category.setNameEn("Accessories");
category.setIsActive(true);
category.setSortOrder(0);
return category;
}
private ShopProduct buildProduct(ShopCategory category) {
ShopProduct product = new ShopProduct();
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
product.setCategory(category);
product.setSlug("bike-wall-hanger");
product.setName("Bike Wall-Hanger");
product.setNameIt("Supporto bici");
product.setNameEn("Bike Wall-Hanger");
product.setIsActive(true);
product.setIsFeatured(true);
product.setSortOrder(0);
return product;
}
private ShopProductVariant buildVariant(ShopProduct product) {
ShopProductVariant variant = new ShopProductVariant();
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
variant.setProduct(product);
variant.setVariantLabel("PLA");
variant.setColorName("Grigio");
variant.setInternalMaterialCode("PLA");
variant.setPriceChf(new BigDecimal("29.90"));
variant.setIsActive(true);
variant.setIsDefault(true);
variant.setSortOrder(0);
return variant;
}
}

View File

@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\"")); assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/accessori\""));
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza")); assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\"")); assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\"")); assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>")); assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
assertFalse(xml.contains("33333333-draft")); assertFalse(xml.contains("33333333-draft"));

View File

@@ -29,6 +29,12 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-}
- OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-}
- OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-}
- OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-}
- OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-}
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}

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

View File

@@ -1 +1,14 @@
<router-outlet></router-outlet> <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 { RouterOutlet } from '@angular/router';
import { SeoService } from './core/services/seo.service'; import { SeoService } from './core/services/seo.service';
import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet], imports: [RouterOutlet, BrandAnimationLogoComponent],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent { export class AppComponent {
private readonly seoService = inject(SeoService); 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 { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
import { catchError, firstValueFrom, of } from 'rxjs'; import { catchError, firstValueFrom, of } from 'rxjs';
import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
import {
type SupportedLang = 'it' | 'en' | 'de' | 'fr'; getNavigatorLanguagePreferences,
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr']; parseAcceptLanguage,
resolveInitialLanguage,
function resolveLangFromUrl(url: string): SupportedLang { SUPPORTED_LANGS,
const firstSegment = (url || '/') } from './core/i18n/language-resolution';
.split('?')[0]
.split('#')[0]
.split('/')
.filter(Boolean)[0]
?.toLowerCase();
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
? (firstSegment as SupportedLang)
: 'it';
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = {
withComponentInputBinding(), withComponentInputBinding(),
withViewTransitions(), withViewTransitions(),
withInMemoryScrolling({ withInMemoryScrolling({
scrollPositionRestoration: 'top', scrollPositionRestoration: 'enabled',
}), }),
), ),
provideHttpClient( provideHttpClient(
@@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = {
), ),
importProvidersFrom( importProvidersFrom(
TranslateModule.forRoot({ TranslateModule.forRoot({
defaultLanguage: 'it', fallbackLang: 'it',
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: StaticTranslateLoader, useClass: StaticTranslateLoader,
@@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = {
const router = inject(Router); const router = inject(Router);
const request = inject(REQUEST, { optional: true }) as { const request = inject(REQUEST, { optional: true }) as {
url?: string; url?: string;
headers?: Record<string, string | string[] | undefined>;
} | null; } | null;
translate.addLangs([...SUPPORTED_LANGS]); translate.addLangs([...SUPPORTED_LANGS]);
translate.setDefaultLang('it'); translate.setFallbackLang('it');
const requestedUrl = const requestedUrl =
(typeof request?.url === 'string' && request.url) || router.url || '/'; (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( return firstValueFrom(
translate.use(lang).pipe( translate.use(lang).pipe(
@@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = {
provideClientHydration(withEventReplay()), 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,18 +15,8 @@ const appChildRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent), import('./features/home/home.component').then((m) => m.HomeComponent),
data: { data: {
seoTitleByLang: { seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab', seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab',
de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab',
fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab',
},
seoDescriptionByLang: {
it: '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.',
en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.',
de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.',
fr: "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.",
},
}, },
}, },
{ {
@@ -36,9 +26,8 @@ const appChildRoutes: Routes = [
(m) => m.CALCULATOR_ROUTES, (m) => m.CALCULATOR_ROUTES,
), ),
data: { data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab', seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
}, },
}, },
{ {
@@ -46,9 +35,8 @@ const appChildRoutes: Routes = [
loadChildren: () => loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES), import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: { data: {
seoTitle: 'Shop 3D fab', seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
}, },
}, },
{ {
@@ -56,9 +44,8 @@ const appChildRoutes: Routes = [
loadChildren: () => loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES), import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: { data: {
seoTitle: 'Chi siamo | 3D fab', seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
}, },
}, },
/* { /* {
@@ -68,9 +55,8 @@ const appChildRoutes: Routes = [
(m) => m.MaterialsPageComponent, (m) => m.MaterialsPageComponent,
), ),
data: { data: {
seoTitle: 'Qualita e Materiali | 3D fab', seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
}, },
},*/ },*/
{ {
@@ -78,9 +64,8 @@ const appChildRoutes: Routes = [
loadChildren: () => loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: { data: {
seoTitle: 'Contatti | 3D fab', seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
}, },
}, },
{ {
@@ -90,7 +75,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: { data: {
seoTitle: 'Checkout | 3D fab', seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow', seoRobots: 'noindex, nofollow',
}, },
}, },
@@ -101,7 +87,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent, (m) => m.CheckoutComponent,
), ),
data: { data: {
seoTitle: 'Checkout | 3D fab', seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow', seoRobots: 'noindex, nofollow',
}, },
}, },
@@ -110,7 +97,8 @@ const appChildRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: { data: {
seoTitle: 'Ordine | 3D fab', seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow', seoRobots: 'noindex, nofollow',
}, },
}, },
@@ -119,7 +107,8 @@ const appChildRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent), import('./features/order/order.component').then((m) => m.OrderComponent),
data: { data: {
seoTitle: 'Ordine | 3D fab', seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow', seoRobots: 'noindex, nofollow',
}, },
}, },
@@ -133,7 +122,8 @@ const appChildRoutes: Routes = [
loadChildren: () => loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES), import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: { data: {
seoTitle: 'Admin | 3D fab', seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
seoRobots: 'noindex, nofollow', seoRobots: 'noindex, nofollow',
}, },
}, },
@@ -144,6 +134,31 @@ const appChildRoutes: Routes = [
]; ];
export const routes: 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', path: ':lang',
canMatch: [langPrefixCanMatch], canMatch: [langPrefixCanMatch],

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 { TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs'; import { from, Observable } 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';
const TRANSLATIONS: Record<string, TranslationObject> = { type SupportedLang = 'it' | 'en' | 'de' | 'fr';
it: it as TranslationObject,
en: en as TranslationObject, const FALLBACK_LANG: SupportedLang = 'it';
de: de as TranslationObject, const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
fr: fr as 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() @Injectable()
export class StaticTranslateLoader implements TranslateLoader { export class StaticTranslateLoader implements TranslateLoader {
private readonly platformId = inject(PLATFORM_ID);
private readonly transferState = inject(TransferState);
getTranslation(lang: string): Observable<TranslationObject> { getTranslation(lang: string): Observable<TranslationObject> {
const normalized = String(lang || 'it').toLowerCase(); const normalized = this.normalizeLanguage(lang);
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); 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 { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core'; import { inject, REQUEST } from '@angular/core';
import {
RequestLike,
resolveRequestOrigin,
} from '../../../core/request-origin';
type RequestLike = { const FORWARDED_REQUEST_HEADERS = [
protocol?: string; 'authorization',
get?: (name: string) => string | undefined; 'cookie',
headers?: Record<string, string | string[] | undefined>; 'accept-language',
}; ] as const;
function isAbsoluteUrl(url: string): boolean { function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); 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 { function normalizeRelativePath(url: string): string {
const withoutDot = url.replace(/^\.\//, ''); const withoutDot = url.replace(/^\.\//, '');
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; 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) => { export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
if (isAbsoluteUrl(req.url)) { if (isAbsoluteUrl(req.url)) {
return next(req); return next(req);
} }
const request = inject(REQUEST, { optional: true }) as RequestLike | null; const request = inject(REQUEST, { optional: true }) as RequestLike | null;
const origin = resolveOrigin(request); const origin = resolveRequestOrigin(request);
if (!origin) { if (!origin) {
return next(req); return next(req);
} }
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; 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"> <footer class="footer">
<div class="container footer-inner"> <div class="container footer-inner">
<div class="col"> <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> <p class="copyright">&copy; 2026 3D fab.</p>
</div> </div>
<div class="col links"> <div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a> <a [routerLink]="languageService.localizedPath('/privacy')">{{
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a> "FOOTER.PRIVACY" | translate
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a> }}</a>
<a [routerLink]="languageService.localizedPath('/terms')">{{
"FOOTER.TERMS" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/contact')">{{
"FOOTER.CONTACT" | translate
}}</a>
</div> </div>
<div class="col social"> <div class="col social">

View File

@@ -38,9 +38,10 @@
} }
.brand { .brand {
font-weight: 700;
color: white;
display: block; display: block;
width: auto;
height: 1.85rem;
max-width: min(9.25rem, 46vw);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.copyright { .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 { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { LanguageService } from '../services/language.service';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'], styleUrls: ['./footer.component.scss'],
}) })
export class FooterComponent {} export class FooterComponent {
readonly languageService = inject(LanguageService);
}

View File

@@ -1,6 +1,15 @@
<header class="navbar"> <header class="navbar">
<div class="container navbar-inner"> <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 <div
class="mobile-toggle" class="mobile-toggle"
@@ -14,27 +23,33 @@
<nav class="nav-links" [class.open]="isMenuOpen"> <nav class="nav-links" [class.open]="isMenuOpen">
<a <a
routerLink="/" [routerLink]="langService.localizedPath('/')"
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }" [routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()" (click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a >{{ "NAV.HOME" | translate }}</a
> >
<a <a
routerLink="/calculator/basic" [routerLink]="langService.localizedPath('/calculator/basic')"
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }" [routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()" (click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a >{{ "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 <a
routerLink="/contact" [routerLink]="langService.localizedPath('/shop')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.SHOP" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/about')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.ABOUT" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/contact')"
routerLinkActive="active" routerLinkActive="active"
(click)="closeMenu()" (click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a >{{ "NAV.CONTACT" | translate }}</a
@@ -82,7 +97,10 @@
} }
</select> </select>
<div class="icon-placeholder" routerLink="/admin"> <div
class="icon-placeholder"
[routerLink]="langService.localizedPath('/admin')"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="20" width="20"
@@ -130,7 +148,9 @@
<div class="cart-line-copy"> <div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong> <strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) { @if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant | translate }}</span> <span class="cart-line-meta">{{
variant | translate
}}</span>
} }
@if (cartItemColor(item); as color) { @if (cartItemColor(item); as color) {
<span class="cart-line-color"> <span class="cart-line-color">

View File

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

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import {
afterNextRender,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
NavigationStart, NavigationStart,
@@ -23,7 +30,13 @@ import {
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule], imports: [
CommonModule,
RouterLink,
RouterLinkActive,
TranslateModule,
NgOptimizedImage,
],
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'], styleUrls: ['./navbar.component.scss'],
}) })
@@ -58,16 +71,9 @@ export class NavbarComponent {
]; ];
constructor(public langService: LanguageService) { constructor(public langService: LanguageService) {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
}); });
}
this.router.events this.router.events
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -96,6 +102,9 @@ export class NavbarComponent {
toggleCart(): void { toggleCart(): void {
this.closeMenu(); this.closeMenu();
this.isCartOpen.update((open) => !open); this.isCartOpen.update((open) => !open);
if (this.isCartOpen()) {
this.loadCartIfNeeded();
}
} }
closeCart(): void { closeCart(): void {
@@ -133,7 +142,7 @@ export class NavbarComponent {
} }
this.closeCart(); this.closeCart();
this.router.navigate(['/checkout'], { this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
queryParams: { queryParams: {
session: sessionId, session: sessionId,
}, },
@@ -192,5 +201,44 @@ export class NavbarComponent {
.subscribe(); .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; protected readonly routes = routes;
} }

View File

@@ -1,7 +1,13 @@
import { Subject } from 'rxjs'; 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 { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service'; import { LanguageService } from './language.service';
import { RequestLike } from '../../../core/request-origin';
describe('LanguageService', () => { describe('LanguageService', () => {
function createTranslateMock() { function createTranslateMock() {
@@ -9,7 +15,7 @@ describe('LanguageService', () => {
const translate = { const translate = {
currentLang: '', currentLang: '',
addLangs: jasmine.createSpy('addLangs'), addLangs: jasmine.createSpy('addLangs'),
setDefaultLang: jasmine.createSpy('setDefaultLang'), setFallbackLang: jasmine.createSpy('setFallbackLang'),
use: jasmine.createSpy('use').and.callFake((lang: string) => { use: jasmine.createSpy('use').and.callFake((lang: string) => {
translate.currentLang = lang; translate.currentLang = lang;
onLangChange.next({ lang }); onLangChange.next({ lang });
@@ -60,7 +66,14 @@ describe('LanguageService', () => {
parseUrl: (url: string) => serializer.parse(url), parseUrl: (url: string) => serializer.parse(url),
createUrlTree, createUrlTree,
serializeUrl: (tree: UrlTree) => serializer.serialize(tree), 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; return router as unknown as Router;
@@ -70,11 +83,17 @@ describe('LanguageService', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc'); const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; 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 // 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.use).toHaveBeenCalledWith('it');
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
expect(navigateSpy).toHaveBeenCalledTimes(1); expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent(); const firstCall = navigateSpy.calls.mostRecent();
@@ -84,6 +103,48 @@ describe('LanguageService', () => {
expect(navOptions.replaceUrl).toBeTrue(); 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', () => { it('switches language while preserving path and query params', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/it/calculator?session=abc&mode=advanced'); const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
@@ -103,4 +164,34 @@ describe('LanguageService', () => {
'/de/calculator?session=abc&mode=advanced', '/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 { TranslateService } from '@ngx-translate/core';
import { import {
NavigationEnd, NavigationEnd,
@@ -6,25 +6,32 @@ import {
Router, Router,
UrlTree, UrlTree,
} from '@angular/router'; } 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class LanguageService { export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); currentLang = signal<SupportedLang>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [ private readonly defaultLang: SupportedLang = 'it';
'it', private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
'en', private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
'de',
'fr',
];
constructor( constructor(
private translate: TranslateService, private translate: TranslateService,
private router: Router, private router: Router,
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
) { ) {
this.translate.addLangs(this.supportedLangs); this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it'); this.translate.setFallbackLang('it');
this.translate.onLangChange.subscribe((event) => { this.translate.onLangChange.subscribe((event) => {
const lang = const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null; typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
@@ -34,13 +41,14 @@ export class LanguageService {
}); });
const initialTree = this.router.parseUrl(this.router.url); const initialTree = this.router.parseUrl(this.router.url);
const initialSegments = this.getPrimarySegments(initialTree); const initialLang = resolveInitialLanguage({
const queryLang = this.getQueryLang(initialTree); url: this.router.url,
const initialLang = this.isSupportedLang(initialSegments[0]) preferredLanguages: this.request
? initialSegments[0] ? parseAcceptLanguage(this.readRequestHeader('accept-language'))
: this.isSupportedLang(queryLang) : getNavigatorLanguagePreferences(
? queryLang typeof navigator === 'undefined' ? null : navigator,
: 'it'; ),
});
this.applyLanguage(initialLang); this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree); this.ensureLanguageInPath(initialTree);
@@ -53,13 +61,21 @@ export class LanguageService {
}); });
} }
switchLang(lang: 'it' | 'en' | 'de' | 'fr') { switchLang(lang: SupportedLang) {
if (!this.isSupportedLang(lang)) { if (!this.isSupportedLang(lang)) {
return; return;
} }
this.applyLanguage(lang);
const currentTree = this.router.parseUrl(this.router.url); 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); const segments = this.getPrimarySegments(currentTree);
let targetSegments: string[]; let targetSegments: string[];
@@ -77,7 +93,7 @@ export class LanguageService {
this.navigateIfChanged(currentTree, targetSegments); this.navigateIfChanged(currentTree, targetSegments);
} }
selectedLang(): 'it' | 'en' | 'de' | 'fr' { selectedLang(): SupportedLang {
const activeLang = const activeLang =
typeof this.translate.currentLang === 'string' typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase() ? this.translate.currentLang.toLowerCase()
@@ -85,6 +101,41 @@ export class LanguageService {
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang(); 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 { private ensureLanguageInPath(urlTree: UrlTree): void {
const segments = this.getPrimarySegments(urlTree); const segments = this.getPrimarySegments(urlTree);
@@ -93,23 +144,26 @@ export class LanguageService {
return; return;
} }
if (segments.length === 0) {
const queryLang = this.getQueryLang(urlTree); const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang) const rootLang = this.isSupportedLang(queryLang)
? queryLang ? queryLang
: this.currentLang(); : this.currentLang();
if (activeLang !== this.currentLang()) { if (rootLang !== this.currentLang()) {
this.applyLanguage(activeLang); this.applyLanguage(rootLang);
}
this.navigateIfChanged(urlTree, [rootLang]);
return;
} }
let targetSegments: string[];
if (segments.length === 0) { if (this.currentLang() !== this.defaultLang) {
targetSegments = [activeLang]; this.applyLanguage(this.defaultLang);
} else if (this.looksLikeLangToken(segments[0])) {
targetSegments = [activeLang, ...segments.slice(1)];
} else {
targetSegments = [activeLang, ...segments];
} }
const targetSegments = this.looksLikeLangToken(segments[0])
? [this.defaultLang, ...segments.slice(1)]
: [this.defaultLang, ...segments];
this.navigateIfChanged(urlTree, targetSegments); this.navigateIfChanged(urlTree, targetSegments);
} }
@@ -126,12 +180,23 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null; 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( private isSupportedLang(
lang: string | null | undefined, lang: string | null | undefined,
): lang is 'it' | 'en' | 'de' | 'fr' { ): lang is SupportedLang {
return ( return (
typeof lang === 'string' && 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) { if (this.currentLang() === lang && this.translate.currentLang === lang) {
return; return;
} }
@@ -149,6 +214,88 @@ export class LanguageService {
this.currentLang.set(lang); 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( private navigateIfChanged(
currentTree: UrlTree, currentTree: UrlTree,
targetSegments: string[], 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,18 +2,34 @@ import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser'; import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
export interface PageSeoOverride { export interface PageSeoOverride {
title?: string | null; title?: string | null;
titleKey?: string | null;
description?: string | null; description?: string | null;
descriptionKey?: string | null;
robots?: string | null; robots?: string | null;
ogTitle?: string | null; ogTitle?: string | null;
ogTitleKey?: string | null;
ogDescription?: string | null; ogDescription?: string | null;
ogDescriptionKey?: string | null;
} }
type SupportedLang = 'it' | 'en' | 'de' | 'fr'; 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 SeoMap = Partial<Record<SupportedLang, string>>;
type SeoTextDataKey =
| 'seoTitle'
| 'seoDescription'
| 'ogTitle'
| 'ogDescription';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -31,17 +47,33 @@ export class SeoService {
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.', 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.", fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
}; };
private readonly supportedLangs = new Set<SupportedLang>([ private readonly supportedLangs: readonly SupportedLang[] = [
'it', 'it',
'en', 'en',
'de', 'de',
'fr', '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( constructor(
private router: Router, private router: Router,
private titleService: Title, private titleService: Title,
private metaService: Meta, private metaService: Meta,
private translate: TranslateService,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
) { ) {
this.applyRouteSeo(this.router.routerState.snapshot.root); this.applyRouteSeo(this.router.routerState.snapshot.root);
@@ -59,14 +91,48 @@ export class SeoService {
applyPageSeo(override: PageSeoOverride): void { applyPageSeo(override: PageSeoOverride): void {
const cleanPath = this.getCleanPath(this.router.url); const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath); const lang = this.resolveLangFromPath(cleanPath);
const title = this.asString(override.title) ?? this.defaultTitleByLang[lang]; const { title, description, robots, ogTitle, ogDescription } =
const description = this.resolvePageSeoOverride(override, lang);
this.asString(override.description) ?? this.defaultDescriptionByLang[lang]; const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const robots = this.asString(override.robots) ?? 'index, follow'; const alternates = this.buildAlternatePaths(canonicalPath);
const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description;
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 { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
@@ -75,16 +141,29 @@ export class SeoService {
const lang = this.resolveLangFromPath(cleanPath); const lang = this.resolveLangFromPath(cleanPath);
const title = const title =
this.resolveSeoText(mergedData, 'seoTitle', lang) ?? this.resolveSeoText(mergedData, 'seoTitle', lang) ??
this.defaultTitleByLang[lang]; this.defaultTitle(lang);
const description = const description =
this.resolveSeoText(mergedData, 'seoDescription', lang) ?? this.resolveSeoText(mergedData, 'seoDescription', lang) ??
this.defaultDescriptionByLang[lang]; this.defaultDescription(lang);
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title; const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription = const ogDescription =
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? 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( private applySeoValues(
@@ -93,6 +172,11 @@ export class SeoService {
robots: string, robots: string,
ogTitle: string, ogTitle: string,
ogDescription: string, ogDescription: string,
cleanPath: string,
canonicalPath: string | null,
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang,
): void { ): void {
this.titleService.setTitle(title); this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description }); this.metaService.updateTag({ name: 'description', content: description });
@@ -103,13 +187,21 @@ export class SeoService {
content: ogDescription, content: ogDescription,
}); });
this.metaService.updateTag({ property: 'og:type', content: 'website' }); 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: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 ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
const canonical = `${this.document.location.origin}${cleanPath}`; this.metaService.updateTag({ property: 'og:url', content: ogUrl });
this.metaService.updateTag({ property: 'og:url', content: canonical }); this.updateCanonicalTag(
this.updateCanonicalTag(canonical); canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
this.updateLangAndAlternates(cleanPath); );
this.updateOpenGraphLocales(lang);
this.updateLangAndAlternates(alternates, xDefaultPath, lang);
} }
private getMergedRouteData( private getMergedRouteData(
@@ -128,23 +220,100 @@ export class SeoService {
return typeof value === 'string' ? value : undefined; 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( private resolveSeoText(
routeData: Record<string, unknown>, routeData: Record<string, unknown>,
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription', key: SeoTextDataKey,
lang: SupportedLang, lang: SupportedLang,
): string | undefined { ): string | undefined {
const mapKey = `${key}ByLang`; const mapKey = `${key}ByLang`;
const localized = routeData[mapKey]; const localized = routeData[mapKey];
if (localized && typeof localized === 'object' && !Array.isArray(localized)) { if (
localized &&
typeof localized === 'object' &&
!Array.isArray(localized)
) {
const mapped = localized as SeoMap; const mapped = localized as SeoMap;
const byLang = this.asString(mapped[lang]); const byLang = this.asString(mapped[lang]);
if (byLang) { if (byLang) {
return byLang; return byLang;
} }
} }
const translated = this.resolveTranslation(routeData[`${key}Key`]);
if (translated) {
return translated;
}
return this.asString(routeData[key]); 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 { private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0]; const path = (url || '/').split('?')[0].split('#')[0];
return path || '/'; return path || '/';
@@ -152,16 +321,105 @@ export class SeoService {
private resolveLangFromPath(path: string): SupportedLang { private resolveLangFromPath(path: string): SupportedLang {
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase(); const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
if (firstSegment && this.supportedLangs.has(firstSegment as SupportedLang)) { if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
return firstSegment as SupportedLang; return firstSegment as SupportedLang;
} }
return 'it'; return 'it';
} }
private updateCanonicalTag(url: string): void { 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( let link = this.document.head.querySelector(
'link[rel="canonical"]', 'link[rel="canonical"]',
) as HTMLLinkElement | null; ) as HTMLLinkElement | null;
if (!url) {
link?.remove();
return;
}
if (!link) { if (!link) {
link = this.document.createElement('link'); link = this.document.createElement('link');
link.setAttribute('rel', 'canonical'); link.setAttribute('rel', 'canonical');
@@ -170,33 +428,55 @@ export class SeoService {
link.setAttribute('href', url); link.setAttribute('href', url);
} }
private updateLangAndAlternates(path: string): void { private updateOpenGraphLocales(lang: SupportedLang): void {
const segments = path.split('/').filter(Boolean); this.metaService.updateTag({
const firstSegment = segments[0]?.toLowerCase(); property: 'og:locale',
const maybeLang = firstSegment as SupportedLang | undefined; content: this.ogLocaleByLang[lang],
const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang)); });
const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
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 this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove()); .forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) { if (!alternates) {
this.appendAlternateLink( return;
alt, }
`${this.document.location.origin}/${alt}${suffix}`,
); for (const alt of this.supportedLangs) {
const path = alternates[alt];
if (!path) {
continue;
} }
this.appendAlternateLink( this.appendAlternateLink(
'x-default', this.seoLocaleByLang[alt],
`${this.document.location.origin}/it${suffix}`, this.toAbsoluteUrl(path),
); );
} }
if (xDefaultPath) {
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
}
}
private appendAlternateLink(hreflang: string, href: string): void { private appendAlternateLink(hreflang: string, href: string): void {
const link = this.document.createElement('link'); const link = this.document.createElement('link');
@@ -206,4 +486,12 @@ export class SeoService {
link.setAttribute('data-seo-managed', 'true'); link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link); 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: '', path: '',
component: AboutPageComponent, component: AboutPageComponent,
data: { data: {
seoTitle: 'Chi siamo | 3D fab', seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescription: seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
}, },
}, },
]; ];

View File

@@ -668,9 +668,31 @@
<div> <div>
<h3>Contenuti localizzati</h3> <h3>Contenuti localizzati</h3>
<p> <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> </p>
</div> </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>
<div class="ui-language-toolbar"> <div class="ui-language-toolbar">

View File

@@ -18,6 +18,8 @@ import {
AdminShopProductModel, AdminShopProductModel,
AdminShopProductVariant, AdminShopProductVariant,
AdminShopService, AdminShopService,
AdminTranslateShopProductPayload,
AdminTranslateShopProductResponse,
AdminUpsertShopCategoryPayload, AdminUpsertShopCategoryPayload,
AdminUpsertShopProductPayload, AdminUpsertShopProductPayload,
AdminUpsertShopProductVariantPayload, AdminUpsertShopProductVariantPayload,
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
loading = false; loading = false;
detailLoading = false; detailLoading = false;
savingProduct = false; savingProduct = false;
translatingProduct = false;
deletingProduct = false; deletingProduct = false;
savingCategory = false; savingCategory = false;
deletingCategory = false; deletingCategory = false;
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
productStatusFilter: ProductStatusFilter = 'ALL'; productStatusFilter: ProductStatusFilter = 'ALL';
showCategoryManager = false; showCategoryManager = false;
activeContentLanguage: ShopLanguage = 'it'; activeContentLanguage: ShopLanguage = 'it';
overwriteExistingTranslations = false;
errorMessage: string | null = null; errorMessage: string | null = null;
successMessage: string | null = null; successMessage: string | null = null;
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
this.categoryForm.slug = this.slugify(source); 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 { setActiveContentLanguage(language: ShopLanguage): void {
this.syncDescriptionFromEditor(this.descriptionEditorElement, true); this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
this.activeContentLanguage = language; this.activeContentLanguage = language;
@@ -1343,7 +1393,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']), seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']),
seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']), seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']),
seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']), seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']),
seoDescription: this.optionalValue(this.categoryForm.seoDescriptions['it']), seoDescription: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionIt: this.optionalValue( seoDescriptionIt: this.optionalValue(
this.categoryForm.seoDescriptions['it'], this.categoryForm.seoDescriptions['it'],
), ),
@@ -1667,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[] { private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
const existingVariantsByKey = new Map( const existingVariantsByKey = new Map(
(this.selectedProduct?.variants ?? []).map((variant) => [ (this.selectedProduct?.variants ?? []).map((variant) => [

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; altText: string;
} }
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
export interface AdminShopCategoryRef { export interface AdminShopCategoryRef {
id: string; id: string;
slug: string; slug: string;
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
variants: AdminUpsertShopProductVariantPayload[]; 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
@@ -351,6 +375,18 @@ export class AdminShopService {
}); });
} }
translateProduct(
payload: AdminTranslateShopProductPayload,
): Observable<AdminTranslateShopProductResponse> {
return this.http.post<AdminTranslateShopProductResponse>(
`${this.productsBaseUrl}/translate`,
payload,
{
withCredentials: true,
},
);
}
uploadProductModel( uploadProductModel(
productId: string, productId: string,
file: File, 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()) { @if (loading()) {
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <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"> <h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }} {{ "CALC.ANALYZING_TITLE" | translate }}
</h3> </h3>

View File

@@ -93,7 +93,7 @@
.loader-content { .loader-content {
text-align: center; text-align: center;
max-width: 300px; max-width: 22rem;
margin: 0 auto; margin: 0 auto;
/* Center content vertically within the stretched card */ /* Center content vertically within the stretched card */
@@ -101,12 +101,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center;
gap: var(--space-3);
} }
.loading-title { .loading-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: var(--space-4) 0 var(--space-2); margin: 0;
color: var(--color-text); color: var(--color-text);
} }
@@ -114,23 +116,21 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-muted); color: var(--color-text-muted);
line-height: 1.5; line-height: 1.5;
margin: 0;
} }
.spinner { .loader-logo {
border: 3px solid var(--color-neutral-200); display: block;
border-left-color: var(--color-brand); width: min(100%, 16rem);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto; margin: 0 auto;
} --brand-animation-width: 16rem;
--brand-animation-height: 4.8rem;
@keyframes spin { --brand-animation-letter-width: 2.85rem;
0% { --brand-animation-scale: 0.84;
transform: rotate(0deg); --brand-animation-word-spacing: 0.97;
} --brand-animation-width-mobile: 14rem;
100% { --brand-animation-height-mobile: 4.1rem;
transform: rotate(360deg); --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 { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.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 { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { import {
@@ -48,6 +49,7 @@ type TrackedPrintSettings = {
AppCardComponent, AppCardComponent,
AppAlertComponent, AppAlertComponent,
AppButtonComponent, AppButtonComponent,
BrandAnimationLogoComponent,
UploadFormComponent, UploadFormComponent,
QuoteResultComponent, QuoteResultComponent,
SuccessStateComponent, SuccessStateComponent,

View File

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

View File

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

View File

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

View File

@@ -120,13 +120,18 @@
<input type="checkbox" formControlName="acceptLegal" /> <input type="checkbox" formControlName="acceptLegal" />
<span> <span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }} {{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ <a
"LEGAL.CONSENT.TERMS_LINK" | translate [href]="languageService.localizedPath('/terms')"
}}</a> target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }} {{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ <a
"LEGAL.CONSENT.PRIVACY_LINK" | translate [href]="languageService.localizedPath('/privacy')"
}}</a target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>. >.
</span> </span>
</label> </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 { CommonModule } from '@angular/common';
import { import {
ReactiveFormsModule, 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 { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteResult } from '../../services/quote-estimator.service'; import { QuoteResult } from '../../services/quote-estimator.service';
import { LanguageService } from '../../../../core/services/language.service';
@Component({ @Component({
selector: 'app-user-details', selector: 'app-user-details',
@@ -30,6 +31,7 @@ export class UserDetailsComponent {
quote = input<QuoteResult>(); quote = input<QuoteResult>();
submitOrder = output<any>(); submitOrder = output<any>();
cancel = output<void>(); cancel = output<void>();
readonly languageService = inject(LanguageService);
form: FormGroup; form: FormGroup;
submitting = signal(false); submitting = signal(false);

View File

@@ -2,9 +2,9 @@
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero"> <div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1> <h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()"> <p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
Servizio CAD {{ "CHECKOUT.CAD_SERVICE" | translate }}
<ng-container *ngIf="cadRequestId()"> <ng-container *ngIf="cadRequestId()">
riferito alla richiesta contatto #{{ cadRequestId() }} {{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
</ng-container> </ng-container>
</p> </p>
</div> </div>
@@ -204,13 +204,18 @@
<span class="ui-checkbox__mark"></span> <span class="ui-checkbox__mark"></span>
<span> <span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }} {{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ <a
"LEGAL.CONSENT.TERMS_LINK" | translate [href]="languageService.localizedPath('/terms')"
}}</a> target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }} {{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ <a
"LEGAL.CONSENT.PRIVACY_LINK" | translate [href]="languageService.localizedPath('/privacy')"
}}</a target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>. >.
</span> </span>
</label> </label>
@@ -329,7 +334,9 @@
</div> </div>
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0"> <div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
<div class="item-details"> <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 class="item-specs-sub">{{ cadHours() }}h</div>
</div> </div>
<div class="item-price"> <div class="item-price">

View File

@@ -51,7 +51,7 @@ export class CheckoutComponent implements OnInit {
private quoteService = inject(QuoteEstimatorService); private quoteService = inject(QuoteEstimatorService);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private languageService = inject(LanguageService); readonly languageService = inject(LanguageService);
checkoutForm: FormGroup; checkoutForm: FormGroup;
sessionId: string | null = null; sessionId: string | null = null;
@@ -147,7 +147,7 @@ export class CheckoutComponent implements OnInit {
this.sessionId = params['session']; this.sessionId = params['session'];
if (!this.sessionId) { if (!this.sessionId) {
this.error = 'CHECKOUT.ERR_NO_SESSION_START'; this.error = 'CHECKOUT.ERR_NO_SESSION_START';
this.router.navigate(['/']); // Redirect if no session this.router.navigate(['/', this.languageService.selectedLang()]);
return; return;
} }

View File

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

View File

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

View File

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

View File

@@ -16,15 +16,21 @@
{{ "HOME.HERO_SUBTITLE" | translate }} {{ "HOME.HERO_SUBTITLE" | translate }}
</p> </p>
<div class="hero-actions ui-inline-actions ui-inline-actions--wide"> <div class="hero-actions ui-inline-actions ui-inline-actions--wide">
<app-button variant="primary" routerLink="/calculator/basic">{{ <app-button
"HOME.BTN_CALCULATE" | translate variant="primary"
}}</app-button> [routerLink]="languageService.localizedPath('/calculator/basic')"
<app-button variant="outline" routerLink="/shop">{{ >{{ "HOME.BTN_CALCULATE" | translate }}</app-button
"HOME.BTN_SHOP" | translate >
}}</app-button> <app-button
<app-button variant="text" routerLink="/contact">{{ variant="outline"
"HOME.BTN_CONTACT" | translate [routerLink]="languageService.localizedPath('/shop')"
}}</app-button> >{{ "HOME.BTN_SHOP" | translate }}</app-button
>
<app-button
variant="text"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div> </div>
</div> </div>
<aside class="hero-swiss-card"> <aside class="hero-swiss-card">
@@ -136,13 +142,13 @@
<app-button <app-button
variant="primary" variant="primary"
[fullWidth]="true" [fullWidth]="true"
routerLink="/calculator/basic" [routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button >{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
> >
<app-button <app-button
variant="outline" variant="outline"
[fullWidth]="true" [fullWidth]="true"
routerLink="/contact" [routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button >{{ "HOME.BTN_CONTACT" | translate }}</app-button
> >
</div> </div>
@@ -167,12 +173,16 @@
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li> <li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
</ul> </ul>
<div class="shop-actions ui-inline-actions"> <div class="shop-actions ui-inline-actions">
<app-button variant="primary" routerLink="/shop">{{ <app-button
"HOME.BTN_DISCOVER" | translate variant="primary"
}}</app-button> [routerLink]="languageService.localizedPath('/shop')"
<app-button variant="outline" routerLink="/contact">{{ >{{ "HOME.BTN_DISCOVER" | translate }}</app-button
"HOME.BTN_REQ_SOLUTION" | translate >
}}</app-button> <app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
>
</div> </div>
</div> </div>
<div <div
@@ -237,12 +247,16 @@
{{ "HOME.SEC_ABOUT_TEXT" | translate }} {{ "HOME.SEC_ABOUT_TEXT" | translate }}
</p> </p>
<div class="about-actions ui-inline-actions"> <div class="about-actions ui-inline-actions">
<app-button variant="primary" routerLink="/about">{{ <app-button
"HOME.SEC_ABOUT_TITLE" | translate variant="primary"
}}</app-button> [routerLink]="languageService.localizedPath('/about')"
<app-button variant="outline" routerLink="/contact">{{ >{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
"HOME.BTN_CONTACT" | translate >
}}</app-button> <app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div> </div>
</div> </div>
<div class="about-media"> <div class="about-media">

View File

@@ -56,7 +56,7 @@
width: min(100%, 340px); width: min(100%, 340px);
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-left: 4px solid var(--swiss-red); border-left: 4px solid var(--color-brand);
border-radius: 12px; border-radius: 12px;
background: #fff; background: #fff;
animation: fadeUp 0.85s ease both; animation: fadeUp 0.85s ease both;

View File

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

View File

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

View File

@@ -0,0 +1,397 @@
<main class="materials-page">
<section class="hero">
<div class="container hero-inner">
<p class="ui-eyebrow">Guida materiali</p>
<h1>Qualita e Materiali</h1>
<p class="hero-lead">
Confronta materiali in modo interattivo con radar chart, metriche tecniche,
vantaggi, limiti e fonti verificabili.
</p>
<p class="hero-note">
Seleziona fino a {{ maxCompareCount }} materiali: il grafico aggiorna i
punteggi in tempo reale.
</p>
</div>
</section>
<section class="selector-section">
<div class="container">
<h2>Selezione confronto</h2>
<div class="selector-grid" role="group" aria-label="Selezione materiali">
@for (material of materials; track trackMaterial($index, material)) {
<button
type="button"
class="selector-chip"
[class.is-selected]="isSelected(material.id)"
[disabled]="!canSelect(material.id)"
(click)="toggleMaterial(material.id)"
>
<span
class="selector-dot"
[style.background-color]="legendDotColor(material.id)"
></span>
<span>{{ material.name }}</span>
</button>
}
</div>
<p class="selector-help">
Nota: per l asse Economicita, un valore alto significa costo al kg piu
conveniente.
</p>
</div>
</section>
<section class="chart-section">
<div class="container chart-layout">
<article class="chart-card">
<header class="chart-header">
<h2>Radar chart comparativo</h2>
<p>
Punteggi normalizzati 0-100 su tutto il set materiali (min-max scaling).
</p>
</header>
<svg
class="radar-chart"
[attr.viewBox]="'0 0 ' + chartSize + ' ' + chartSize"
role="img"
aria-label="Radar chart materiali"
>
<g class="chart-rings">
@for (ring of ringPolygons(); track $index) {
<polygon [attr.points]="ring"></polygon>
}
</g>
<g class="chart-axes">
@for (axis of axisGuides(); track axis.id) {
<line
[attr.x1]="axis.fromX"
[attr.y1]="axis.fromY"
[attr.x2]="axis.x"
[attr.y2]="axis.y"
></line>
<text
[attr.x]="axis.labelX"
[attr.y]="axis.labelY"
[attr.text-anchor]="axis.labelAnchor"
>
{{ radarAxes[$index].label }}
</text>
}
</g>
<g class="chart-series">
@for (series of radarSeries(); track series.material.id) {
<polygon
class="series-shape"
[attr.points]="series.points"
[style.stroke]="series.color"
[style.fill]="series.fill"
[class.is-hovered]="hoveredMaterialId() === series.material.id"
(mouseenter)="setHoveredMaterial(series.material.id)"
(mouseleave)="setHoveredMaterial(null)"
></polygon>
@for (point of series.values; track point.axis.id) {
<circle
class="series-node"
[attr.cx]="point.x"
[attr.cy]="point.y"
r="4"
[style.fill]="series.color"
></circle>
}
}
</g>
</svg>
<div class="chart-legend">
@for (series of radarSeries(); track series.material.id) {
<button
type="button"
class="legend-item"
(mouseenter)="setHoveredMaterial(series.material.id)"
(mouseleave)="setHoveredMaterial(null)"
>
<span
class="legend-dot"
[style.background-color]="series.color"
></span>
<span>{{ series.material.name }}</span>
</button>
}
</div>
</article>
<article class="explain-card">
<h3>Spiegazione completa del radar</h3>
<p>
Ogni asse mostra una proprieta tecnica. Il valore 100 rappresenta la
miglior performance relativa nel dataset attuale; 0 la meno favorevole.
</p>
<ul>
@for (axis of radarAxes; track axis.id) {
<li>
<strong>{{ axis.label }}:</strong>
{{ axis.description }}
</li>
}
</ul>
<p>
La normalizzazione e calcolata su tutti i materiali mostrati in pagina.
Per leggibilita il radar usa un raggio minimo visivo: i valori minimi
restano i meno favorevoli, ma non collassano tutti nello stesso punto.
</p>
</article>
</div>
</section>
<section class="table-section">
<div class="container">
<h2>Tabella tecnica di confronto</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Parametro</th>
@for (material of selectedMaterials(); track trackMaterial($index, material)) {
<th>{{ material.name }}</th>
}
</tr>
</thead>
<tbody>
@for (row of comparisonRows(); track row.label) {
<tr>
<th>{{ row.label }}</th>
@for (value of row.values; track $index) {
<td>{{ value }}</td>
}
</tr>
}
</tbody>
</table>
</div>
</div>
</section>
<section class="quality-section">
<div class="container">
<h2>Layer, ugello e infill: esempi pratici</h2>
<p class="quality-intro">
Questa sezione non e un calcolatore interattivo: spiega visivamente cosa
cambia su oggetti reali e come leggere i risultati del vostro calcolatore.
</p>
<div class="visual-guide-grid">
@for (guide of qualityVisualCards(); track trackVisualGuide($index, guide)) {
<article class="visual-guide-card">
<p class="visual-guide-category">{{ guide.category }}</p>
<h3>{{ guide.title }}</h3>
<div class="visual-guide-media">
@if (guide.image; as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || guide.title"
width="640"
height="420"
/>
</picture>
} @else {
<div class="media-fallback">
<span>
Nessuna immagine assegnata.
Carica asset backend con usageType
<code>MATERIALS_PAGE</code> e usageKey
<code>{{ guide.usageKey }}</code>.
</span>
</div>
}
</div>
<p><strong>Oggetto esempio:</strong> {{ guide.objectExample }}</p>
<p><strong>Meglio per:</strong> {{ guide.bestFor }}</p>
<p><strong>Limite:</strong> {{ guide.tradeoff }}</p>
<p class="visual-guide-calc">
<strong>Lettura nel calcolatore:</strong> {{ guide.calculatorRead }}
</p>
</article>
}
</div>
<article class="calculator-logic-card">
<h3>Come leggere il nostro calcolatore</h3>
<p>
Il calcolatore non sostituisce i profili slicer: serve a spiegare il
compromesso tra estetica, robustezza e tempi in modo coerente.
</p>
<div class="logic-table-wrap">
<table>
<thead>
<tr>
<th>Metrica</th>
<th>Cosa significa</th>
<th>Valore alto</th>
<th>Valore basso</th>
</tr>
</thead>
<tbody>
@for (rule of calculatorRules; track rule.metric) {
<tr>
<th>{{ rule.metric }}</th>
<td>{{ rule.whatItMeans }}</td>
<td>{{ rule.whenHigh }}</td>
<td>{{ rule.whenLow }}</td>
</tr>
}
</tbody>
</table>
</div>
</article>
<div class="quality-layout">
<article class="quality-card">
<h3>Regole rapide per l utente</h3>
<ul>
<li>
Layer basso e ugello piccolo migliorano i dettagli, ma aumentano i
tempi.
</li>
<li>
Infill e perimetri alti migliorano resistenza, ma aumentano tempo e
materiale.
</li>
<li>
Per pezzi estetici usa profili fini; per pezzi funzionali scegli setup
bilanciati o robusti.
</li>
</ul>
</article>
</div>
<div class="guides-grid">
@for (guide of qualityGuides; track trackGuide($index, guide)) {
<article class="guide-card">
<h3>{{ guide.title }}</h3>
<p><strong>Range consigliato:</strong> {{ guide.recommendation }}</p>
<p>{{ guide.explanation }}</p>
<p class="guide-effect">{{ guide.practicalEffect }}</p>
</article>
}
</div>
</div>
</section>
<section class="materials-section">
<div class="container">
<h2>Schede materiali: spiegazioni, pro/contro, fonti</h2>
<div class="materials-grid">
@for (card of selectedCards(); track card.material.id) {
<article class="material-card">
<header>
<h3>{{ card.material.name }}</h3>
<p>{{ card.material.summary }}</p>
</header>
<div class="material-media">
@if (card.image; as image) {
<picture>
@if (image.source.avifUrl) {
<source [srcset]="image.source.avifUrl" type="image/avif" />
}
@if (image.source.webpUrl) {
<source [srcset]="image.source.webpUrl" type="image/webp" />
}
<img
[src]="image.source.fallbackUrl"
[attr.alt]="image.altText || card.material.name"
width="640"
height="400"
/>
</picture>
} @else {
<div class="media-fallback">
<span>
Nessuna immagine assegnata.
Carica asset backend con usageType
<code>MATERIALS_PAGE</code> e usageKey
<code>material-{{ card.material.id }}</code>.
</span>
</div>
}
</div>
<div class="material-columns">
<div>
<h4>Vantaggi</h4>
<ul>
@for (item of card.material.pros; track $index) {
<li>{{ item }}</li>
}
</ul>
</div>
<div>
<h4>Limiti</h4>
<ul>
@for (item of card.material.cons; track $index) {
<li>{{ item }}</li>
}
</ul>
</div>
<div>
<h4>Ideale per</h4>
<ul>
@for (item of card.material.idealFor; track $index) {
<li>{{ item }}</li>
}
</ul>
</div>
</div>
<div class="source-list">
<h4>Fonti citate</h4>
<ul>
@for (source of card.material.sources; track trackSource($index, source)) {
<li>
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
{{ source.label }}
</a>
<span class="source-kind">{{ source.kind }}</span>
</li>
}
</ul>
</div>
</article>
}
</div>
</div>
</section>
<section class="global-sources">
<div class="container">
<h2>Indice completo fonti</h2>
<p>
Tutti i link usati per metriche e descrizioni sono riportati qui in forma
centralizzata.
</p>
<ul class="global-source-list">
@for (source of allSources(); track trackSource($index, source)) {
<li>
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
{{ source.label }}
</a>
<span>{{ source.kind }}</span>
</li>
}
</ul>
</div>
</section>
</main>

View File

@@ -0,0 +1,546 @@
.materials-page {
--materials-bg: #ffffff;
--materials-accent: #c23b22;
--materials-muted: #5f6771;
--materials-card: #ffffff;
background: var(--materials-bg);
color: var(--color-text-main);
}
.hero {
padding: 5rem 0 2.25rem;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: -40% -15% auto auto;
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(194, 59, 34, 0.08), transparent 70%);
pointer-events: none;
}
.hero-inner {
position: relative;
z-index: 1;
}
.hero h1 {
margin: 0.4rem 0 1rem;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.05;
}
.hero-lead {
margin: 0;
max-width: 68ch;
font-size: 1.05rem;
color: var(--color-text-main);
}
.hero-note {
margin: 0.9rem 0 0;
color: var(--materials-muted);
}
.selector-section,
.chart-section,
.table-section,
.quality-section,
.materials-section,
.global-sources {
padding: 1.8rem 0;
}
.selector-grid {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.selector-chip {
border: 1px solid var(--color-border);
background: #fff;
color: var(--color-text-main);
border-radius: 999px;
padding: 0.5rem 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
.selector-chip:hover:enabled {
transform: translateY(-1px);
border-color: var(--materials-accent);
box-shadow: 0 4px 12px rgb(16 24 32 / 0.12);
}
.selector-chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.selector-chip.is-selected {
border-color: var(--materials-accent);
background: #fff3ee;
}
.selector-dot {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
border: 1px solid rgb(0 0 0 / 0.15);
display: inline-block;
}
.selector-help {
margin-top: 0.8rem;
color: var(--materials-muted);
}
.chart-layout {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
align-items: start;
}
.chart-card,
.explain-card,
.material-card,
.table-wrap,
.quality-card,
.guide-card {
background: var(--materials-card);
border: 1px solid var(--color-border);
border-radius: 1rem;
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
}
.chart-card {
padding: 1rem;
}
.chart-header h2 {
margin: 0;
}
.chart-header p {
margin: 0.5rem 0 0;
color: var(--materials-muted);
}
.radar-chart {
width: 100%;
max-width: 520px;
margin: 0 auto;
display: block;
}
.chart-rings polygon {
fill: none;
stroke: #d7d9de;
stroke-width: 1;
}
.chart-axes line {
stroke: #c3c8cf;
stroke-width: 1;
}
.chart-axes text {
font-size: 0.75rem;
fill: #4f5a66;
font-weight: 600;
}
.series-shape {
stroke-width: 2.2;
transition: filter 0.2s ease;
}
.series-shape.is-hovered {
filter: drop-shadow(0 3px 8px rgb(16 24 32 / 0.26));
}
.series-node {
stroke: #ffffff;
stroke-width: 1.2;
}
.chart-legend {
margin-top: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.legend-item {
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: #fff;
display: inline-flex;
gap: 0.45rem;
align-items: center;
font-weight: 600;
cursor: default;
}
.legend-dot {
width: 0.65rem;
height: 0.65rem;
border-radius: 50%;
display: inline-block;
}
.explain-card {
padding: 1rem;
}
.explain-card h3 {
margin: 0;
}
.explain-card p {
margin: 0.75rem 0;
color: var(--materials-muted);
}
.explain-card ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
}
.table-wrap {
overflow-x: auto;
}
.table-wrap table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
.table-wrap th,
.table-wrap td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.table-wrap thead th {
background: #f8fafd;
font-size: 0.84rem;
letter-spacing: 0.01em;
}
.table-wrap tbody tr:hover {
background: #f8fbff;
}
.quality-intro {
margin: 0.4rem 0 0;
color: var(--materials-muted);
max-width: 72ch;
}
.visual-guide-grid {
margin-top: 1rem;
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.visual-guide-card {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 1rem;
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
padding: 0.85rem;
display: grid;
gap: 0.55rem;
}
.visual-guide-category {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #2563b8;
}
.visual-guide-card h3 {
margin: 0;
font-size: 1.02rem;
}
.visual-guide-media {
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
background: #f7f8fb;
min-height: 170px;
}
.visual-guide-media img {
width: 100%;
height: 185px;
object-fit: cover;
display: block;
}
.visual-guide-card p {
margin: 0;
color: var(--materials-muted);
font-size: 0.92rem;
line-height: 1.42;
}
.visual-guide-calc {
margin-top: 0.2rem;
color: var(--color-text-main);
}
.calculator-logic-card {
margin-top: 1rem;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 1rem;
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
padding: 1rem;
}
.calculator-logic-card h3 {
margin: 0;
}
.calculator-logic-card p {
margin: 0.6rem 0 0;
color: var(--materials-muted);
}
.logic-table-wrap {
margin-top: 0.75rem;
overflow-x: auto;
}
.logic-table-wrap table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.logic-table-wrap th,
.logic-table-wrap td {
border-bottom: 1px solid var(--color-border);
text-align: left;
padding: 0.62rem 0.7rem;
}
.logic-table-wrap thead th {
background: #f8fafd;
font-size: 0.84rem;
letter-spacing: 0.01em;
}
.quality-layout {
margin-top: 1rem;
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
.quality-card {
padding: 1rem;
}
.quality-card h3 {
margin: 0;
}
.quality-card ul {
margin: 0.7rem 0 0;
padding-left: 1rem;
display: grid;
gap: 0.45rem;
color: var(--materials-muted);
}
.guides-grid {
margin-top: 1rem;
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.guide-card {
padding: 0.9rem;
}
.guide-card h3 {
margin: 0;
font-size: 1rem;
}
.guide-card p {
margin: 0.55rem 0 0;
color: var(--materials-muted);
}
.guide-effect {
color: var(--color-text-main);
font-weight: 500;
}
.materials-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.material-card {
padding: 1rem;
}
.material-card h3 {
margin: 0;
font-size: 1.25rem;
}
.material-card header p {
margin: 0.55rem 0 0;
color: var(--materials-muted);
}
.material-media {
margin-top: 0.85rem;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--color-border);
background: #f7f8fb;
min-height: 180px;
}
.material-media img {
width: 100%;
height: 220px;
object-fit: cover;
display: block;
}
.media-fallback {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
color: var(--materials-muted);
text-align: center;
font-size: 0.9rem;
line-height: 1.45;
}
.material-columns {
margin-top: 0.9rem;
display: grid;
gap: 0.7rem;
}
.material-columns h4,
.source-list h4 {
margin: 0;
font-size: 0.95rem;
}
.material-columns ul,
.source-list ul,
.global-source-list {
margin: 0.45rem 0 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
}
.source-list {
margin-top: 0.9rem;
padding-top: 0.9rem;
border-top: 1px solid var(--color-border);
}
.source-list li,
.global-source-list li {
display: flex;
gap: 0.5rem;
align-items: baseline;
justify-content: space-between;
}
.source-list a,
.global-source-list a {
color: #14409b;
word-break: break-word;
}
.source-kind {
color: var(--materials-muted);
font-size: 0.8rem;
white-space: nowrap;
}
.global-sources p {
color: var(--materials-muted);
}
.global-source-list {
background: #fff;
border: 1px solid var(--color-border);
border-radius: 0.9rem;
padding: 1rem 1rem 1rem 1.35rem;
}
@media (max-width: 1024px) {
.chart-layout {
grid-template-columns: 1fr;
}
.quality-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.hero {
padding-top: 4.2rem;
}
.chart-card,
.explain-card,
.material-card {
padding: 0.85rem;
}
.table-wrap table {
min-width: 640px;
}
.source-list li,
.global-source-list li {
flex-direction: column;
align-items: flex-start;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -239,7 +239,8 @@
<div class="order-item-meta"> <div class="order-item-meta">
<span <span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span >{{ "CHECKOUT.QTY" | translate }}:
{{ item.quantity }}</span
> >
<span *ngIf="showItemMaterial(item)"> <span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}: {{ "CHECKOUT.MATERIAL" | translate }}:

View File

@@ -245,7 +245,9 @@ export class OrderComponent implements OnInit {
amount: order?.subtotalChf ?? 0, 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, amount: order?.cadTotalChf ?? 0,
visible: (order?.cadTotalChf ?? 0) > 0, visible: (order?.cadTotalChf ?? 0) > 0,
}, },

View File

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

View File

@@ -74,4 +74,16 @@ export class ProductCardComponent {
shopReturnUrl: this.router.url, 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 }} ← {{ "SHOP.BACK" | translate }}
</button> </button>
@if (loading()) { @if (loading() || softFallbackActive()) {
<div class="detail-grid skeleton-grid"> <div class="detail-grid skeleton-grid">
<div class="skeleton-block"></div> <div class="skeleton-block"></div>
<div class="skeleton-block"></div> <div class="skeleton-block"></div>
@@ -20,7 +20,9 @@
}}</a> }}</a>
@for (crumb of p.breadcrumbs; track crumb.id) { @for (crumb of p.breadcrumbs; track crumb.id) {
<span class="breadcrumbs__separator">/</span> <span class="breadcrumbs__separator">/</span>
<a class="breadcrumbs__item" [routerLink]="categoryLink(crumb.slug)" <a
class="breadcrumbs__item"
[routerLink]="categoryLink(crumb.slug)"
>{{ crumb.name }}</a >{{ crumb.name }}</a
> >
} }
@@ -143,12 +145,15 @@
<span>{{ selectedMaterial()?.label }}</span> <span>{{ selectedMaterial()?.label }}</span>
} }
@if ( @if (
colorLabel(activeVariant) !== selectedMaterial()?.label colorLabel(activeVariant) !==
selectedMaterial()?.label
) { ) {
@if (selectedMaterial()?.label) { @if (selectedMaterial()?.label) {
<span aria-hidden="true">·</span> <span aria-hidden="true">·</span>
} }
<span>{{ colorLabel(activeVariant) | translate }}</span> <span>{{
colorLabel(activeVariant) | translate
}}</span>
} }
</p> </p>
} }
@@ -174,7 +179,10 @@
</div> </div>
<div class="material-grid"> <div class="material-grid">
@for (material of materialOptions(); track material.key) { @for (
material of materialOptions();
track material.key
) {
<button <button
type="button" type="button"
class="material-option" class="material-option"

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,24 +1,31 @@
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
import { import {
RESPONSE_INIT,
afterNextRender,
Component, Component,
DestroyRef, DestroyRef,
Injector, Injector,
PLATFORM_ID, PLATFORM_ID,
computed, computed,
inject, inject,
input,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 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 { 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 { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { import { findColorHex } from '../../core/constants/colors.const';
findColorHex,
getColorHex,
} from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -28,6 +35,7 @@ import {
ShopService, ShopService,
} from './services/shop.service'; } from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service'; import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
interface ShopMaterialOption { interface ShopMaterialOption {
key: string; key: string;
@@ -61,18 +69,23 @@ export class ProductDetailComponent {
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService); private readonly shopRouteService = inject(ShopRouteService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly routeCategorySlug = signal<string | null>(
readonly productSlug = input<string | undefined>(); this.readRouteParam('categorySlug'),
);
readonly loading = signal(true); readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly product = signal<ShopProductDetail | null>(null); readonly product = signal<ShopProductDetail | null>(null);
readonly selectedVariantId = signal<string | null>(null); readonly selectedVariantId = signal<string | null>(null);
@@ -196,49 +209,78 @@ export class ProductDetailComponent {
); );
constructor() { constructor() {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart() });
.pipe(takeUntilDestroyed(this.destroyRef)) this.destroyRef.onDestroy(() => {
.subscribe({ this.languageService.clearLocalizedRouteOverrides();
error: () => {
this.shopService.cart.set(null);
},
}); });
}
combineLatest([ 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, { toObservable(this.languageService.currentLang, {
injector: this.injector, injector: this.injector,
}), }).pipe(distinctUntilChanged()),
]) ])
.pipe( .pipe(
tap(() => { tap(() => {
this.loading.set(true); this.loading.set(true);
this.softFallbackActive.set(false);
this.error.set(null); this.error.set(null);
this.addSuccess.set(false); this.addSuccess.set(false);
this.modelError.set(false); this.modelError.set(false);
this.colorPopupOpen.set(false); this.colorPopupOpen.set(false);
this.modelModalOpen.set(false); this.modelModalOpen.set(false);
}), }),
switchMap(([productSlug]) => { switchMap(([routeParams]) => {
if (!productSlug) { this.routeCategorySlug.set(routeParams.categorySlug);
if (!routeParams.productSlug) {
this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND'); this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
this.loading.set(false); this.loading.set(false);
return of(null); return of(null);
} }
return this.shopService.getProductByPublicPath(productSlug).pipe( const productSlug = routeParams.productSlug as string;
return this.shopService
.getProductByPublicPath(productSlug)
.pipe(
catchError((error) => { catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null); this.product.set(null);
this.selectedVariantId.set(null); this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null); this.setSelectedImageAssetId(null);
this.modelFile.set(null); this.modelFile.set(null);
this.error.set( const isNotFound = error?.status === 404;
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', if (isNotFound) {
); this.error.set('SHOP.NOT_FOUND');
this.applyFallbackSeo(); 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); return of(null);
}), }),
finalize(() => this.loading.set(false)), finalize(() => this.loading.set(false)),
@@ -252,6 +294,7 @@ export class ProductDetailComponent {
} }
this.product.set(product); this.product.set(product);
this.softFallbackActive.set(false);
this.selectedVariantId.set( this.selectedVariantId.set(
product.defaultVariant?.id ?? product.variants[0]?.id ?? null, product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
); );
@@ -266,6 +309,7 @@ export class ProductDetailComponent {
null, null,
); );
this.quantity.set(1); this.quantity.set(1);
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
this.syncPublicUrl(product); this.syncPublicUrl(product);
this.applySeo(product); this.applySeo(product);
this.modelFile.set(null); this.modelFile.set(null);
@@ -285,6 +329,45 @@ 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 { selectImage(mediaAssetId: string): void {
this.setSelectedImageAssetId(mediaAssetId); this.setSelectedImageAssetId(mediaAssetId);
} }
@@ -369,9 +452,12 @@ export class ProductDetailComponent {
if (!sessionId) { if (!sessionId) {
return; return;
} }
this.router.navigate(['/checkout'], { this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: { session: sessionId }, queryParams: { session: sessionId },
}); },
);
} }
priceLabel(): number { priceLabel(): number {
@@ -381,7 +467,9 @@ export class ProductDetailComponent {
} }
colorLabel(variant: ShopProductVariantOption): string { colorLabel(variant: ShopProductVariantOption): string {
return variant.colorLabel || variant.colorName || variant.variantLabel || '-'; return (
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
);
} }
colorHex(variant: ShopProductVariantOption | null | undefined): string { colorHex(variant: ShopProductVariantOption | null | undefined): string {
@@ -443,7 +531,8 @@ export class ProductDetailComponent {
} }
productLinkRoot(): string[] { productLinkRoot(): string[] {
const categorySlug = this.product()?.category.slug || this.categorySlug(); const categorySlug =
this.product()?.category.slug || this.routeCategorySlug();
return this.shopRouteService.shopRootCommands(categorySlug); return this.shopRouteService.shopRootCommands(categorySlug);
} }
@@ -454,6 +543,11 @@ export class ProductDetailComponent {
: null; : null;
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) { if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
if (this.isBrowser && window.history.length > 1) {
this.location.back();
return;
}
void this.router.navigateByUrl(returnUrl); void this.router.navigateByUrl(returnUrl);
return; return;
} }
@@ -513,28 +607,86 @@ export class ProductDetailComponent {
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots = const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow'; 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, title,
description, description,
robots, robots,
ogTitle: product.ogTitle || title, ogTitle: product.ogTitle || title,
ogDescription: product.ogDescription || description, 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 title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); 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, title,
description, description,
robots: 'index, follow', robots: 'index, follow',
ogTitle: title, ogTitle: title,
ogDescription: description, 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( private materialLabelForVariant(
variant: ShopProductVariantOption | null, variant: ShopProductVariantOption | null,
): string { ): string {
@@ -706,21 +858,23 @@ export class ProductDetailComponent {
return; return;
} }
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? ''; const currentTree = this.router.parseUrl(this.router.url);
const targetProductSlug = this.shopRouteService.productPathSegment(product); const lang = this.languageService.selectedLang();
if (currentProductSlug === targetProductSlug) { 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; return;
} }
const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree( const targetTree = this.router.createUrlTree(
[ ['/', ...normalizedTargetPath.split('/').filter(Boolean)],
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
{ {
queryParams: currentTree.queryParams, queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined, fragment: currentTree.fragment ?? undefined,
@@ -739,4 +893,21 @@ export class ProductDetailComponent {
state: history.state, 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 { Injectable } from '@angular/core';
import { LanguageService } from '../../../core/services/language.service'; import { LanguageService } from '../../../core/services/language.service';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export interface ShopProductRouteRef { export interface ShopProductRouteRef {
id: string | null | undefined; id: string | null | undefined;
name: string | null | undefined; name: string | null | undefined;
slug?: string | null | undefined; slug?: string | null | undefined;
} publicPath?: string | null | undefined;
localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined;
export interface ShopProductLookup {
idPrefix: string | null;
slugHint: string | null;
} }
@Injectable({ @Injectable({
@@ -26,11 +25,21 @@ export class ShopRouteService {
} }
productCommands(product: ShopProductRouteRef): string[] { productCommands(product: ShopProductRouteRef): string[] {
const localizedPath = this.localizedProductPath(product);
if (localizedPath) {
return ['/', ...localizedPath.split('/').filter(Boolean)];
}
const lang = this.languageService.currentLang(); const lang = this.languageService.currentLang();
return ['/', lang, 'shop', 'p', this.productPathSegment(product)]; return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
} }
productPathSegment(product: ShopProductRouteRef): string { productPathSegment(product: ShopProductRouteRef): string {
const publicPath = String(product.publicPath ?? '').trim();
if (publicPath) {
return publicPath;
}
const idPrefix = this.productIdPrefix(product.id); const idPrefix = this.productIdPrefix(product.id);
const tail = const tail =
this.slugify(product.name) || this.slugify(product.slug) || 'product'; this.slugify(product.name) || this.slugify(product.slug) || 'product';
@@ -38,41 +47,6 @@ export class ShopRouteService {
return idPrefix ? `${idPrefix}-${tail}` : tail; 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 { isCatalogUrl(url: string | null | undefined): boolean {
if (!url) { if (!url) {
return false; return false;
@@ -92,6 +66,12 @@ export class ShopRouteService {
.replace(/-{2,}/g, '-'); .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 { private productIdPrefix(productId: string | null | undefined): string {
const normalized = String(productId ?? '') const normalized = String(productId ?? '')
.trim() .trim()

View File

@@ -5,7 +5,6 @@ import {
} from '@angular/common/http/testing'; } from '@angular/common/http/testing';
import { import {
ShopCartResponse, ShopCartResponse,
ShopProductCatalogResponse,
ShopProductDetail, ShopProductDetail,
ShopService, ShopService,
} from './shop.service'; } from './shop.service';
@@ -90,32 +89,6 @@ describe('ShopService', () => {
grandTotalChf: 36.8, 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 => ({ const buildProduct = (): ShopProductDetail => ({
id: '12345678-abcd-4abc-9abc-1234567890ab', id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip', slug: 'desk-cable-clip',
@@ -142,6 +115,13 @@ describe('ShopService', () => {
primaryImage: null, primaryImage: null,
images: [], images: [],
model3d: null, 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(() => { beforeEach(() => {
@@ -212,86 +192,61 @@ describe('ShopService', () => {
response = product; response = product;
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = 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) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 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' request.params.get('lang') === 'it'
); );
}); });
detailRequest.flush(buildProduct()); request.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
expect(response?.name).toBe('Supporto cavo scrivania'); expect(response?.name).toBe('Supporto cavo scrivania');
}); });
it('resolves product detail from uuid prefix even when slug tail does not match', () => { it('rejects product paths whose slug tail does not match the canonical path', () => {
let response: ShopProductDetail | undefined; let errorResponse: { status?: number } | undefined;
service service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
.getProductByPublicPath('12345678-qualunque-nome') next: () => fail('Expected canonical path mismatch to return 404'),
.subscribe((product) => { error: (error) => {
response = product; errorResponse = error;
},
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = 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) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 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' request.params.get('lang') === 'it'
); );
}); });
detailRequest.flush(buildProduct()); request.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
}); });
it('resolves product detail from bare uuid prefix without slug tail', () => { it('rejects bare uuid product paths without the localized slug tail', () => {
let response: ShopProductDetail | undefined; let errorResponse: { status?: number } | undefined;
service.getProductByPublicPath('12345678').subscribe((product) => { service.getProductByPublicPath('12345678').subscribe({
response = product; next: () => fail('Expected bare uuid path to return 404'),
error: (error) => {
errorResponse = error;
},
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = 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) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 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' request.params.get('lang') === 'it'
); );
}); });
detailRequest.flush(buildProduct()); request.flush('Not found', { status: 404, statusText: 'Not Found' });
expect(errorResponse?.status).toBe(404);
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
}); });
}); });

View File

@@ -1,13 +1,15 @@
import { computed, inject, Injectable, signal } from '@angular/core'; import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; 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 { environment } from '../../../../environments/environment';
import { import {
PublicMediaUsageDto, PublicMediaUsageDto,
PublicMediaVariantDto, PublicMediaVariantDto,
} from '../../../core/services/public-media.service'; } from '../../../core/services/public-media.service';
import { LanguageService } from '../../../core/services/language.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 { export interface ShopCategoryRef {
id: string; id: string;
@@ -84,6 +86,8 @@ export interface ShopProductSummary {
defaultVariant: ShopProductVariantOption | null; defaultVariant: ShopProductVariantOption | null;
primaryImage: PublicMediaUsageDto | null; primaryImage: PublicMediaUsageDto | null;
model3d: ShopProductModel | null; model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
} }
export interface ShopProductDetail { export interface ShopProductDetail {
@@ -108,6 +112,8 @@ export interface ShopProductDetail {
primaryImage: PublicMediaUsageDto | null; primaryImage: PublicMediaUsageDto | null;
images: PublicMediaUsageDto[]; images: PublicMediaUsageDto[];
model3d: ShopProductModel | null; model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
} }
export interface ShopProductCatalogResponse { export interface ShopProductCatalogResponse {
@@ -185,7 +191,6 @@ export interface ShopCategoryNavNode {
export class ShopService { export class ShopService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
private readonly apiUrl = `${environment.apiUrl}/api/shop`; private readonly apiUrl = `${environment.apiUrl}/api/shop`;
readonly cart = signal<ShopCartResponse | null>(null); readonly cart = signal<ShopCartResponse | null>(null);
@@ -278,29 +283,27 @@ export class ShopService {
getProductByPublicPath( getProductByPublicPath(
productPathSegment: string, productPathSegment: string,
): Observable<ShopProductDetail> { ): Observable<ShopProductDetail> {
const lookup = const normalizedPath = this.normalizePublicPath(productPathSegment);
this.shopRouteService.resolveProductLookup(productPathSegment); if (!normalizedPath) {
if (!lookup.idPrefix && lookup.slugHint) {
return this.getProduct(lookup.slugHint);
}
return this.getProductCatalog().pipe(
map((catalog) =>
catalog.products.find((product) =>
product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
),
),
switchMap((product) => {
if (!product) {
return throwError(() => ({ return throwError(() => ({
status: 404, 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> { loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true); this.cartLoading.set(true);
return this.http return this.http

View File

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

View File

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

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