59 Commits

Author SHA1 Message Date
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
d8ad61ec54 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 21s
2026-03-13 16:28:40 +01:00
7e9a1482d6 fix(front-end): maximum budget
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
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
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-13 16:24:39 +01:00
aa0adbf993 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 41s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:21:57 +01:00
00af9a9701 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 1m15s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:16:49 +01:00
fcdede2dd6 chore(front-end):map color
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Failing after 26s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:43:00 +01:00
5d17b23c3a chore(front-end): new seo, and improvements in shop component
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 1m16s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:26:36 +01:00
1ec8a43a50 Merge pull request 'dev' (#43) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #43
2026-03-12 12:20:34 +01:00
96cfa91c67 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
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 1m3s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m25s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 12:17:42 +01:00
669ace82c0 Merge remote-tracking branch 'origin/dev' into dev
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) Successful in 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 1m7s
2026-03-12 12:17:28 +01:00
93163ae6e8 fix(front-end): sitemap static 2026-03-12 12:17:13 +01:00
af2d506da1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #42
2026-03-11 17:35:12 +01:00
637541994a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m1s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 22s
2026-03-11 17:32:53 +01:00
printcalc-ci
63cd4c4f5e style: apply prettier formatting 2026-03-11 16:31:15 +00:00
fd4104da39 fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
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 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-11 17:27:28 +01:00
5bb23fbcfa fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:23:32 +01:00
6a22c54e9f feat(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
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 19s
2026-03-11 17:19:26 +01:00
3ac3262e77 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
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 27s
Build and Deploy / deploy (push) Successful in 20s
# Conflicts:
#	frontend/src/app/app.config.ts
2026-03-11 17:10:06 +01:00
18ecc07240 feat(front-end): ssr i18n fix 2026-03-11 17:09:51 +01:00
cb468492b3 Merge pull request 'feat(front-end): ssr implementation' (#41) from feat/ssr into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 52s
Build and Deploy / deploy (push) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m0s
Reviewed-on: #41
2026-03-11 16:59:21 +01:00
379a2161ca Merge remote-tracking branch 'origin/feat/ssr' into feat/ssr
All checks were successful
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
2026-03-11 16:57:07 +01:00
c47a7e28c7 feat(front-end): ssr implementation 2026-03-11 16:57:03 +01:00
printcalc-ci
502126c915 style: apply prettier formatting 2026-03-11 15:37:29 +00:00
2ace632022 feat(front-end): ssr implementation
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Failing after 57s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-11 16:37:08 +01:00
feee2b0bff Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #40
2026-03-11 15:32:14 +01:00
b7dfc53bc0 feat(front-end): update header layout
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 48s
Build and Deploy / deploy (push) Successful in 11s
2026-03-11 15:30:21 +01:00
d77c3c7a5c feat(front-end): update header layout
Some checks failed
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 36s
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-11 15:28:40 +01:00
printcalc-ci
faaa59ae01 style: apply prettier formatting 2026-03-11 14:18:47 +00:00
1bde2690b6 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 1m41s
Build and Deploy / deploy (push) Has been skipped
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 28s
2026-03-11 15:17:52 +01:00
92fddd9e4a Merge remote-tracking branch 'origin/dev' into dev
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
Build and Deploy / test-frontend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 11s
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 1m4s
2026-03-11 15:17:32 +01:00
f9414b2db7 feat(front-end): sitemap update 2026-03-11 15:17:19 +01:00
d1e7e7eaca feat(front-end): sitemap 2026-03-11 15:07:12 +01:00
75929a15f8 Merge pull request 'fix(front-end): fix security' (#39) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #39
2026-03-11 11:42:50 +01:00
c0585d7107 Merge branch 'main' into dev
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 31s
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 12s
2026-03-11 11:39:59 +01:00
aeeed1c138 fix(front-end): fix security
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) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 11:39:45 +01:00
10f05fabc9 Merge pull request 'dev' (#38) from dev into main
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 7s
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 16s
Reviewed-on: #38
2026-03-11 11:05:12 +01:00
78b719d3c2 fix(front-end): fix security
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 11:02:47 +01:00
printcalc-ci
052ade3e91 style: apply prettier formatting 2026-03-11 09:59:12 +00:00
035851133e Merge branch 'main' into dev
Some checks failed
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Failing after 27s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-11 10:57:23 +01:00
1ff8883d25 fix(back-end): fix hex color
Some checks failed
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 23s
Build and Deploy / deploy (push) Successful in 8s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Failing after 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 10:29:29 +01:00
30ada043ef fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 34s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 10:21:15 +01:00
e0aaa7c92e fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 1m43s
Build and Deploy / deploy (push) Successful in 10s
2026-03-11 10:14:30 +01:00
edae13541f feat(back-end): rich text improvements
All checks were successful
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 27s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 19:01:17 +01:00
d150c19f9f feat(back-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 53s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 18:51:15 +01:00
71890e4cc2 feat(front-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 25s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 18:48:27 +01:00
95494efcae Merge pull request 'dev' (#37) from dev into main
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 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #37
2026-03-10 17:43:45 +01:00
8893a80c12 feat(front-end): update media ffmpeg
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / test-frontend (pull_request) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 1m40s
Build and Deploy / deploy (push) Successful in 11s
2026-03-10 17:29:38 +01:00
a4facf74d7 feat(front-end): update media ffmpeg
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 32s
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) Failing after 1m0s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 17:20:45 +01:00
ed8fd89217 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 15:32:59 +01:00
58869be9f7 feat(front-end): css allert fix 2026-03-10 15:32:53 +01:00
fda0174a77 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 29s
Build and Deploy / build-and-push (push) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 12s
2026-03-10 15:25:51 +01:00
printcalc-ci
52c6239f6d style: apply prettier formatting 2026-03-10 14:23:52 +00:00
c007cee093 feat(front-end): shop category ui
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / build-and-push (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 13s
2026-03-10 15:23:08 +01:00
d011268d20 feat(front-end): shop category ui
Some checks failed
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) Failing after 44s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 15:14:10 +01:00
126d3ef4c4 feat(back-end front-end): shop route 2026-03-10 15:04:56 +01:00
3f228ef6e2 feat(back-end front-end): shop feature 2026-03-10 15:04:49 +01:00
8733184dc5 Merge branch 'feat/shop' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 38s
Build and Deploy / deploy (push) Successful in 10s
2026-03-10 12:42:42 +01:00
6d491eb694 Merge pull request 'feat/shop' (#36) from feat/shop into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 11s
Reviewed-on: #36
2026-03-10 11:16:22 +01:00
3ab518a9b6 Merge pull request 'dev' (#29) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #29
2026-03-09 09:58:45 +01:00
117 changed files with 8652 additions and 1514 deletions

View File

@@ -125,6 +125,18 @@ jobs:
docker build -t "$FRONTEND_IMAGE" ./frontend docker build -t "$FRONTEND_IMAGE" ./frontend
docker push "$FRONTEND_IMAGE" docker push "$FRONTEND_IMAGE"
- name: Cleanup Docker on runner (prevent vdisk growth)
if: always()
shell: bash
run: |
set +e
# Keep recent artifacts, drop old local residue from CI builds.
docker container prune -f --filter "until=168h" || true
docker image prune -a -f --filter "until=168h" || true
docker builder prune -a -f --filter "until=168h" || true
docker network prune -f --filter "until=168h" || true
deploy: deploy:
needs: build-and-push needs: build-and-push
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -33,7 +33,7 @@ Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.p
- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) - `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`)
- `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop - `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop
- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` - `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` (nel deploy Docker default: `/usr/local/bin/ffmpeg-media`)
- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine - `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine
```bash ```bash
@@ -107,7 +107,7 @@ Operativamente:
Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno).
### FFmpeg e media pubblici ### FFmpeg e media pubblici
Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente. Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF (encoder + muxer AVIF). Nel container backend il default è `/usr/local/bin/ffmpeg-media`: usa `/usr/bin/ffmpeg` se già compatibile, altrimenti installa un fallback statico con supporto AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente.
### 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.

View File

@@ -12,25 +12,48 @@ RUN ./gradlew bootJar -x test --no-daemon
FROM eclipse-temurin:21-jre-jammy FROM eclipse-temurin:21-jre-jammy
ARG ORCA_VERSION=2.3.1 ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL ARG ORCA_DOWNLOAD_URL
ARG FFMPEG_STATIC_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
# Install system dependencies for OrcaSlicer and media processing. # Install system dependencies for OrcaSlicer and media processing.
# The build fails fast if the packaged ffmpeg lacks JPEG/WebP/AVIF encoders. # Prefer system ffmpeg; if AVIF support is incomplete, install a static ffmpeg fallback.
RUN set -eux; \ RUN set -eux; \
apt-get update; \ apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
wget \ wget \
xz-utils \
ca-certificates \
assimp-utils \ assimp-utils \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37; \ libwebkit2gtk-4.0-37; \
ffmpeg -hide_banner -encoders > /tmp/ffmpeg-encoders.txt; \ check_ffmpeg_support() { \
grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt; \ ffmpeg_bin="$1"; \
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ "$ffmpeg_bin" -hide_banner -encoders > /tmp/ffmpeg-encoders.txt 2>&1 || return 1; \
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt; \ "$ffmpeg_bin" -hide_banner -muxers > /tmp/ffmpeg-muxers.txt 2>&1 || return 1; \
grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' /tmp/ffmpeg-muxers.txt || return 1; \
return 0; \
}; \
if check_ffmpeg_support /usr/bin/ffmpeg; then \
ln -sf /usr/bin/ffmpeg /usr/local/bin/ffmpeg-media; \
else \
echo "System ffmpeg lacks AVIF support, installing static fallback from ${FFMPEG_STATIC_URL}"; \
wget -q "${FFMPEG_STATIC_URL}" -O /tmp/ffmpeg-static.tar.xz; \
tar -xJf /tmp/ffmpeg-static.tar.xz -C /tmp; \
FFMPEG_STATIC_BIN="$(find /tmp -maxdepth 2 -type f -name ffmpeg | head -n 1)"; \
test -n "${FFMPEG_STATIC_BIN}"; \
install -m 0755 "${FFMPEG_STATIC_BIN}" /usr/local/bin/ffmpeg-media; \
check_ffmpeg_support /usr/local/bin/ffmpeg-media; \
fi; \
rm -f /tmp/ffmpeg-muxers.txt; \
rm -f /tmp/ffmpeg-encoders.txt; \ rm -f /tmp/ffmpeg-encoders.txt; \
rm -f /tmp/ffmpeg-static.tar.xz; \
rm -rf /tmp/ffmpeg-*-amd64-static; \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer # Install OrcaSlicer
@@ -71,6 +94,8 @@ ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp" ENV ASSIMP_PATH="assimp"
# Use ffmpeg selected at image build time (system or static fallback) for media generation.
ENV MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -42,6 +42,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.jsoup:jsoup:1.18.3'
implementation platform('org.lwjgl:lwjgl-bom:3.3.4') implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
implementation 'org.lwjgl:lwjgl' implementation 'org.lwjgl:lwjgl'
implementation 'org.lwjgl:lwjgl-assimp' implementation 'org.lwjgl:lwjgl-assimp'

View File

@@ -1,10 +1,61 @@
#!/bin/sh #!/bin/sh
set -e
# In container default to the ffmpeg selected during image build.
if [ -z "${MEDIA_FFMPEG_PATH:-}" ]; then
MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
fi
export MEDIA_FFMPEG_PATH
validate_ffmpeg_support() {
ffmpeg_bin="$1"
if ! command -v "$ffmpeg_bin" >/dev/null 2>&1; then
echo "ERROR: FFmpeg executable not found: ${ffmpeg_bin}" >&2
exit 11
fi
encoders="$(mktemp)"
muxers="$(mktemp)"
trap 'rm -f "$encoders" "$muxers"' EXIT
"$ffmpeg_bin" -hide_banner -encoders > "$encoders" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg encoders from ${ffmpeg_bin}" >&2
cat "$encoders" >&2
exit 12
}
"$ffmpeg_bin" -hide_banner -muxers > "$muxers" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg muxers from ${ffmpeg_bin}" >&2
cat "$muxers" >&2
exit 13
}
grep -Eq '[[:space:]]mjpeg[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing JPEG encoder (mjpeg)." >&2
exit 14
}
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing WebP encoder." >&2
exit 15
}
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF-capable encoder." >&2
exit 16
}
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' "$muxers" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF muxer." >&2
exit 17
}
}
validate_ffmpeg_support "$MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Determine which environment variables to use for database connection

View File

@@ -94,6 +94,10 @@ public class OptionsController {
v.getId(), v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
v.getColorLabelIt(),
v.getColorLabelEn(),
v.getColorLabelDe(),
v.getColorLabelFr(),
resolveHexColor(v), resolveHexColor(v),
v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,

View File

@@ -0,0 +1,37 @@
package com.printcalculator.controller;
import com.printcalculator.service.shop.ShopSitemapService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
@RestController
public class SitemapController {
private final ShopSitemapService shopSitemapService;
private final long cacheSeconds;
public SitemapController(
ShopSitemapService shopSitemapService,
@Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds
) {
this.shopSitemapService = shopSitemapService;
this.cacheSeconds = Math.max(cacheSeconds, 0L);
}
@GetMapping(value = "/api/sitemap-shop.xml", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> getShopSitemap() {
CacheControl cacheControl = cacheSeconds > 0
? CacheControl.maxAge(Duration.ofSeconds(cacheSeconds)).cachePublic()
: CacheControl.noCache();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/xml;charset=UTF-8"))
.cacheControl(cacheControl)
.body(shopSitemapService.getShopSitemapXml());
}
}

View File

@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
private String materialTechnicalTypeLabel; private String materialTechnicalTypeLabel;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -10,9 +10,25 @@ public class AdminShopCategoryDto {
private String parentCategoryName; private String parentCategoryName;
private String slug; private String slug;
private String name; private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description; private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -69,6 +85,38 @@ public class AdminShopCategoryDto {
this.name = name; this.name = name;
} }
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }
@@ -77,6 +125,38 @@ public class AdminShopCategoryDto {
this.description = description; this.description = description;
} }
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -85,6 +165,38 @@ public class AdminShopCategoryDto {
this.seoTitle = seoTitle; this.seoTitle = seoTitle;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -93,6 +205,38 @@ public class AdminShopCategoryDto {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -27,7 +27,15 @@ public class AdminShopProductDto {
private String descriptionDe; private String descriptionDe;
private String descriptionFr; private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -215,6 +223,38 @@ public class AdminShopProductDto {
this.seoTitle = seoTitle; this.seoTitle = seoTitle;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -223,6 +263,38 @@ public class AdminShopProductDto {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -9,6 +9,10 @@ public class AdminShopProductVariantDto {
private String sku; private String sku;
private String variantLabel; private String variantLabel;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String internalMaterialCode; private String internalMaterialCode;
private BigDecimal priceChf; private BigDecimal priceChf;
@@ -50,6 +54,38 @@ public class AdminShopProductVariantDto {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId; private Long materialTypeId;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -6,9 +6,25 @@ public class AdminUpsertShopCategoryRequest {
private UUID parentCategoryId; private UUID parentCategoryId;
private String slug; private String slug;
private String name; private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description; private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -39,6 +55,38 @@ public class AdminUpsertShopCategoryRequest {
this.name = name; this.name = name;
} }
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }
@@ -47,6 +95,38 @@ public class AdminUpsertShopCategoryRequest {
this.description = description; this.description = description;
} }
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -55,6 +135,38 @@ public class AdminUpsertShopCategoryRequest {
this.seoTitle = seoTitle; this.seoTitle = seoTitle;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -63,6 +175,38 @@ public class AdminUpsertShopCategoryRequest {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -22,7 +22,15 @@ public class AdminUpsertShopProductRequest {
private String descriptionDe; private String descriptionDe;
private String descriptionFr; private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -175,6 +183,38 @@ public class AdminUpsertShopProductRequest {
this.seoTitle = seoTitle; this.seoTitle = seoTitle;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -183,6 +223,38 @@ public class AdminUpsertShopProductRequest {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -8,6 +8,10 @@ public class AdminUpsertShopProductVariantRequest {
private String sku; private String sku;
private String variantLabel; private String variantLabel;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String internalMaterialCode; private String internalMaterialCode;
private BigDecimal priceChf; private BigDecimal priceChf;
@@ -47,6 +51,38 @@ public class AdminUpsertShopProductVariantRequest {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -15,6 +15,10 @@ public record OptionsResponse(
Long id, Long id,
String name, String name,
String colorName, String colorName,
String colorLabelIt,
String colorLabelEn,
String colorLabelDe,
String colorLabelFr,
String hexColor, String hexColor,
String finishType, String finishType,
Double stockSpools, Double stockSpools,

View File

@@ -17,9 +17,17 @@ public class OrderItemDto {
private String shopProductName; private String shopProductName;
private String shopVariantLabel; private String shopVariantLabel;
private String shopVariantColorName; private String shopVariantColorName;
private String shopVariantColorLabelIt;
private String shopVariantColorLabelEn;
private String shopVariantColorLabelDe;
private String shopVariantColorLabelFr;
private String shopVariantColorHex; private String shopVariantColorHex;
private String filamentVariantDisplayName; private String filamentVariantDisplayName;
private String filamentColorName; private String filamentColorName;
private String filamentColorLabelIt;
private String filamentColorLabelEn;
private String filamentColorLabelDe;
private String filamentColorLabelFr;
private String filamentColorHex; private String filamentColorHex;
private String quality; private String quality;
private BigDecimal nozzleDiameterMm; private BigDecimal nozzleDiameterMm;
@@ -73,6 +81,18 @@ public class OrderItemDto {
public String getShopVariantColorName() { return shopVariantColorName; } public String getShopVariantColorName() { return shopVariantColorName; }
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; } public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; }
public String getShopVariantColorLabelIt() { return shopVariantColorLabelIt; }
public void setShopVariantColorLabelIt(String shopVariantColorLabelIt) { this.shopVariantColorLabelIt = shopVariantColorLabelIt; }
public String getShopVariantColorLabelEn() { return shopVariantColorLabelEn; }
public void setShopVariantColorLabelEn(String shopVariantColorLabelEn) { this.shopVariantColorLabelEn = shopVariantColorLabelEn; }
public String getShopVariantColorLabelDe() { return shopVariantColorLabelDe; }
public void setShopVariantColorLabelDe(String shopVariantColorLabelDe) { this.shopVariantColorLabelDe = shopVariantColorLabelDe; }
public String getShopVariantColorLabelFr() { return shopVariantColorLabelFr; }
public void setShopVariantColorLabelFr(String shopVariantColorLabelFr) { this.shopVariantColorLabelFr = shopVariantColorLabelFr; }
public String getShopVariantColorHex() { return shopVariantColorHex; } public String getShopVariantColorHex() { return shopVariantColorHex; }
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; } public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
@@ -82,6 +102,18 @@ public class OrderItemDto {
public String getFilamentColorName() { return filamentColorName; } public String getFilamentColorName() { return filamentColorName; }
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; } public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
public String getFilamentColorLabelIt() { return filamentColorLabelIt; }
public void setFilamentColorLabelIt(String filamentColorLabelIt) { this.filamentColorLabelIt = filamentColorLabelIt; }
public String getFilamentColorLabelEn() { return filamentColorLabelEn; }
public void setFilamentColorLabelEn(String filamentColorLabelEn) { this.filamentColorLabelEn = filamentColorLabelEn; }
public String getFilamentColorLabelDe() { return filamentColorLabelDe; }
public void setFilamentColorLabelDe(String filamentColorLabelDe) { this.filamentColorLabelDe = filamentColorLabelDe; }
public String getFilamentColorLabelFr() { return filamentColorLabelFr; }
public void setFilamentColorLabelFr(String filamentColorLabelFr) { this.filamentColorLabelFr = filamentColorLabelFr; }
public String getFilamentColorHex() { return filamentColorHex; } public String getFilamentColorHex() { return filamentColorHex; }
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }

View File

@@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto(
String sku, String sku,
String variantLabel, String variantLabel,
String colorName, String colorName,
String colorLabel,
String colorHex, String colorHex,
BigDecimal priceChf, BigDecimal priceChf,
Boolean isDefault Boolean isDefault

View File

@@ -24,6 +24,18 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; private String colorName;
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -93,6 +105,38 @@ public class FilamentVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -173,4 +217,60 @@ public class FilamentVariant {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
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;
}
} }

View File

@@ -15,6 +15,7 @@ import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -23,6 +24,8 @@ import java.util.UUID;
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order") @Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
}) })
public class ShopCategory { public class ShopCategory {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_category_id", nullable = false) @Column(name = "shop_category_id", nullable = false)
@@ -38,15 +41,63 @@ public class ShopCategory {
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name; private String name;
@Column(name = "name_it", length = Integer.MAX_VALUE)
private String nameIt;
@Column(name = "name_en", length = Integer.MAX_VALUE)
private String nameEn;
@Column(name = "name_de", length = Integer.MAX_VALUE)
private String nameDe;
@Column(name = "name_fr", length = Integer.MAX_VALUE)
private String nameFr;
@Column(name = "description", length = Integer.MAX_VALUE) @Column(name = "description", length = Integer.MAX_VALUE)
private String description; private String description;
@Column(name = "description_it", length = Integer.MAX_VALUE)
private String descriptionIt;
@Column(name = "description_en", length = Integer.MAX_VALUE)
private String descriptionEn;
@Column(name = "description_de", length = Integer.MAX_VALUE)
private String descriptionDe;
@Column(name = "description_fr", length = Integer.MAX_VALUE)
private String descriptionFr;
@Column(name = "seo_title", length = Integer.MAX_VALUE) @Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle; private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE) @Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription; private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE) @Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle; private String ogTitle;
@@ -139,6 +190,38 @@ public class ShopCategory {
this.name = name; this.name = name;
} }
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }
@@ -147,6 +230,38 @@ public class ShopCategory {
this.description = description; this.description = description;
} }
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -155,6 +270,38 @@ public class ShopCategory {
this.seoTitle = seoTitle; this.seoTitle = seoTitle;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -163,6 +310,38 @@ public class ShopCategory {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }
@@ -218,4 +397,109 @@ public class ShopCategory {
public void setUpdatedAt(OffsetDateTime updatedAt) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public String getNameForLanguage(String language) {
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
}
public void setNameForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> nameIt = value;
case "en" -> nameEn = value;
case "de" -> nameDe = value;
case "fr" -> nameFr = value;
default -> {
}
}
}
public String getDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
}
public void setDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> descriptionIt = value;
case "en" -> descriptionEn = value;
case "de" -> descriptionDe = value;
case "fr" -> descriptionFr = value;
default -> {
}
}
}
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
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;
}
} }

View File

@@ -86,9 +86,33 @@ public class ShopProduct {
@Column(name = "seo_title", length = Integer.MAX_VALUE) @Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle; private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE) @Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription; private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE) @Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle; private String ogTitle;
@@ -319,6 +343,70 @@ public class ShopProduct {
this.seoDescription = seoDescription; this.seoDescription = seoDescription;
} }
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }
@@ -428,6 +516,36 @@ public class ShopProduct {
} }
} }
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language, private String resolveLocalizedValue(String language,
String fallback, String fallback,
String valueIt, String valueIt,

View File

@@ -42,6 +42,18 @@ public class ShopProductVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; private String colorName;
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -152,6 +164,38 @@ public class ShopProductVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -215,4 +259,60 @@ public class ShopProductVariant {
public void setUpdatedAt(OffsetDateTime updatedAt) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
public String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
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;
}
} }

View File

@@ -29,6 +29,7 @@ import java.util.*;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -235,18 +236,20 @@ public class OrderService {
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath);
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
if (sourcePath == null || !Files.exists(sourcePath)) { if (sourcePath == null || !Files.exists(sourcePath)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); if (requiresStoredSourceFile(qItem)) {
} throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
try { }
storageService.store(sourcePath, Paths.get(relativePath)); } else {
oItem.setFileSizeBytes(Files.size(sourcePath)); String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
} catch (IOException e) { oItem.setStoredRelativePath(relativePath);
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); try {
storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath));
} catch (IOException e) {
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
@@ -318,6 +321,12 @@ public class OrderService {
return "stl"; return "stl";
} }
private boolean requiresStoredSourceFile(QuoteLineItem qItem) {
return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase(
qItem.getLineItemType() != null ? qItem.getLineItemType() : ""
);
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) { if (storedPath == null || storedPath.isBlank()) {
return null; return null;

View File

@@ -161,10 +161,21 @@ public class AdminFilamentControllerService {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand()); String normalizedBrand = normalizeOptional(payload.getBrand());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
variant.setFilamentMaterialType(material); variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName); variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName); variant.setColorName(normalizedColorName);
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
variant.setColorHex(normalizedColorHex); variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType); variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand); variant.setBrand(normalizedBrand);
@@ -226,6 +237,18 @@ public class AdminFilamentControllerService {
return normalized.isBlank() ? null : normalized; return normalized.isBlank() ? null : 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 FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) { if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
@@ -306,6 +329,10 @@ public class AdminFilamentControllerService {
dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName()); dto.setColorName(variant.getColorName());
dto.setColorLabelIt(variant.getColorLabelIt());
dto.setColorLabelEn(variant.getColorLabelEn());
dto.setColorLabelDe(variant.getColorLabelDe());
dto.setColorLabelFr(variant.getColorLabelFr());
dto.setColorHex(variant.getColorHex()); dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType()); dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand()); dto.setBrand(variant.getBrand());

View File

@@ -402,7 +402,7 @@ public class AdminMediaControllerService {
if (!skippedFormats.isEmpty()) { if (!skippedFormats.isEmpty()) {
logger.warn( logger.warn(
"Skipping media formats for asset {} because FFmpeg encoders are unavailable: {}", "Skipping media formats for asset {} because FFmpeg support is unavailable: {}",
asset.getId(), asset.getId(),
String.join(", ", skippedFormats) String.join(", ", skippedFormats)
); );

View File

@@ -67,13 +67,13 @@ public class AdminShopCategoryControllerService {
@Transactional @Transactional
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) { public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
ensurePayload(payload); ensurePayload(payload);
String normalizedName = normalizeRequiredName(payload.getName()); LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, null); ensureSlugAvailable(normalizedSlug, null);
ShopCategory category = new ShopCategory(); ShopCategory category = new ShopCategory();
category.setCreatedAt(OffsetDateTime.now()); category.setCreatedAt(OffsetDateTime.now());
applyPayload(category, payload, normalizedName, normalizedSlug, null); applyPayload(category, payload, localizedContent, normalizedSlug, null);
ShopCategory saved = shopCategoryRepository.save(category); ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId()); return getCategory(saved.getId());
@@ -86,11 +86,11 @@ public class AdminShopCategoryControllerService {
ShopCategory category = shopCategoryRepository.findById(categoryId) ShopCategory category = shopCategoryRepository.findById(categoryId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
String normalizedName = normalizeRequiredName(payload.getName()); LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, category.getId()); ensureSlugAvailable(normalizedSlug, category.getId());
applyPayload(category, payload, normalizedName, normalizedSlug, category.getId()); applyPayload(category, payload, localizedContent, normalizedSlug, category.getId());
ShopCategory saved = shopCategoryRepository.save(category); ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId()); return getCategory(saved.getId());
} }
@@ -112,17 +112,33 @@ public class AdminShopCategoryControllerService {
private void applyPayload(ShopCategory category, private void applyPayload(ShopCategory category,
AdminUpsertShopCategoryRequest payload, AdminUpsertShopCategoryRequest payload,
String normalizedName, LocalizedCategoryContent localizedContent,
String normalizedSlug, String normalizedSlug,
UUID currentCategoryId) { UUID currentCategoryId) {
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId); ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
category.setParentCategory(parentCategory); category.setParentCategory(parentCategory);
category.setSlug(normalizedSlug); category.setSlug(normalizedSlug);
category.setName(normalizedName); category.setName(localizedContent.defaultName());
category.setDescription(normalizeOptional(payload.getDescription())); category.setNameIt(localizedContent.names().get("it"));
category.setSeoTitle(normalizeOptional(payload.getSeoTitle())); category.setNameEn(localizedContent.names().get("en"));
category.setSeoDescription(normalizeOptional(payload.getSeoDescription())); category.setNameDe(localizedContent.names().get("de"));
category.setNameFr(localizedContent.names().get("fr"));
category.setDescription(localizedContent.defaultDescription());
category.setDescriptionIt(localizedContent.descriptions().get("it"));
category.setDescriptionEn(localizedContent.descriptions().get("en"));
category.setDescriptionDe(localizedContent.descriptions().get("de"));
category.setDescriptionFr(localizedContent.descriptions().get("fr"));
category.setSeoTitle(localizedContent.defaultSeoTitle());
category.setSeoTitleIt(localizedContent.seoTitles().get("it"));
category.setSeoTitleEn(localizedContent.seoTitles().get("en"));
category.setSeoTitleDe(localizedContent.seoTitles().get("de"));
category.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
category.setSeoDescription(localizedContent.defaultSeoDescription());
category.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
category.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
category.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
category.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
category.setOgTitle(normalizeOptional(payload.getOgTitle())); category.setOgTitle(normalizeOptional(payload.getOgTitle()));
category.setOgDescription(normalizeOptional(payload.getOgDescription())); category.setOgDescription(normalizeOptional(payload.getOgDescription()));
category.setIndexable(payload.getIndexable() == null || payload.getIndexable()); category.setIndexable(payload.getIndexable() == null || payload.getIndexable());
@@ -161,14 +177,6 @@ public class AdminShopCategoryControllerService {
} }
} }
private String normalizeRequiredName(String name) {
String normalized = normalizeOptional(name);
if (normalized == null) {
throw new ResponseStatusException(BAD_REQUEST, "Category name is required");
}
return normalized;
}
private String normalizeAndValidateSlug(String slug, String fallbackName) { private String normalizeAndValidateSlug(String slug, String fallbackName) {
String source = normalizeOptional(slug); String source = normalizeOptional(slug);
if (source == null) { if (source == null) {
@@ -203,6 +211,103 @@ public class AdminShopCategoryControllerService {
return normalized.isBlank() ? null : normalized; return normalized.isBlank() ? null : normalized;
} }
private String normalizeRequired(String value, String message) {
String normalized = normalizeOptional(value);
if (normalized == null) {
throw new ResponseStatusException(BAD_REQUEST, message);
}
return normalized;
}
private LocalizedCategoryContent normalizeLocalizedCategoryContent(AdminUpsertShopCategoryRequest payload) {
String legacyName = normalizeOptional(payload.getName());
String fallbackName = firstNonBlank(
legacyName,
normalizeOptional(payload.getNameIt()),
normalizeOptional(payload.getNameEn()),
normalizeOptional(payload.getNameDe()),
normalizeOptional(payload.getNameFr())
);
if (fallbackName == null) {
throw new ResponseStatusException(BAD_REQUEST, "Category name is required");
}
Map<String, String> names = new LinkedHashMap<>();
names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian category name is required"));
names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English category name is required"));
names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German category name is required"));
names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French category name is required"));
String fallbackDescription = firstNonBlank(
normalizeOptional(payload.getDescription()),
normalizeOptional(payload.getDescriptionIt()),
normalizeOptional(payload.getDescriptionEn()),
normalizeOptional(payload.getDescriptionDe()),
normalizeOptional(payload.getDescriptionFr())
);
Map<String, String> descriptions = new LinkedHashMap<>();
descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription));
descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription));
descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription));
descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription));
String fallbackSeoTitle = firstNonBlank(
normalizeOptional(payload.getSeoTitle()),
normalizeOptional(payload.getSeoTitleIt()),
normalizeOptional(payload.getSeoTitleEn()),
normalizeOptional(payload.getSeoTitleDe()),
normalizeOptional(payload.getSeoTitleFr())
);
Map<String, String> seoTitles = new LinkedHashMap<>();
seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle));
seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle));
seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle));
seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle));
String fallbackSeoDescription = firstNonBlank(
normalizeOptional(payload.getSeoDescription()),
normalizeOptional(payload.getSeoDescriptionIt()),
normalizeOptional(payload.getSeoDescriptionEn()),
normalizeOptional(payload.getSeoDescriptionDe()),
normalizeOptional(payload.getSeoDescriptionFr())
);
Map<String, String> seoDescriptions = new LinkedHashMap<>();
seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian"));
seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English"));
seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German"));
seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French"));
return new LocalizedCategoryContent(
names.get("it"),
firstNonBlank(descriptions.get("it"), fallbackDescription),
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
names,
descriptions,
seoTitles,
seoDescriptions
);
}
private String validateSeoDescriptionLength(String value, String languageLabel) {
if (value != null && value.length() > 160) {
throw new ResponseStatusException(BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
}
return value;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private CategoryContext buildContext() { private CategoryContext buildContext() {
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
List<ShopProduct> products = shopProductRepository.findAll(); List<ShopProduct> products = shopProductRepository.findAll();
@@ -278,9 +383,25 @@ public class AdminShopCategoryControllerService {
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null); dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
dto.setSlug(category.getSlug()); dto.setSlug(category.getSlug());
dto.setName(category.getName()); dto.setName(category.getName());
dto.setNameIt(category.getNameIt());
dto.setNameEn(category.getNameEn());
dto.setNameDe(category.getNameDe());
dto.setNameFr(category.getNameFr());
dto.setDescription(category.getDescription()); dto.setDescription(category.getDescription());
dto.setDescriptionIt(category.getDescriptionIt());
dto.setDescriptionEn(category.getDescriptionEn());
dto.setDescriptionDe(category.getDescriptionDe());
dto.setDescriptionFr(category.getDescriptionFr());
dto.setSeoTitle(category.getSeoTitle()); dto.setSeoTitle(category.getSeoTitle());
dto.setSeoTitleIt(category.getSeoTitleIt());
dto.setSeoTitleEn(category.getSeoTitleEn());
dto.setSeoTitleDe(category.getSeoTitleDe());
dto.setSeoTitleFr(category.getSeoTitleFr());
dto.setSeoDescription(category.getSeoDescription()); dto.setSeoDescription(category.getSeoDescription());
dto.setSeoDescriptionIt(category.getSeoDescriptionIt());
dto.setSeoDescriptionEn(category.getSeoDescriptionEn());
dto.setSeoDescriptionDe(category.getSeoDescriptionDe());
dto.setSeoDescriptionFr(category.getSeoDescriptionFr());
dto.setOgTitle(category.getOgTitle()); dto.setOgTitle(category.getOgTitle());
dto.setOgDescription(category.getOgDescription()); dto.setOgDescription(category.getOgDescription());
dto.setIndexable(category.getIndexable()); dto.setIndexable(category.getIndexable());
@@ -331,4 +452,16 @@ public class AdminShopCategoryControllerService {
Map<UUID, Integer> descendantProductCounts Map<UUID, Integer> descendantProductCounts
) { ) {
} }
private record LocalizedCategoryContent(
String defaultName,
String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) {
}
} }

View File

@@ -22,6 +22,9 @@ import com.printcalculator.service.SlicerService;
import com.printcalculator.service.media.PublicMediaQueryService; import com.printcalculator.service.media.PublicMediaQueryService;
import com.printcalculator.service.shop.ShopStorageService; import com.printcalculator.service.shop.ShopStorageService;
import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.storage.ClamAVService;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -64,6 +67,10 @@ public class AdminShopProductControllerService {
private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+");
private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)");
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 final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
private final ShopCategoryRepository shopCategoryRepository; private final ShopCategoryRepository shopCategoryRepository;
@@ -163,7 +170,13 @@ public class AdminShopProductControllerService {
} }
} }
shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> deleteExistingModelFile(asset, productId)); shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> {
deleteExistingModelFile(asset, productId);
shopProductModelAssetRepository.delete(asset);
});
if (!variants.isEmpty()) {
shopProductVariantRepository.deleteAll(variants);
}
shopProductRepository.delete(product); shopProductRepository.delete(product);
} }
@@ -315,10 +328,18 @@ public class AdminShopProductControllerService {
product.setDescriptionEn(localizedContent.descriptions().get("en")); product.setDescriptionEn(localizedContent.descriptions().get("en"));
product.setDescriptionDe(localizedContent.descriptions().get("de")); product.setDescriptionDe(localizedContent.descriptions().get("de"));
product.setDescriptionFr(localizedContent.descriptions().get("fr")); product.setDescriptionFr(localizedContent.descriptions().get("fr"));
product.setSeoTitle(normalizeOptional(payload.getSeoTitle())); product.setSeoTitle(localizedContent.defaultSeoTitle());
product.setSeoDescription(normalizeOptional(payload.getSeoDescription())); product.setSeoTitleIt(localizedContent.seoTitles().get("it"));
product.setOgTitle(normalizeOptional(payload.getOgTitle())); product.setSeoTitleEn(localizedContent.seoTitles().get("en"));
product.setOgDescription(normalizeOptional(payload.getOgDescription())); product.setSeoTitleDe(localizedContent.seoTitles().get("de"));
product.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
product.setSeoDescription(localizedContent.defaultSeoDescription());
product.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
product.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
product.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
product.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
product.setOgTitle(localizedContent.defaultSeoTitle());
product.setOgDescription(localizedContent.defaultSeoDescription());
product.setIndexable(payload.getIndexable() == null || payload.getIndexable()); product.setIndexable(payload.getIndexable() == null || payload.getIndexable());
product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured())); product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured()));
product.setIsActive(payload.getIsActive() == null || payload.getIsActive()); product.setIsActive(payload.getIsActive() == null || payload.getIsActive());
@@ -332,6 +353,13 @@ public class AdminShopProductControllerService {
String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel()); String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
String normalizedSku = normalizeOptional(payload.getSku()); String normalizedSku = normalizeOptional(payload.getSku());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
String normalizedMaterialCode = normalizeRequired( String normalizedMaterialCode = normalizeRequired(
payload.getInternalMaterialCode(), payload.getInternalMaterialCode(),
"Variant internalMaterialCode is required" "Variant internalMaterialCode is required"
@@ -359,6 +387,10 @@ public class AdminShopProductControllerService {
variant.setSku(normalizedSku); variant.setSku(normalizedSku);
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName); variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName);
variant.setColorName(normalizedColorName); variant.setColorName(normalizedColorName);
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
variant.setColorHex(normalizeColorHex(payload.getColorHex())); variant.setColorHex(normalizeColorHex(payload.getColorHex()));
variant.setInternalMaterialCode(normalizedMaterialCode); variant.setInternalMaterialCode(normalizedMaterialCode);
variant.setPriceChf(price); variant.setPriceChf(price);
@@ -374,16 +406,23 @@ public class AdminShopProductControllerService {
} }
List<AdminUpsertShopProductVariantRequest> normalized = new ArrayList<>(payloads); List<AdminUpsertShopProductVariantRequest> normalized = new ArrayList<>(payloads);
Set<String> colorKeys = new LinkedHashSet<>(); Set<String> variantKeys = new LinkedHashSet<>();
int defaultCount = 0; int defaultCount = 0;
for (AdminUpsertShopProductVariantRequest payload : normalized) { for (AdminUpsertShopProductVariantRequest payload : normalized) {
if (payload == null) { if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required");
} }
String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String colorKey = colorName.toLowerCase(Locale.ROOT); String materialCode = normalizeRequired(
if (!colorKeys.add(colorKey)) { payload.getInternalMaterialCode(),
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate variant colorName: " + colorName); "Variant internalMaterialCode is required"
).toUpperCase(Locale.ROOT);
String variantKey = materialCode + "|" + colorName.toLowerCase(Locale.ROOT);
if (!variantKeys.add(variantKey)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Duplicate variant combination: " + materialCode + " / " + colorName
);
} }
if (Boolean.TRUE.equals(payload.getIsDefault())) { if (Boolean.TRUE.equals(payload.getIsDefault())) {
defaultCount++; defaultCount++;
@@ -467,7 +506,15 @@ public class AdminShopProductControllerService {
dto.setDescriptionDe(product.getDescriptionDe()); dto.setDescriptionDe(product.getDescriptionDe());
dto.setDescriptionFr(product.getDescriptionFr()); dto.setDescriptionFr(product.getDescriptionFr());
dto.setSeoTitle(product.getSeoTitle()); dto.setSeoTitle(product.getSeoTitle());
dto.setSeoTitleIt(product.getSeoTitleIt());
dto.setSeoTitleEn(product.getSeoTitleEn());
dto.setSeoTitleDe(product.getSeoTitleDe());
dto.setSeoTitleFr(product.getSeoTitleFr());
dto.setSeoDescription(product.getSeoDescription()); dto.setSeoDescription(product.getSeoDescription());
dto.setSeoDescriptionIt(product.getSeoDescriptionIt());
dto.setSeoDescriptionEn(product.getSeoDescriptionEn());
dto.setSeoDescriptionDe(product.getSeoDescriptionDe());
dto.setSeoDescriptionFr(product.getSeoDescriptionFr());
dto.setOgTitle(product.getOgTitle()); dto.setOgTitle(product.getOgTitle());
dto.setOgDescription(product.getOgDescription()); dto.setOgDescription(product.getOgDescription());
dto.setIndexable(product.getIndexable()); dto.setIndexable(product.getIndexable());
@@ -495,6 +542,10 @@ public class AdminShopProductControllerService {
dto.setSku(variant.getSku()); dto.setSku(variant.getSku());
dto.setVariantLabel(variant.getVariantLabel()); dto.setVariantLabel(variant.getVariantLabel());
dto.setColorName(variant.getColorName()); dto.setColorName(variant.getColorName());
dto.setColorLabelIt(variant.getColorLabelIt());
dto.setColorLabelEn(variant.getColorLabelEn());
dto.setColorLabelDe(variant.getColorLabelDe());
dto.setColorLabelFr(variant.getColorLabelFr());
dto.setColorHex(variant.getColorHex()); dto.setColorHex(variant.getColorHex());
dto.setInternalMaterialCode(variant.getInternalMaterialCode()); dto.setInternalMaterialCode(variant.getInternalMaterialCode());
dto.setPriceChf(variant.getPriceChf()); dto.setPriceChf(variant.getPriceChf());
@@ -584,25 +635,55 @@ public class AdminShopProductControllerService {
excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt));
String fallbackDescription = firstNonBlank( String fallbackDescription = firstNonBlank(
normalizeOptional(payload.getDescription()), normalizeRichTextOptional(payload.getDescription()),
normalizeOptional(payload.getDescriptionIt()), normalizeRichTextOptional(payload.getDescriptionIt()),
normalizeOptional(payload.getDescriptionEn()), normalizeRichTextOptional(payload.getDescriptionEn()),
normalizeOptional(payload.getDescriptionDe()), normalizeRichTextOptional(payload.getDescriptionDe()),
normalizeOptional(payload.getDescriptionFr()) normalizeRichTextOptional(payload.getDescriptionFr())
); );
Map<String, String> descriptions = new LinkedHashMap<>(); Map<String, String> descriptions = new LinkedHashMap<>();
descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); descriptions.put("it", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionIt()), fallbackDescription));
descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); descriptions.put("en", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionEn()), fallbackDescription));
descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); descriptions.put("de", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionDe()), fallbackDescription));
descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); descriptions.put("fr", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionFr()), fallbackDescription));
String fallbackSeoTitle = firstNonBlank(
normalizeOptional(payload.getSeoTitle()),
normalizeOptional(payload.getSeoTitleIt()),
normalizeOptional(payload.getSeoTitleEn()),
normalizeOptional(payload.getSeoTitleDe()),
normalizeOptional(payload.getSeoTitleFr())
);
Map<String, String> seoTitles = new LinkedHashMap<>();
seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle));
seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle));
seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle));
seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle));
String fallbackSeoDescription = firstNonBlank(
normalizeOptional(payload.getSeoDescription()),
normalizeOptional(payload.getSeoDescriptionIt()),
normalizeOptional(payload.getSeoDescriptionEn()),
normalizeOptional(payload.getSeoDescriptionDe()),
normalizeOptional(payload.getSeoDescriptionFr())
);
Map<String, String> seoDescriptions = new LinkedHashMap<>();
seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian"));
seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English"));
seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German"));
seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French"));
return new LocalizedProductContent( return new LocalizedProductContent(
names.get("it"), names.get("it"),
firstNonBlank(excerpts.get("it"), fallbackExcerpt), firstNonBlank(excerpts.get("it"), fallbackExcerpt),
firstNonBlank(descriptions.get("it"), fallbackDescription), firstNonBlank(descriptions.get("it"), fallbackDescription),
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
names, names,
excerpts, excerpts,
descriptions descriptions,
seoTitles,
seoDescriptions
); );
} }
@@ -630,6 +711,27 @@ public class AdminShopProductControllerService {
return normalized.isBlank() ? null : normalized; return normalized.isBlank() ? null : 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 firstNonBlank(String... values) { private String firstNonBlank(String... values) {
if (values == null) { if (values == null) {
return null; return null;
@@ -670,6 +772,13 @@ public class AdminShopProductControllerService {
return normalized.toUpperCase(Locale.ROOT); return normalized.toUpperCase(Locale.ROOT);
} }
private String validateSeoDescriptionLength(String value, String languageLabel) {
if (value != null && value.length() > 160) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
}
return value;
}
private void validateModelUpload(MultipartFile file) { private void validateModelUpload(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required");
@@ -786,9 +895,13 @@ public class AdminShopProductControllerService {
String defaultName, String defaultName,
String defaultExcerpt, String defaultExcerpt,
String defaultDescription, String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names, Map<String, String> names,
Map<String, String> excerpts, Map<String, String> excerpts,
Map<String, String> descriptions Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) { ) {
} }
} }

View File

@@ -29,13 +29,18 @@ public class MediaFfmpegService {
"WEBP", List.of("libwebp", "webp"), "WEBP", List.of("libwebp", "webp"),
"AVIF", List.of("libaom-av1", "librav1e", "libsvtav1") "AVIF", List.of("libaom-av1", "librav1e", "libsvtav1")
); );
private static final Map<String, List<String>> REQUIRED_MUXERS = Map.of(
"AVIF", List.of("avif")
);
private final String ffmpegExecutable; private final String ffmpegExecutable;
private final Set<String> availableEncoders; private final Set<String> availableEncoders;
private final Set<String> availableMuxers;
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
this.ffmpegExecutable = resolveExecutable(ffmpegPath); this.ffmpegExecutable = resolveExecutable(ffmpegPath);
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders()); this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
this.availableMuxers = Collections.unmodifiableSet(loadAvailableMuxers());
} }
public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException {
@@ -47,9 +52,13 @@ public class MediaFfmpegService {
Path targetPath = sanitizeMediaPath(target, "target", false); Path targetPath = sanitizeMediaPath(target, "target", false);
Files.createDirectories(targetPath.getParent()); Files.createDirectories(targetPath.getParent());
String encoder = resolveEncoder(format); String normalizedFormat = normalizeFormat(format);
String encoder = resolveEncoder(normalizedFormat);
if (encoder == null) { if (encoder == null) {
throw new IOException("FFmpeg encoder not available for media format " + format + "."); throw new IOException("FFmpeg encoder not available for media format " + normalizedFormat + ".");
}
if (!hasRequiredMuxer(normalizedFormat)) {
throw new IOException("FFmpeg muxer not available for media format " + normalizedFormat + ".");
} }
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
@@ -66,7 +75,7 @@ public class MediaFfmpegService {
command.add("1"); command.add("1");
command.add("-an"); command.add("-an");
switch (format) { switch (normalizedFormat) {
case "JPEG" -> { case "JPEG" -> {
command.add("-c:v"); command.add("-c:v");
command.add(encoder); command.add(encoder);
@@ -86,8 +95,10 @@ public class MediaFfmpegService {
command.add("30"); command.add("30");
command.add("-b:v"); command.add("-b:v");
command.add("0"); command.add("0");
command.add("-f");
command.add("avif");
} }
default -> throw new IllegalArgumentException("Unsupported media format: " + format); default -> throw new IllegalArgumentException("Unsupported media format: " + normalizedFormat);
} }
command.add(targetPath.toString()); command.add(targetPath.toString());
@@ -112,14 +123,16 @@ public class MediaFfmpegService {
} }
public boolean canEncode(String format) { public boolean canEncode(String format) {
return resolveEncoder(format) != null; String normalizedFormat = normalizeFormat(format);
return resolveEncoder(normalizedFormat) != null && hasRequiredMuxer(normalizedFormat);
} }
private String resolveEncoder(String format) { private String resolveEncoder(String format) {
if (format == null) { String normalizedFormat = normalizeFormat(format);
if (normalizedFormat == null) {
return null; return null;
} }
List<String> candidates = ENCODER_CANDIDATES.get(format.trim().toUpperCase(Locale.ROOT)); List<String> candidates = ENCODER_CANDIDATES.get(normalizedFormat);
if (candidates == null) { if (candidates == null) {
return null; return null;
} }
@@ -129,6 +142,21 @@ public class MediaFfmpegService {
.orElse(null); .orElse(null);
} }
private boolean hasRequiredMuxer(String format) {
List<String> requiredMuxers = REQUIRED_MUXERS.get(format);
if (requiredMuxers == null || requiredMuxers.isEmpty()) {
return true;
}
return requiredMuxers.stream().anyMatch(availableMuxers::contains);
}
private String normalizeFormat(String format) {
if (format == null) {
return null;
}
return format.trim().toUpperCase(Locale.ROOT);
}
private Set<String> loadAvailableEncoders() { private Set<String> loadAvailableEncoders() {
List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-encoders"); List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-encoders");
try { try {
@@ -154,6 +182,31 @@ public class MediaFfmpegService {
} }
} }
private Set<String> loadAvailableMuxers() {
List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-muxers");
try {
Process process = startValidatedProcess(command);
String output;
try (InputStream processStream = process.getInputStream()) {
output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8);
}
int exitCode = process.waitFor();
if (exitCode != 0) {
logger.warn("Unable to inspect FFmpeg muxers. Falling back to empty muxer list.");
return Set.of();
}
return parseAvailableMuxers(output);
} catch (Exception e) {
logger.warn(
"Unable to inspect FFmpeg muxers for executable '{}'. Falling back to empty muxer list. {}",
ffmpegExecutable,
e.getMessage()
);
return Set.of();
}
}
private Process startValidatedProcess(List<String> command) throws IOException { private Process startValidatedProcess(List<String> command) throws IOException {
// nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder // nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder
return new ProcessBuilder(List.copyOf(command)) return new ProcessBuilder(List.copyOf(command))
@@ -264,6 +317,26 @@ public class MediaFfmpegService {
return encoders; return encoders;
} }
private Set<String> parseAvailableMuxers(String output) {
if (output == null || output.isBlank()) {
return Set.of();
}
Set<String> muxers = new LinkedHashSet<>();
for (String line : output.split("\\R")) {
String trimmed = line.trim();
if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Muxers:")) {
continue;
}
String[] parts = trimmed.split("\\s+", 3);
if (parts.length < 2) {
continue;
}
muxers.add(parts[1]);
}
return muxers;
}
private String truncate(String output) { private String truncate(String output) {
if (output == null || output.isBlank()) { if (output == null || output.isBlank()) {
return ""; return "";

View File

@@ -21,7 +21,7 @@ public class MediaStorageService {
private final String frontendBaseUrl; private final String frontendBaseUrl;
public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot,
@Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8080}}") String frontendBaseUrl) { @Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8081}}") String frontendBaseUrl) {
this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize();
this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); this.originalRootLocation = normalizedRootLocation.resolve("original").normalize();
this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); this.publicRootLocation = normalizedRootLocation.resolve("public").normalize();
@@ -131,8 +131,11 @@ public class MediaStorageService {
private String buildMediaBaseUrl() { private String buildMediaBaseUrl() {
String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : ""; String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : "";
if (normalized.contains("localhost")){
return "http://localhost:8081";
}
if (normalized.isBlank()) { if (normalized.isBlank()) {
normalized = "http://localhost:4200"; normalized = "http://localhost:8081";
} }
if (normalized.endsWith("/")) { if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1); normalized = normalized.substring(0, normalized.length() - 1);

View File

@@ -280,11 +280,19 @@ public class AdminOrderControllerService {
itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName()); itemDto.setShopVariantColorName(item.getShopVariantColorName());
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -334,11 +334,19 @@ public class OrderControllerService {
itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName()); itemDto.setShopVariantColorName(item.getShopVariantColorName());
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -81,7 +81,15 @@ public class QuoteSessionResponseAssembler {
dto.put("shopProductName", item.getShopProductName()); dto.put("shopProductName", item.getShopProductName());
dto.put("shopVariantLabel", item.getShopVariantLabel()); dto.put("shopVariantLabel", item.getShopVariantLabel());
dto.put("shopVariantColorName", item.getShopVariantColorName()); dto.put("shopVariantColorName", item.getShopVariantColorName());
dto.put("shopVariantColorLabelIt", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
dto.put("shopVariantColorLabelEn", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
dto.put("shopVariantColorLabelDe", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
dto.put("shopVariantColorLabelFr", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
dto.put("shopVariantColorHex", item.getShopVariantColorHex()); dto.put("shopVariantColorHex", item.getShopVariantColorHex());
dto.put("filamentColorLabelIt", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelIt() : null);
dto.put("filamentColorLabelEn", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelEn() : null);
dto.put("filamentColorLabelDe", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelDe() : null);
dto.put("filamentColorLabelFr", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelFr() : null);
dto.put("materialCode", item.getMaterialCode()); dto.put("materialCode", item.getMaterialCode());
dto.put("quality", item.getQuality()); dto.put("quality", item.getQuality());
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());

View File

@@ -9,10 +9,12 @@ import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.dto.ShopProductModelDto; import com.printcalculator.dto.ShopProductModelDto;
import com.printcalculator.dto.ShopProductSummaryDto; import com.printcalculator.dto.ShopProductSummaryDto;
import com.printcalculator.dto.ShopProductVariantOptionDto; import com.printcalculator.dto.ShopProductVariantOptionDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.ShopCategory; import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct; import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset; import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant; import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.ShopCategoryRepository; import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository; import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository; import com.printcalculator.repository.ShopProductRepository;
@@ -31,6 +33,7 @@ import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
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.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -46,6 +49,7 @@ public class PublicShopCatalogService {
private final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
private final ShopProductVariantRepository shopProductVariantRepository; private final ShopProductVariantRepository shopProductVariantRepository;
private final ShopProductModelAssetRepository shopProductModelAssetRepository; private final ShopProductModelAssetRepository shopProductModelAssetRepository;
private final FilamentVariantRepository filamentVariantRepository;
private final PublicMediaQueryService publicMediaQueryService; private final PublicMediaQueryService publicMediaQueryService;
private final ShopStorageService shopStorageService; private final ShopStorageService shopStorageService;
@@ -53,19 +57,21 @@ public class PublicShopCatalogService {
ShopProductRepository shopProductRepository, ShopProductRepository shopProductRepository,
ShopProductVariantRepository shopProductVariantRepository, ShopProductVariantRepository shopProductVariantRepository,
ShopProductModelAssetRepository shopProductModelAssetRepository, ShopProductModelAssetRepository shopProductModelAssetRepository,
FilamentVariantRepository filamentVariantRepository,
PublicMediaQueryService publicMediaQueryService, PublicMediaQueryService publicMediaQueryService,
ShopStorageService shopStorageService) { ShopStorageService shopStorageService) {
this.shopCategoryRepository = shopCategoryRepository; this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository; this.shopProductRepository = shopProductRepository;
this.shopProductVariantRepository = shopProductVariantRepository; this.shopProductVariantRepository = shopProductVariantRepository;
this.shopProductModelAssetRepository = shopProductModelAssetRepository; this.shopProductModelAssetRepository = shopProductModelAssetRepository;
this.filamentVariantRepository = filamentVariantRepository;
this.publicMediaQueryService = publicMediaQueryService; this.publicMediaQueryService = publicMediaQueryService;
this.shopStorageService = shopStorageService; this.shopStorageService = shopStorageService;
} }
public List<ShopCategoryTreeDto> getCategories(String language) { public List<ShopCategoryTreeDto> getCategories(String language) {
CategoryContext categoryContext = loadCategoryContext(language); CategoryContext categoryContext = loadCategoryContext(language);
return buildCategoryTree(null, categoryContext); return buildCategoryTree(null, categoryContext, language);
} }
public ShopCategoryDetailDto getCategory(String slug, String language) { public ShopCategoryDetailDto getCategory(String slug, String language) {
@@ -77,7 +83,7 @@ public class PublicShopCatalogService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found");
} }
return buildCategoryDetail(category, categoryContext); return buildCategoryDetail(category, categoryContext, language);
} }
public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) { public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) {
@@ -99,11 +105,16 @@ public class PublicShopCatalogService {
List<ShopProductSummaryDto> products = productContext.entries().stream() List<ShopProductSummaryDto> products = productContext.entries().stream()
.filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId()))
.filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured()))
.map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug(), language)) .map(entry -> toProductSummaryDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
))
.toList(); .toList();
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
? buildCategoryDetail(selectedCategory, categoryContext) ? buildCategoryDetail(selectedCategory, categoryContext, language)
: null; : null;
return new ShopProductCatalogResponseDto( return new ShopProductCatalogResponseDto(
@@ -128,7 +139,12 @@ public class PublicShopCatalogService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
} }
return toProductDetailDto(entry, productContext.productMediaBySlug(), language); return toProductDetailDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
);
} }
public ProductModelDownload getProductModelDownload(String slug) { public ProductModelDownload getProductModelDownload(String slug) {
@@ -187,11 +203,32 @@ public class PublicShopCatalogService {
entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(), entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(),
language language
); );
Map<String, String> variantColorHexByMaterialAndColor = buildFilamentVariantColorHexMap();
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));
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug); return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
}
private Map<String, String> buildFilamentVariantColorHexMap() {
Map<String, String> colorsByMaterialAndColor = new LinkedHashMap<>();
for (FilamentVariant variant : filamentVariantRepository.findByIsActiveTrue()) {
String materialCode = variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: null;
String key = toMaterialAndColorKey(materialCode, variant.getColorName());
if (key == null) {
continue;
}
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
continue;
}
colorsByMaterialAndColor.putIfAbsent(key, colorHex);
}
return colorsByMaterialAndColor;
} }
private List<ProductEntry> loadPublicProducts(Collection<UUID> activeCategoryIds) { private List<ProductEntry> loadPublicProducts(Collection<UUID> activeCategoryIds) {
@@ -279,53 +316,63 @@ public class PublicShopCatalogService {
return total; return total;
} }
private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId, CategoryContext categoryContext) { private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId,
CategoryContext categoryContext,
String language) {
return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream() return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream()
.map(category -> new ShopCategoryTreeDto( .map(category -> new ShopCategoryTreeDto(
category.getId(), category.getId(),
category.getParentCategory() != null ? category.getParentCategory().getId() : null, category.getParentCategory() != null ? category.getParentCategory().getId() : null,
category.getSlug(), category.getSlug(),
category.getName(), category.getNameForLanguage(language),
category.getDescription(), category.getDescriptionForLanguage(language),
category.getSeoTitle(), category.getSeoTitleForLanguage(language),
category.getSeoDescription(), category.getSeoDescriptionForLanguage(language),
category.getOgTitle(), category.getOgTitle(),
category.getOgDescription(), category.getOgDescription(),
category.getIndexable(), category.getIndexable(),
category.getSortOrder(), category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))), selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
buildCategoryTree(category.getId(), categoryContext) buildCategoryTree(category.getId(), categoryContext, language)
)) ))
.toList(); .toList();
} }
private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) { private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category,
CategoryContext categoryContext,
String language) {
List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of()); List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of());
String localizedSeoTitle = category.getSeoTitleForLanguage(language);
String localizedSeoDescription = category.getSeoDescriptionForLanguage(language);
return new ShopCategoryDetailDto( return new ShopCategoryDetailDto(
category.getId(), category.getId(),
category.getSlug(), category.getSlug(),
category.getName(), category.getNameForLanguage(language),
category.getDescription(), category.getDescriptionForLanguage(language),
category.getSeoTitle(), localizedSeoTitle,
category.getSeoDescription(), localizedSeoDescription,
category.getOgTitle(), category.getOgTitle(),
category.getOgDescription(), category.getOgDescription(),
category.getIndexable(), category.getIndexable(),
category.getSortOrder(), category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
buildCategoryBreadcrumbs(category), buildCategoryBreadcrumbs(category, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
buildCategoryTree(category.getId(), categoryContext) buildCategoryTree(category.getId(), categoryContext, language)
); );
} }
private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category) { private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category, String language) {
List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>(); List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>();
ShopCategory current = category; ShopCategory current = category;
while (current != null) { while (current != null) {
breadcrumbs.add(new ShopCategoryRefDto(current.getId(), current.getSlug(), current.getName())); breadcrumbs.add(new ShopCategoryRefDto(
current.getId(),
current.getSlug(),
current.getNameForLanguage(language)
));
current = current.getParentCategory(); current = current.getParentCategory();
} }
java.util.Collections.reverse(breadcrumbs); java.util.Collections.reverse(breadcrumbs);
@@ -349,6 +396,7 @@ public class PublicShopCatalogService {
private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
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());
return new ShopProductSummaryDto( return new ShopProductSummaryDto(
@@ -361,11 +409,11 @@ public class PublicShopCatalogService {
new ShopCategoryRefDto( new ShopCategoryRefDto(
entry.product().getCategory().getId(), entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(), entry.product().getCategory().getSlug(),
entry.product().getCategory().getName() entry.product().getCategory().getNameForLanguage(language)
), ),
resolvePriceFrom(entry.variants()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
toProductModelDto(entry) toProductModelDto(entry)
); );
@@ -373,32 +421,35 @@ public class PublicShopCatalogService {
private ShopProductDetailDto toProductDetailDto(ProductEntry entry, private ShopProductDetailDto toProductDetailDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
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 localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
return new ShopProductDetailDto( return new ShopProductDetailDto(
entry.product().getId(), 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),
entry.product().getDescriptionForLanguage(language), entry.product().getDescriptionForLanguage(language),
entry.product().getSeoTitle(), localizedSeoTitle,
entry.product().getSeoDescription(), localizedSeoDescription,
entry.product().getOgTitle(), localizedSeoTitle,
entry.product().getOgDescription(), localizedSeoDescription,
entry.product().getIndexable(), entry.product().getIndexable(),
entry.product().getIsFeatured(), entry.product().getIsFeatured(),
entry.product().getSortOrder(), entry.product().getSortOrder(),
new ShopCategoryRefDto( new ShopCategoryRefDto(
entry.product().getCategory().getId(), entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(), entry.product().getCategory().getSlug(),
entry.product().getCategory().getName() entry.product().getCategory().getNameForLanguage(language)
), ),
buildCategoryBreadcrumbs(entry.product().getCategory()), buildCategoryBreadcrumbs(entry.product().getCategory(), language),
resolvePriceFrom(entry.variants()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
entry.variants().stream() entry.variants().stream()
.map(variant -> toVariantDto(variant, entry.defaultVariant())) .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language))
.toList(), .toList(),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
@@ -406,21 +457,63 @@ public class PublicShopCatalogService {
); );
} }
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant) { private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
ShopProductVariant defaultVariant,
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
if (variant == null) { if (variant == null) {
return null; return null;
} }
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
String key = toMaterialAndColorKey(variant.getInternalMaterialCode(), variant.getColorName());
colorHex = key != null ? variantColorHexByMaterialAndColor.get(key) : null;
}
return new ShopProductVariantOptionDto( return new ShopProductVariantOptionDto(
variant.getId(), variant.getId(),
variant.getSku(), variant.getSku(),
variant.getVariantLabel(), variant.getVariantLabel(),
variant.getColorName(), variant.getColorName(),
variant.getColorHex(), variant.getColorLabelForLanguage(language),
colorHex,
variant.getPriceChf(), variant.getPriceChf(),
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
); );
} }
private String toMaterialAndColorKey(String materialCode, String colorName) {
String normalizedMaterialCode = normalizeMaterialCode(materialCode);
String normalizedColorName = normalizeColorName(colorName);
if (normalizedMaterialCode == null || normalizedColorName == null) {
return null;
}
return normalizedMaterialCode + "|" + normalizedColorName;
}
private String normalizeMaterialCode(String materialCode) {
String raw = trimToNull(materialCode);
if (raw == null) {
return null;
}
return raw.toUpperCase(Locale.ROOT);
}
private String normalizeColorName(String colorName) {
String raw = trimToNull(colorName);
if (raw == null) {
return null;
}
return raw.toLowerCase(Locale.ROOT);
}
private String trimToNull(String value) {
String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) {
return null;
}
return raw;
}
private ShopProductModelDto toProductModelDto(ProductEntry entry) { private ShopProductModelDto toProductModelDto(ProductEntry entry) {
if (entry.modelAsset() == null) { if (entry.modelAsset() == null) {
return null; return null;
@@ -492,7 +585,8 @@ 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, List<PublicMediaUsageDto>> productMediaBySlug Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor
) { ) {
} }

View File

@@ -0,0 +1,258 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class ShopSitemapService {
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
private static final String DEFAULT_LANGUAGE = "it";
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository;
private final String frontendBaseUrl;
private final Duration cacheTtl;
private final Clock clock;
private volatile CachedSitemap cachedSitemap;
@Autowired
public ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository,
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
@Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds) {
this(shopCategoryRepository, shopProductRepository, frontendBaseUrl, cacheSeconds, Clock.systemUTC());
}
ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository,
String frontendBaseUrl,
long cacheSeconds,
Clock clock) {
this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository;
this.frontendBaseUrl = normalizeBaseUrl(frontendBaseUrl);
this.cacheTtl = cacheSeconds > 0 ? Duration.ofSeconds(cacheSeconds) : Duration.ZERO;
this.clock = clock;
}
public String getShopSitemapXml() {
Instant now = Instant.now(clock);
CachedSitemap current = cachedSitemap;
if (current != null && now.isBefore(current.expiresAt())) {
return current.xml();
}
synchronized (this) {
current = cachedSitemap;
now = Instant.now(clock);
if (current != null && now.isBefore(current.expiresAt())) {
return current.xml();
}
String xml = buildSitemapXml();
Instant expiresAt = cacheTtl.isZero() ? now : now.plus(cacheTtl);
cachedSitemap = new CachedSitemap(xml, expiresAt);
return xml;
}
}
private String buildSitemapXml() {
List<ShopCategory> activeCategories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
Set<UUID> activeCategoryIds = activeCategories.stream()
.map(ShopCategory::getId)
.collect(Collectors.toSet());
List<ShopProduct> activeProducts = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
StringBuilder xml = new StringBuilder(16_384);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ");
xml.append("xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n");
appendCategoryUrls(xml, activeCategories);
appendProductUrls(xml, activeProducts, activeCategoryIds);
xml.append("</urlset>\n");
return xml.toString();
}
private void appendCategoryUrls(StringBuilder xml, List<ShopCategory> categories) {
for (ShopCategory category : categories) {
if (!Boolean.TRUE.equals(category.getIndexable())) {
continue;
}
String encodedSlug = pathEncodeSegment(category.getSlug());
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/" + encodedSlug);
}
appendUrlEntry(xml, hrefByLanguage, category.getUpdatedAt());
}
}
private void appendProductUrls(StringBuilder xml,
List<ShopProduct> products,
Set<UUID> activeCategoryIds) {
for (ShopProduct product : products) {
if (!Boolean.TRUE.equals(product.getIndexable())) {
continue;
}
if (product.getCategory() == null || !activeCategoryIds.contains(product.getCategory().getId())) {
continue;
}
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
String publicSegment = localizedProductPathSegment(product, language);
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
}
appendUrlEntry(xml, hrefByLanguage, product.getUpdatedAt());
}
}
private void appendUrlEntry(StringBuilder xml,
Map<String, String> hrefByLanguage,
OffsetDateTime lastmod) {
String defaultHref = hrefByLanguage.get(DEFAULT_LANGUAGE);
if (defaultHref == null || defaultHref.isBlank()) {
return;
}
for (String locLanguage : SUPPORTED_LANGUAGES) {
String locHref = hrefByLanguage.get(locLanguage);
if (locHref == null || locHref.isBlank()) {
continue;
}
appendLocalizedUrlEntry(xml, locHref, hrefByLanguage, defaultHref, lastmod);
}
}
private void appendLocalizedUrlEntry(StringBuilder xml,
String locHref,
Map<String, String> hrefByLanguage,
String defaultHref,
OffsetDateTime lastmod) {
xml.append(" <url>\n");
xml.append(" <loc>").append(xmlEscape(locHref)).append("</loc>\n");
for (String language : SUPPORTED_LANGUAGES) {
String href = hrefByLanguage.get(language);
if (href == null || href.isBlank()) {
continue;
}
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(language)
.append("\" href=\"")
.append(xmlEscape(href))
.append("\" />\n");
}
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"")
.append(xmlEscape(defaultHref))
.append("\" />\n");
if (lastmod != null) {
xml.append(" <lastmod>").append(LASTMOD_FORMATTER.format(lastmod)).append("</lastmod>\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) {
String safeSegment = rawSegment == null ? "" : rawSegment;
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
}
private String xmlEscape(String value) {
return String.valueOf(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
private String normalizeBaseUrl(String baseUrl) {
String normalized = (baseUrl == null ? "" : baseUrl).trim();
if (normalized.isBlank()) {
return "http://localhost:4200";
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private record CachedSitemap(String xml, Instant expiresAt) {
}
}

View File

@@ -56,6 +56,7 @@ 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.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
# Admin back-office authentication # Admin back-office authentication
admin.password=${ADMIN_PASSWORD} admin.password=${ADMIN_PASSWORD}

View File

@@ -0,0 +1,31 @@
package com.printcalculator.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FilamentVariantTest {
@Test
void getColorLabelForLanguageShouldReturnLocalizedValue() {
FilamentVariant variant = new FilamentVariant();
variant.setColorName("Orange");
variant.setColorLabelIt("Arancione");
variant.setColorLabelEn("Orange");
variant.setColorLabelDe("Orange");
variant.setColorLabelFr("Orange");
assertEquals("Arancione", variant.getColorLabelForLanguage("it"));
assertEquals("Orange", variant.getColorLabelForLanguage("en"));
assertEquals("Orange", variant.getColorLabelForLanguage("de-CH"));
}
@Test
void getColorLabelForLanguageShouldFallbackToColorName() {
FilamentVariant variant = new FilamentVariant();
variant.setColorName("Orange");
assertEquals("Orange", variant.getColorLabelForLanguage("it"));
assertEquals("Orange", variant.getColorLabelForLanguage("fr"));
}
}

View File

@@ -0,0 +1,55 @@
package com.printcalculator.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShopCategoryTest {
@Test
void localizedAccessorsShouldReturnLanguageSpecificValues() {
ShopCategory category = new ShopCategory();
category.setName("Desk accessories");
category.setNameIt("Accessori da scrivania");
category.setNameEn("Desk accessories");
category.setNameDe("Schreibtischzubehor");
category.setNameFr("Accessoires de bureau");
category.setDescription("Legacy description");
category.setDescriptionIt("Organizer e accessori stampati per la scrivania.");
category.setDescriptionEn("Printed desk organizers and accessories.");
category.setDescriptionDe("Gedruckte Organizer und Zubehor fur den Schreibtisch.");
category.setDescriptionFr("Accessoires et organiseurs imprimes pour le bureau.");
category.setSeoTitle("Legacy SEO title");
category.setSeoTitleIt("Accessori da scrivania stampati in 3D");
category.setSeoTitleEn("3D printed desk accessories");
category.setSeoTitleDe("3D-gedruckte Schreibtischaccessoires");
category.setSeoTitleFr("Accessoires de bureau imprimes en 3D");
category.setSeoDescription("Legacy SEO description");
category.setSeoDescriptionIt("Accessori da scrivania personalizzati e funzionali.");
category.setSeoDescriptionEn("Functional custom desk accessories.");
category.setSeoDescriptionDe("Funktionale personalisierte Schreibtischaccessoires.");
category.setSeoDescriptionFr("Accessoires de bureau fonctionnels et personnalises.");
assertEquals("Accessori da scrivania", category.getNameForLanguage("it"));
assertEquals("Desk accessories", category.getNameForLanguage("en"));
assertEquals("Schreibtischzubehor", category.getNameForLanguage("de"));
assertEquals("Accessoires de bureau", category.getNameForLanguage("fr"));
assertEquals("Gedruckte Organizer und Zubehor fur den Schreibtisch.", category.getDescriptionForLanguage("de"));
assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("en"));
assertEquals("Accessoires de bureau fonctionnels et personnalises.", category.getSeoDescriptionForLanguage("fr"));
}
@Test
void localizedAccessorsShouldFallbackToLegacyValues() {
ShopCategory category = new ShopCategory();
category.setName("Desk accessories");
category.setDescription("Printed desk organizers and accessories.");
category.setSeoTitle("3D printed desk accessories");
category.setSeoDescription("Functional custom desk accessories.");
assertEquals("Desk accessories", category.getNameForLanguage("it"));
assertEquals("Printed desk organizers and accessories.", category.getDescriptionForLanguage("de"));
assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("fr-CH"));
assertEquals("Functional custom desk accessories.", category.getSeoDescriptionForLanguage("en-US"));
}
}

View File

@@ -0,0 +1,32 @@
package com.printcalculator.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShopProductVariantTest {
@Test
void getColorLabelForLanguageShouldReturnLocalizedValue() {
ShopProductVariant variant = new ShopProductVariant();
variant.setColorName("Gray");
variant.setColorLabelIt("Grigio");
variant.setColorLabelEn("Gray");
variant.setColorLabelDe("Grau");
variant.setColorLabelFr("Gris");
assertEquals("Grigio", variant.getColorLabelForLanguage("it"));
assertEquals("Gray", variant.getColorLabelForLanguage("en"));
assertEquals("Grau", variant.getColorLabelForLanguage("de"));
assertEquals("Gris", variant.getColorLabelForLanguage("fr-CH"));
}
@Test
void getColorLabelForLanguageShouldFallbackToColorName() {
ShopProductVariant variant = new ShopProductVariant();
variant.setColorName("Gray");
assertEquals("Gray", variant.getColorLabelForLanguage("it"));
assertEquals("Gray", variant.getColorLabelForLanguage("de"));
}
}

View File

@@ -40,10 +40,13 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -217,6 +220,210 @@ class OrderServiceTest {
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
} }
@Test
void createOrderFromQuote_withShopProductMissingSourceFile_shouldNotFail() throws Exception {
UUID sessionId = UUID.randomUUID();
UUID orderId = UUID.randomUUID();
UUID orderItemId = UUID.randomUUID();
QuoteSession session = new QuoteSession();
session.setId(sessionId);
session.setStatus("ACTIVE");
session.setSessionType("SHOP_CART");
session.setMaterialCode("SHOP");
session.setPricingVersion("v1");
session.setSetupCostChf(BigDecimal.ZERO);
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
ShopCategory category = new ShopCategory();
category.setId(UUID.randomUUID());
category.setSlug("desk");
category.setName("Desk");
ShopProduct product = new ShopProduct();
product.setId(UUID.randomUUID());
product.setCategory(category);
product.setSlug("organizer");
product.setName("Organizer");
ShopProductVariant variant = new ShopProductVariant();
variant.setId(UUID.randomUUID());
variant.setProduct(product);
variant.setVariantLabel("PLA");
variant.setColorName("Orange");
variant.setColorHex("#ff8a00");
variant.setInternalMaterialCode("PLA");
variant.setPriceChf(new BigDecimal("18.00"));
Path missingSource = Path.of("storage_quotes")
.toAbsolutePath()
.normalize()
.resolve(sessionId.toString())
.resolve("missing-shop-item.stl");
QuoteLineItem qItem = new QuoteLineItem();
qItem.setId(UUID.randomUUID());
qItem.setQuoteSession(session);
qItem.setStatus("READY");
qItem.setLineItemType("SHOP_PRODUCT");
qItem.setOriginalFilename("organizer.stl");
qItem.setDisplayName("Organizer");
qItem.setQuantity(1);
qItem.setColorCode("Orange");
qItem.setMaterialCode("PLA");
qItem.setShopProduct(product);
qItem.setShopProductVariant(variant);
qItem.setShopProductSlug(product.getSlug());
qItem.setShopProductName(product.getName());
qItem.setShopVariantLabel("PLA");
qItem.setShopVariantColorName("Orange");
qItem.setShopVariantColorHex("#ff8a00");
qItem.setUnitPriceChf(new BigDecimal("18.00"));
qItem.setStoredPath(missingSource.toString());
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
Customer saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(UUID.randomUUID());
}
return saved;
});
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
new QuoteSessionTotalsService.QuoteSessionTotals(
new BigDecimal("18.00"),
BigDecimal.ZERO,
BigDecimal.ZERO,
new BigDecimal("18.00"),
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
new BigDecimal("18.00"),
BigDecimal.ZERO
)
);
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
Order saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(orderId);
}
return saved;
});
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
OrderItem saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(orderItemId);
}
return saved;
});
when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("<svg/>".getBytes(StandardCharsets.UTF_8));
when(invoiceService.generateDocumentPdf(any(Order.class), any(List.class), eq(true), eq(qrBillService), isNull()))
.thenReturn("pdf".getBytes(StandardCharsets.UTF_8));
when(paymentService.getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"))).thenReturn(new Payment());
Order order = service.createOrderFromQuote(sessionId, buildRequest());
assertEquals(orderId, order.getId());
assertEquals("CONVERTED", session.getStatus());
ArgumentCaptor<OrderItem> itemCaptor = ArgumentCaptor.forClass(OrderItem.class);
verify(orderItemRepo, times(2)).save(itemCaptor.capture());
OrderItem savedItem = itemCaptor.getAllValues().getLast();
assertEquals("PENDING", savedItem.getStoredRelativePath());
assertNull(savedItem.getFileSizeBytes());
verify(storageService, never()).store(eq(missingSource), any(Path.class));
verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER");
}
@Test
void createOrderFromQuote_withCalculatorItemMissingSourceFile_shouldFail() {
UUID sessionId = UUID.randomUUID();
UUID orderId = UUID.randomUUID();
UUID orderItemId = UUID.randomUUID();
QuoteSession session = new QuoteSession();
session.setId(sessionId);
session.setStatus("ACTIVE");
session.setSessionType("QUOTE");
session.setMaterialCode("PLA");
session.setPricingVersion("v1");
session.setSetupCostChf(BigDecimal.ZERO);
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
Path missingSource = Path.of("storage_quotes")
.toAbsolutePath()
.normalize()
.resolve(sessionId.toString())
.resolve("missing-calculator-item.stl");
QuoteLineItem qItem = new QuoteLineItem();
qItem.setId(UUID.randomUUID());
qItem.setQuoteSession(session);
qItem.setStatus("READY");
qItem.setLineItemType("PRINT_FILE");
qItem.setOriginalFilename("part.stl");
qItem.setDisplayName("part.stl");
qItem.setQuantity(1);
qItem.setMaterialCode("PLA");
qItem.setUnitPriceChf(new BigDecimal("9.50"));
qItem.setStoredPath(missingSource.toString());
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
Customer saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(UUID.randomUUID());
}
return saved;
});
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
new QuoteSessionTotalsService.QuoteSessionTotals(
new BigDecimal("9.50"),
BigDecimal.ZERO,
BigDecimal.ZERO,
new BigDecimal("9.50"),
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
BigDecimal.ZERO,
new BigDecimal("9.50"),
BigDecimal.ZERO
)
);
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
Order saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(orderId);
}
return saved;
});
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
OrderItem saved = invocation.getArgument(0);
if (saved.getId() == null) {
saved.setId(orderItemId);
}
return saved;
});
IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> service.createOrderFromQuote(sessionId, buildRequest())
);
assertEquals(
"Source file not available for quote line item " + qItem.getId(),
exception.getMessage()
);
verify(paymentService, never()).getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"));
verify(eventPublisher, never()).publishEvent(any(OrderCreatedEvent.class));
}
private CreateOrderRequest buildRequest() { private CreateOrderRequest buildRequest() {
CustomerDto customer = new CustomerDto(); CustomerDto customer = new CustomerDto();
customer.setEmail("buyer@example.com"); customer.setEmail("buyer@example.com");

View File

@@ -0,0 +1,137 @@
package com.printcalculator.service.admin;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.media.PublicMediaQueryService;
import com.printcalculator.service.shop.ShopStorageService;
import com.printcalculator.service.storage.ClamAVService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminShopProductControllerServiceTest {
@Mock
private ShopProductRepository shopProductRepository;
@Mock
private ShopCategoryRepository shopCategoryRepository;
@Mock
private ShopProductVariantRepository shopProductVariantRepository;
@Mock
private ShopProductModelAssetRepository shopProductModelAssetRepository;
@Mock
private QuoteLineItemRepository quoteLineItemRepository;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private PublicMediaQueryService publicMediaQueryService;
@Mock
private AdminMediaControllerService adminMediaControllerService;
@Mock
private ShopStorageService shopStorageService;
@Mock
private SlicerService slicerService;
@Mock
private ClamAVService clamAVService;
private AdminShopProductControllerService service;
@BeforeEach
void setUp() {
service = new AdminShopProductControllerService(
shopProductRepository,
shopCategoryRepository,
shopProductVariantRepository,
shopProductModelAssetRepository,
quoteLineItemRepository,
orderItemRepository,
publicMediaQueryService,
adminMediaControllerService,
shopStorageService,
slicerService,
clamAVService,
104857600L
);
}
@Test
void deleteProduct_shouldDeleteManagedDependenciesBeforeDeletingProduct() {
UUID productId = UUID.randomUUID();
UUID variantId = UUID.randomUUID();
ShopProduct product = new ShopProduct();
product.setId(productId);
ShopProductVariant variant = new ShopProductVariant();
variant.setId(variantId);
variant.setProduct(product);
ShopProductModelAsset asset = new ShopProductModelAsset();
asset.setId(UUID.randomUUID());
asset.setProduct(product);
asset.setStoredRelativePath("products/" + productId + "/model.stl");
when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product));
when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false);
when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false);
when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of(variant));
when(quoteLineItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false);
when(orderItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false);
when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.of(asset));
when(shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId))
.thenReturn(Path.of("/tmp/shop-model.stl"));
service.deleteProduct(productId);
InOrder inOrder = inOrder(shopProductModelAssetRepository, shopProductVariantRepository, shopProductRepository);
inOrder.verify(shopProductModelAssetRepository).delete(asset);
inOrder.verify(shopProductVariantRepository).deleteAll(List.of(variant));
inOrder.verify(shopProductRepository).delete(product);
verify(shopStorageService).resolveStoredProductPath(asset.getStoredRelativePath(), productId);
}
@Test
void deleteProduct_shouldSkipDependencyDeletesWhenNothingIsAttached() {
UUID productId = UUID.randomUUID();
ShopProduct product = new ShopProduct();
product.setId(productId);
when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product));
when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false);
when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false);
when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of());
when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.empty());
service.deleteProduct(productId);
verify(shopProductRepository).delete(product);
verify(shopProductVariantRepository, never()).deleteAll(any());
verify(shopProductModelAssetRepository, never()).delete(any());
}
}

View File

@@ -10,6 +10,7 @@ import java.nio.file.attribute.PosixFilePermission;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -80,6 +81,12 @@ class MediaFfmpegServiceTest {
EOF EOF
exit 0 exit 0
fi fi
if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then
cat <<'EOF'
E avif
EOF
exit 0
fi
for arg in "$@"; do for arg in "$@"; do
if [ "$arg" = "-still-picture" ]; then if [ "$arg" = "-still-picture" ]; then
@@ -117,4 +124,42 @@ class MediaFfmpegServiceTest {
assertTrue(Files.exists(target)); assertTrue(Files.exists(target));
assertEquals("ok", Files.readString(target)); assertEquals("ok", Files.readString(target));
} }
@Test
void canEncode_avifShouldRequireMuxerAvailability() throws Exception {
Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg-no-avif-muxer.sh");
Files.writeString(
fakeFfmpeg,
"""
#!/bin/sh
if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then
cat <<'EOF'
V..... mjpeg
V..... libaom-av1
EOF
exit 0
fi
if [ "$1" = "-hide_banner" ] && [ "$2" = "-muxers" ]; then
cat <<'EOF'
E mp4
EOF
exit 0
fi
exit 0
"""
);
Files.setPosixFilePermissions(
fakeFfmpeg,
Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE
)
);
MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString());
assertTrue(service.canEncode("JPEG"));
assertFalse(service.canEncode("AVIF"));
}
} }

View File

@@ -0,0 +1,122 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductRepository;
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 java.time.Clock;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ShopSitemapServiceTest {
@Mock
private ShopCategoryRepository shopCategoryRepository;
@Mock
private ShopProductRepository shopProductRepository;
private ShopSitemapService service;
@BeforeEach
void setUp() {
Clock fixedClock = Clock.fixed(Instant.parse("2026-03-11T10:00:00Z"), ZoneOffset.UTC);
service = new ShopSitemapService(
shopCategoryRepository,
shopProductRepository,
"https://3d-fab.ch/",
900,
fixedClock
);
}
@Test
void getShopSitemapXml_shouldGenerateLocalizedCategoryAndProductEntries() {
ShopCategory visibleCategory = new ShopCategory();
visibleCategory.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
visibleCategory.setSlug("accessori");
visibleCategory.setIndexable(true);
visibleCategory.setIsActive(true);
visibleCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T08:00:00Z"));
ShopCategory hiddenCategory = new ShopCategory();
hiddenCategory.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
hiddenCategory.setSlug("bozza");
hiddenCategory.setIndexable(false);
hiddenCategory.setIsActive(true);
hiddenCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T09:00:00Z"));
ShopProduct indexedProduct = new ShopProduct();
indexedProduct.setId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
indexedProduct.setCategory(visibleCategory);
indexedProduct.setSlug("supporto-bici");
indexedProduct.setNameIt("Supporto bici");
indexedProduct.setNameEn("Bike Holder");
indexedProduct.setNameDe("Fahrrad Halter");
indexedProduct.setNameFr("Support velo");
indexedProduct.setIndexable(true);
indexedProduct.setIsActive(true);
indexedProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T07:30:00Z"));
ShopProduct hiddenProduct = new ShopProduct();
hiddenProduct.setId(UUID.fromString("33333333-3333-3333-3333-333333333333"));
hiddenProduct.setCategory(visibleCategory);
hiddenProduct.setSlug("draft");
hiddenProduct.setIndexable(false);
hiddenProduct.setIsActive(true);
hiddenProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T08:00:00Z"));
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc())
.thenReturn(List.of(visibleCategory, hiddenCategory));
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc())
.thenReturn(List.of(indexedProduct, hiddenProduct));
String xml = service.getShopSitemapXml();
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/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/fr/shop/accessori</loc>"));
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\""));
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/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/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=\"de\" 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("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
assertFalse(xml.contains("33333333-draft"));
}
@Test
void getShopSitemapXml_shouldServeCachedPayloadWithinTtl() {
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of());
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of());
String firstXml = service.getShopSitemapXml();
String secondXml = service.getShopSitemapXml();
assertTrue(firstXml.contains("<urlset"));
assertTrue(secondXml.contains("<urlset"));
verify(shopCategoryRepository, times(1)).findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
verify(shopProductRepository, times(1)).findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
}
}

134
db.sql
View File

@@ -44,6 +44,10 @@ create table filament_variant
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc. color_name text not null, -- Nero, Bianco, ecc.
color_label_it text,
color_label_en text,
color_label_de text,
color_label_fr text,
color_hex text, color_hex text,
finish_type text not null default 'GLOSSY' finish_type text not null default 'GLOSSY'
check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')),
@@ -70,6 +74,22 @@ select filament_variant_id,
(stock_spools * spool_net_kg) as stock_kg (stock_spools * spool_net_kg) as stock_kg
from filament_variant; from filament_variant;
alter table filament_variant
add column if not exists color_label_it text,
add column if not exists color_label_en text,
add column if not exists color_label_de text,
add column if not exists color_label_fr text;
update filament_variant
set color_label_it = coalesce(nullif(btrim(color_label_it), ''), color_name),
color_label_en = coalesce(nullif(btrim(color_label_en), ''), color_name),
color_label_de = coalesce(nullif(btrim(color_label_de), ''), color_name),
color_label_fr = coalesce(nullif(btrim(color_label_fr), ''), color_name)
where nullif(btrim(color_label_it), '') is null
or nullif(btrim(color_label_en), '') is null
or nullif(btrim(color_label_de), '') is null
or nullif(btrim(color_label_fr), '') is null;
create table printer_machine_profile create table printer_machine_profile
( (
printer_machine_profile_id bigserial primary key, printer_machine_profile_id bigserial primary key,
@@ -1013,9 +1033,25 @@ CREATE TABLE IF NOT EXISTS shop_category
parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL, parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL,
slug text NOT NULL UNIQUE, slug text NOT NULL UNIQUE,
name text NOT NULL, name text NOT NULL,
name_it text,
name_en text,
name_de text,
name_fr text,
description text, description text,
description_it text,
description_en text,
description_de text,
description_fr text,
seo_title text, seo_title text,
seo_title_it text,
seo_title_en text,
seo_title_de text,
seo_title_fr text,
seo_description text, seo_description text,
seo_description_it text,
seo_description_en text,
seo_description_de text,
seo_description_fr text,
og_title text, og_title text,
og_description text, og_description text,
indexable boolean NOT NULL DEFAULT true, indexable boolean NOT NULL DEFAULT true,
@@ -1034,6 +1070,66 @@ CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort
CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort
ON shop_category (is_active, sort_order, created_at DESC); ON shop_category (is_active, sort_order, created_at DESC);
ALTER TABLE shop_category
ADD COLUMN IF NOT EXISTS name_it text,
ADD COLUMN IF NOT EXISTS name_en text,
ADD COLUMN IF NOT EXISTS name_de text,
ADD COLUMN IF NOT EXISTS name_fr text,
ADD COLUMN IF NOT EXISTS description_it text,
ADD COLUMN IF NOT EXISTS description_en text,
ADD COLUMN IF NOT EXISTS description_de text,
ADD COLUMN IF NOT EXISTS description_fr text,
ADD COLUMN IF NOT EXISTS seo_title_it text,
ADD COLUMN IF NOT EXISTS seo_title_en text,
ADD COLUMN IF NOT EXISTS seo_title_de text,
ADD COLUMN IF NOT EXISTS seo_title_fr text,
ADD COLUMN IF NOT EXISTS seo_description_it text,
ADD COLUMN IF NOT EXISTS seo_description_en text,
ADD COLUMN IF NOT EXISTS seo_description_de text,
ADD COLUMN IF NOT EXISTS seo_description_fr text;
UPDATE shop_category
SET
name_it = COALESCE(NULLIF(btrim(name_it), ''), name),
name_en = COALESCE(NULLIF(btrim(name_en), ''), name),
name_de = COALESCE(NULLIF(btrim(name_de), ''), name),
name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name),
description_it = COALESCE(NULLIF(btrim(description_it), ''), description),
description_en = COALESCE(NULLIF(btrim(description_en), ''), description),
description_de = COALESCE(NULLIF(btrim(description_de), ''), description),
description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description),
seo_title_it = COALESCE(NULLIF(btrim(seo_title_it), ''), seo_title),
seo_title_en = COALESCE(NULLIF(btrim(seo_title_en), ''), seo_title),
seo_title_de = COALESCE(NULLIF(btrim(seo_title_de), ''), seo_title),
seo_title_fr = COALESCE(NULLIF(btrim(seo_title_fr), ''), seo_title),
seo_description_it = COALESCE(NULLIF(btrim(seo_description_it), ''), seo_description),
seo_description_en = COALESCE(NULLIF(btrim(seo_description_en), ''), seo_description),
seo_description_de = COALESCE(NULLIF(btrim(seo_description_de), ''), seo_description),
seo_description_fr = COALESCE(NULLIF(btrim(seo_description_fr), ''), seo_description)
WHERE
NULLIF(btrim(name_it), '') IS NULL
OR NULLIF(btrim(name_en), '') IS NULL
OR NULLIF(btrim(name_de), '') IS NULL
OR NULLIF(btrim(name_fr), '') IS NULL
OR (description IS NOT NULL AND (
NULLIF(btrim(description_it), '') IS NULL
OR NULLIF(btrim(description_en), '') IS NULL
OR NULLIF(btrim(description_de), '') IS NULL
OR NULLIF(btrim(description_fr), '') IS NULL
))
OR (seo_title IS NOT NULL AND (
NULLIF(btrim(seo_title_it), '') IS NULL
OR NULLIF(btrim(seo_title_en), '') IS NULL
OR NULLIF(btrim(seo_title_de), '') IS NULL
OR NULLIF(btrim(seo_title_fr), '') IS NULL
))
OR (seo_description IS NOT NULL AND (
NULLIF(btrim(seo_description_it), '') IS NULL
OR NULLIF(btrim(seo_description_en), '') IS NULL
OR NULLIF(btrim(seo_description_de), '') IS NULL
OR NULLIF(btrim(seo_description_fr), '') IS NULL
));
CREATE TABLE IF NOT EXISTS shop_product CREATE TABLE IF NOT EXISTS shop_product
( (
shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1055,7 +1151,15 @@ CREATE TABLE IF NOT EXISTS shop_product
description_de text, description_de text,
description_fr text, description_fr text,
seo_title text, seo_title text,
seo_title_it text,
seo_title_en text,
seo_title_de text,
seo_title_fr text,
seo_description text, seo_description text,
seo_description_it text,
seo_description_en text,
seo_description_de text,
seo_description_fr text,
og_title text, og_title text,
og_description text, og_description text,
indexable boolean NOT NULL DEFAULT true, indexable boolean NOT NULL DEFAULT true,
@@ -1066,6 +1170,16 @@ CREATE TABLE IF NOT EXISTS shop_product
updated_at timestamptz NOT NULL DEFAULT now() updated_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE shop_product
ADD COLUMN IF NOT EXISTS seo_title_it text,
ADD COLUMN IF NOT EXISTS seo_title_en text,
ADD COLUMN IF NOT EXISTS seo_title_de text,
ADD COLUMN IF NOT EXISTS seo_title_fr text,
ADD COLUMN IF NOT EXISTS seo_description_it text,
ADD COLUMN IF NOT EXISTS seo_description_en text,
ADD COLUMN IF NOT EXISTS seo_description_de text,
ADD COLUMN IF NOT EXISTS seo_description_fr text;
CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort
ON shop_product (shop_category_id, is_active, sort_order, created_at DESC); ON shop_product (shop_category_id, is_active, sort_order, created_at DESC);
@@ -1147,6 +1261,10 @@ CREATE TABLE IF NOT EXISTS shop_product_variant
sku text UNIQUE, sku text UNIQUE,
variant_label text NOT NULL, variant_label text NOT NULL,
color_name text NOT NULL, color_name text NOT NULL,
color_label_it text,
color_label_en text,
color_label_de text,
color_label_fr text,
color_hex text, color_hex text,
internal_material_code text NOT NULL, internal_material_code text NOT NULL,
price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0),
@@ -1163,6 +1281,22 @@ CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort
CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku
ON shop_product_variant (sku); ON shop_product_variant (sku);
ALTER TABLE shop_product_variant
ADD COLUMN IF NOT EXISTS color_label_it text,
ADD COLUMN IF NOT EXISTS color_label_en text,
ADD COLUMN IF NOT EXISTS color_label_de text,
ADD COLUMN IF NOT EXISTS color_label_fr text;
UPDATE shop_product_variant
SET color_label_it = COALESCE(NULLIF(btrim(color_label_it), ''), color_name),
color_label_en = COALESCE(NULLIF(btrim(color_label_en), ''), color_name),
color_label_de = COALESCE(NULLIF(btrim(color_label_de), ''), color_name),
color_label_fr = COALESCE(NULLIF(btrim(color_label_fr), ''), color_name)
WHERE NULLIF(btrim(color_label_it), '') IS NULL
OR NULLIF(btrim(color_label_en), '') IS NULL
OR NULLIF(btrim(color_label_de), '') IS NULL
OR NULLIF(btrim(color_label_fr), '') IS NULL;
CREATE TABLE IF NOT EXISTS shop_product_model_asset CREATE TABLE IF NOT EXISTS shop_product_model_asset
( (
shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@@ -33,7 +33,7 @@ services:
- 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}
- SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop} - SHOP_STORAGE_ROOT=${SHOP_STORAGE_ROOT:-/app/storage_shop}
- MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg} - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-/usr/local/bin/ffmpeg-media}
- MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400}
restart: always restart: always
logging: logging:

View File

@@ -1,14 +1,13 @@
# Stage 1: Build FROM node:22-alpine
FROM node:20 as build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci --legacy-peer-deps
COPY . . COPY . .
RUN npm run build --configuration=production RUN npm run build -- --configuration=production
# Stage 2: Serve ENV NODE_ENV=production
FROM nginx:alpine ENV PORT=80
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["node", "dist/frontend/server/server.mjs"]

View File

@@ -1,15 +1,15 @@
# Stage 1: Build FROM node:22-alpine
FROM node:20 as build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci --legacy-peer-deps
COPY . . COPY . .
# Use development configuration to pick up environment.ts (localhost) # Use development configuration to pick up environment.ts (localhost)
RUN npm run build -- --configuration=development RUN npm run build -- --configuration=development
# Stage 2: Serve ENV NODE_ENV=development
FROM nginx:alpine ENV PORT=80
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["node", "dist/frontend/server/server.mjs"]

View File

@@ -36,7 +36,12 @@
"@angular/material/prebuilt-themes/azure-blue.css", "@angular/material/prebuilt-themes/azure-blue.css",
"src/styles.scss" "src/styles.scss"
], ],
"scripts": [] "scripts": [],
"server": "src/main.server.ts",
"prerender": false,
"ssr": {
"entry": "src/server.ts"
}
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -56,13 +61,13 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "600kB",
"maximumError": "1MB" "maximumError": "1.2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "10kB",
"maximumError": "8kB" "maximumError": "14kB"
} }
] ]
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -6,35 +6,41 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"serve:ssr:frontend": "node dist/frontend/server/server.mjs"
}, },
"private": true, "private": true,
"engines": { "engines": {
"node": "^22.0.0" "node": "^22.0.0"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.19", "@angular/cdk": "19.2.19",
"@angular/common": "^19.2.18", "@angular/common": "19.2.19",
"@angular/compiler": "^19.2.18", "@angular/compiler": "19.2.19",
"@angular/core": "^19.2.18", "@angular/core": "19.2.19",
"@angular/forms": "^19.2.18", "@angular/forms": "19.2.19",
"@angular/material": "^19.2.19", "@angular/material": "19.2.19",
"@angular/platform-browser": "^19.2.18", "@angular/platform-browser": "19.2.19",
"@angular/platform-browser-dynamic": "^19.2.18", "@angular/platform-browser-dynamic": "19.2.19",
"@angular/router": "^19.2.18", "@angular/platform-server": "19.2.19",
"@angular/router": "19.2.19",
"@angular/ssr": "19.2.19",
"@ngx-translate/core": "^17.0.0", "@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"express": "^4.18.2",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"three": "^0.182.0", "three": "^0.182.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.2.19", "@angular-devkit/build-angular": "19.2.19",
"@angular/cli": "^19.2.19", "@angular/cli": "19.2.19",
"@angular/compiler-cli": "^19.2.18", "@angular/compiler-cli": "19.2.19",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",

View File

@@ -5,17 +5,19 @@ Disallow: /admin
Disallow: /admin/ Disallow: /admin/
Disallow: /*/admin Disallow: /*/admin
Disallow: /*/admin/ Disallow: /*/admin/
Disallow: /order
Disallow: /order/ Disallow: /order/
Disallow: /*/order
Disallow: /*/order/ Disallow: /*/order/
Disallow: /co$
Disallow: /co/ Disallow: /co/
Disallow: /*/co$
Disallow: /*/co/ Disallow: /*/co/
Disallow: /checkout Disallow: /checkout
Disallow: /checkout/ Disallow: /checkout/
Disallow: /checkout/cad
Disallow: /*/checkout Disallow: /*/checkout
Disallow: /*/checkout/ Disallow: /*/checkout/
Disallow: /shop Disallow: /*/checkout/cad
Disallow: /shop/
Disallow: /*/shop
Disallow: /*/shop/
Sitemap: https://3d-fab.ch/sitemap.xml Sitemap: https://3d-fab.ch/sitemap.xml

View File

@@ -0,0 +1,330 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/en</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/de</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

View File

@@ -1,144 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<urlset <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" <sitemap>
xmlns:xhtml="http://www.w3.org/1999/xhtml" <loc>https://3d-fab.ch/sitemap-static.xml</loc>
> </sitemap>
<url> <sitemap>
<loc>https://3d-fab.ch/it</loc> <loc>https://3d-fab.ch/api/sitemap-shop.xml</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" /> </sitemap>
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" /> </sitemapindex>
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/basic"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/basic"
/>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/calculator/advanced"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/calculator/advanced"
/>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/about"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link
rel="alternate"
hreflang="it"
href="https://3d-fab.ch/it/contact"
/>
<xhtml:link
rel="alternate"
hreflang="en"
href="https://3d-fab.ch/en/contact"
/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://3d-fab.ch/de/contact"
/>
<xhtml:link
rel="alternate"
hreflang="fr"
href="https://3d-fab.ch/fr/contact"
/>
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/contact"
/>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link
rel="alternate"
hreflang="x-default"
href="https://3d-fab.ch/it/privacy"
/>
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
</urlset>

View File

@@ -0,0 +1,9 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -1,22 +1,48 @@
import { import {
ApplicationConfig, ApplicationConfig,
provideAppInitializer,
provideZoneChangeDetection, provideZoneChangeDetection,
importProvidersFrom, importProvidersFrom,
inject,
REQUEST,
} from '@angular/core'; } from '@angular/core';
import { import {
provideRouter, provideRouter,
withComponentInputBinding, withComponentInputBinding,
withInMemoryScrolling, withInMemoryScrolling,
withViewTransitions, withViewTransitions,
Router,
} from '@angular/router'; } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { import {
provideTranslateHttpLoader, TranslateLoader,
TranslateHttpLoader, TranslateModule,
} from '@ngx-translate/http-loader'; TranslateService,
} from '@ngx-translate/core';
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor'; import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
import {
provideClientHydration,
withEventReplay,
} from '@angular/platform-browser';
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
import { catchError, firstValueFrom, of } from 'rxjs';
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr'];
function resolveLangFromUrl(url: string): SupportedLang {
const firstSegment = (url || '/')
.split('?')[0]
.split('#')[0]
.split('/')
.filter(Boolean)[0]
?.toLowerCase();
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
? (firstSegment as SupportedLang)
: 'it';
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -29,19 +55,44 @@ export const appConfig: ApplicationConfig = {
scrollPositionRestoration: 'top', scrollPositionRestoration: 'top',
}), }),
), ),
provideHttpClient(withInterceptors([adminAuthInterceptor])), provideHttpClient(
provideTranslateHttpLoader({ withInterceptors([serverOriginInterceptor, adminAuthInterceptor]),
prefix: './assets/i18n/', ),
suffix: '.json',
}),
importProvidersFrom( importProvidersFrom(
TranslateModule.forRoot({ TranslateModule.forRoot({
defaultLanguage: 'it', defaultLanguage: 'it',
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateHttpLoader, useClass: StaticTranslateLoader,
}, },
}), }),
), ),
provideAppInitializer(() => {
const translate = inject(TranslateService);
const router = inject(Router);
const request = inject(REQUEST, { optional: true }) as {
url?: string;
} | null;
translate.addLangs([...SUPPORTED_LANGS]);
translate.setDefaultLang('it');
const requestedUrl =
(typeof request?.url === 'string' && request.url) || router.url || '/';
const lang = resolveLangFromUrl(requestedUrl);
return firstValueFrom(
translate.use(lang).pipe(
catchError((error) => {
console.error('[i18n] Failed to preload language for SSR', {
lang,
requestedUrl,
error,
});
return of({});
}),
),
).then(() => undefined);
}),
provideClientHydration(withEventReplay()),
], ],
}; };

View File

@@ -1,4 +1,13 @@
import { Routes } from '@angular/router'; import { CanMatchFn, Routes } from '@angular/router';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
const langPrefixCanMatch: CanMatchFn = (_route, segments) => {
if (segments.length === 0) {
return false;
}
return SUPPORTED_LANGS.has(segments[0].path.toLowerCase());
};
const appChildRoutes: Routes = [ const appChildRoutes: Routes = [
{ {
@@ -6,9 +15,18 @@ 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: {
seoTitle: '3D fab | Stampa 3D su misura', seoTitleByLang: {
seoDescription: it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', 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.",
},
}, },
}, },
{ {
@@ -43,6 +61,18 @@ const appChildRoutes: Routes = [
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.', 'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
}, },
}, },
/* {
path: 'materials',
loadComponent: () =>
import('./features/materials/materials-page.component').then(
(m) => m.MaterialsPageComponent,
),
data: {
seoTitle: 'Qualita e Materiali | 3D fab',
seoDescription:
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
},
},*/
{ {
path: 'contact', path: 'contact',
loadChildren: () => loadChildren: () =>
@@ -116,6 +146,7 @@ const appChildRoutes: Routes = [
export const routes: Routes = [ export const routes: Routes = [
{ {
path: ':lang', path: ':lang',
canMatch: [langPrefixCanMatch],
loadComponent: () => loadComponent: () =>
import('./core/layout/layout.component').then((m) => m.LayoutComponent), import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes, children: appChildRoutes,

View File

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

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import de from '../../../assets/i18n/de.json';
import en from '../../../assets/i18n/en.json';
import fr from '../../../assets/i18n/fr.json';
import it from '../../../assets/i18n/it.json';
const TRANSLATIONS: Record<string, TranslationObject> = {
it: it as TranslationObject,
en: en as TranslationObject,
de: de as TranslationObject,
fr: fr as TranslationObject,
};
@Injectable()
export class StaticTranslateLoader implements TranslateLoader {
getTranslation(lang: string): Observable<TranslationObject> {
const normalized = String(lang || 'it').toLowerCase();
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']);
}
}

View File

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

View File

@@ -130,7 +130,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 }}</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">
@@ -138,7 +140,7 @@
class="color-dot" class="color-dot"
[style.background-color]="cartItemColorHex(item)" [style.background-color]="cartItemColorHex(item)"
></span> ></span>
<span>{{ color }}</span> <span>{{ color | translate }}</span>
</span> </span>
} }
</div> </div>

View File

@@ -15,6 +15,10 @@ import {
ShopService, ShopService,
} from '../../features/shop/services/shop.service'; } from '../../features/shop/services/shop.service';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../constants/colors.const';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
@@ -143,15 +147,30 @@ export class NavbarComponent {
} }
cartItemVariant(item: ShopCartItem): string | null { cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null; return item.shopVariantLabel || this.cartItemColor(item);
} }
cartItemColor(item: ShopCartItem): string | null { cartItemColor(item: ShopCartItem): string | null {
return item.shopVariantColorName || item.colorCode || null; return (
resolveLocalizedColorLabel(this.langService.selectedLang(), {
fallback: item.shopVariantColorName ?? item.colorCode,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
}) ??
item.shopVariantColorName ??
item.colorCode
);
} }
cartItemColorHex(item: ShopCartItem): string { cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6'; return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
} }
trackByCartItem(_index: number, item: ShopCartItem): string { trackByCartItem(_index: number, item: ShopCartItem): string {

View File

@@ -12,14 +12,31 @@ export interface PageSeoOverride {
ogDescription?: string | null; ogDescription?: string | null;
} }
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type SeoMap = Partial<Record<SupportedLang, string>>;
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class SeoService { export class SeoService {
private readonly defaultTitle = '3D fab | Stampa 3D su misura'; private readonly defaultTitleByLang: Record<SupportedLang, string> = {
private readonly defaultDescription = it: '3D fab | Stampa 3D su misura',
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; en: '3D fab | Custom 3D Printing',
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); de: '3D fab | 3D-Druck nach Maß',
fr: '3D fab | Impression 3D sur mesure',
};
private readonly defaultDescriptionByLang: Record<SupportedLang, string> = {
it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.',
en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.',
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
};
private readonly supportedLangs = new Set<SupportedLang>([
'it',
'en',
'de',
'fr',
]);
constructor( constructor(
private router: Router, private router: Router,
@@ -40,9 +57,13 @@ export class SeoService {
} }
applyPageSeo(override: PageSeoOverride): void { applyPageSeo(override: PageSeoOverride): void {
const title = this.asString(override.title) ?? this.defaultTitle; const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const title =
this.asString(override.title) ?? this.defaultTitleByLang[lang];
const description = const description =
this.asString(override.description) ?? this.defaultDescription; this.asString(override.description) ??
this.defaultDescriptionByLang[lang];
const robots = this.asString(override.robots) ?? 'index, follow'; const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle = this.asString(override.ogTitle) ?? title; const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description; const ogDescription = this.asString(override.ogDescription) ?? description;
@@ -52,13 +73,18 @@ export class SeoService {
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot); const mergedData = this.getMergedRouteData(rootSnapshot);
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const title =
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
this.defaultTitleByLang[lang];
const description = const description =
this.asString(mergedData['seoDescription']) ?? this.defaultDescription; this.resolveSeoText(mergedData, 'seoDescription', lang) ??
this.defaultDescriptionByLang[lang];
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
const ogTitle = this.asString(mergedData['ogTitle']) ?? title; const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription = const ogDescription =
this.asString(mergedData['ogDescription']) ?? description; this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
this.applySeoValues(title, description, robots, ogTitle, ogDescription); this.applySeoValues(title, description, robots, ogTitle, ogDescription);
} }
@@ -104,11 +130,43 @@ export class SeoService {
return typeof value === 'string' ? value : undefined; return typeof value === 'string' ? value : undefined;
} }
private resolveSeoText(
routeData: Record<string, unknown>,
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription',
lang: SupportedLang,
): string | undefined {
const mapKey = `${key}ByLang`;
const localized = routeData[mapKey];
if (
localized &&
typeof localized === 'object' &&
!Array.isArray(localized)
) {
const mapped = localized as SeoMap;
const byLang = this.asString(mapped[lang]);
if (byLang) {
return byLang;
}
}
return this.asString(routeData[key]);
}
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 || '/';
} }
private resolveLangFromPath(path: string): SupportedLang {
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
if (
firstSegment &&
this.supportedLangs.has(firstSegment as SupportedLang)
) {
return firstSegment as SupportedLang;
}
return 'it';
}
private updateCanonicalTag(url: string): void { private updateCanonicalTag(url: string): void {
let link = this.document.head.querySelector( let link = this.document.head.querySelector(
'link[rel="canonical"]', 'link[rel="canonical"]',
@@ -124,10 +182,9 @@ export class SeoService {
private updateLangAndAlternates(path: string): void { private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase(); const firstSegment = segments[0]?.toLowerCase();
const hasLang = Boolean( const maybeLang = firstSegment as SupportedLang | undefined;
firstSegment && this.supportedLangs.has(firstSegment), const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
); const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
const lang = hasLang ? firstSegment : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments; const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix = const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, PLATFORM_ID, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
AdminCadInvoice, AdminCadInvoice,
@@ -16,6 +16,7 @@ import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.d
styleUrl: './admin-cad-invoices.component.scss', styleUrl: './admin-cad-invoices.component.scss',
}) })
export class AdminCadInvoicesComponent implements OnInit { export class AdminCadInvoicesComponent implements OnInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
private readonly adminOrdersService = inject(AdminOrdersService); private readonly adminOrdersService = inject(AdminOrdersService);
@@ -112,11 +113,17 @@ export class AdminCadInvoicesComponent implements OnInit {
} }
openCheckout(path: string): void { openCheckout(path: string): void {
if (!this.isBrowser) {
return;
}
const url = this.toCheckoutUrl(path); const url = this.toCheckoutUrl(path);
window.open(url, '_blank'); window.open(url, '_blank');
} }
copyCheckout(path: string): void { copyCheckout(path: string): void {
if (!this.isBrowser) {
return;
}
const url = this.toCheckoutUrl(path); const url = this.toCheckoutUrl(path);
navigator.clipboard?.writeText(url); navigator.clipboard?.writeText(url);
this.successMessage = 'Link checkout CAD copiato negli appunti.'; this.successMessage = 'Link checkout CAD copiato negli appunti.';
@@ -126,6 +133,9 @@ export class AdminCadInvoicesComponent implements OnInit {
if (!orderId) return; if (!orderId) return;
this.adminOrdersService.downloadOrderInvoice(orderId).subscribe({ this.adminOrdersService.downloadOrderInvoice(orderId).subscribe({
next: (blob) => { next: (blob) => {
if (!this.isBrowser) {
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -142,10 +152,16 @@ export class AdminCadInvoicesComponent implements OnInit {
private toCheckoutUrl(path: string): string { private toCheckoutUrl(path: string): string {
const safePath = path.startsWith('/') ? path : `/${path}`; const safePath = path.startsWith('/') ? path : `/${path}`;
const lang = this.resolveLang(); const lang = this.resolveLang();
if (!this.isBrowser) {
return `/${lang}${safePath}`;
}
return `${window.location.origin}/${lang}${safePath}`; return `${window.location.origin}/${lang}${safePath}`;
} }
private resolveLang(): string { private resolveLang(): string {
if (!this.isBrowser) {
return 'it';
}
const firstSegment = window.location.pathname const firstSegment = window.location.pathname
.split('/') .split('/')
.filter(Boolean) .filter(Boolean)

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; import { Component, PLATFORM_ID, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
AdminContactRequest, AdminContactRequest,
@@ -17,6 +17,7 @@ import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.d
styleUrl: './admin-contact-requests.component.scss', styleUrl: './admin-contact-requests.component.scss',
}) })
export class AdminContactRequestsComponent implements OnInit { export class AdminContactRequestsComponent implements OnInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
readonly statusOptions = ['NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED']; readonly statusOptions = ['NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED'];
@@ -171,6 +172,9 @@ export class AdminContactRequestsComponent implements OnInit {
} }
private downloadBlob(blob: Blob, filename: string): void { private downloadBlob(blob: Blob, filename: string): void {
if (!this.isBrowser) {
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;

View File

@@ -104,7 +104,6 @@
<td> <td>
<span <span
class="order-type-badge" class="order-type-badge"
[class.order-type-badge--shop]="orderKind(order) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(order) === 'MIXED'" [class.order-type-badge--mixed]="orderKind(order) === 'MIXED'"
> >
{{ orderKindLabel(order) }} {{ orderKindLabel(order) }}
@@ -133,7 +132,6 @@
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2> <h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<span <span
class="order-type-badge" class="order-type-badge"
[class.order-type-badge--shop]="orderKind(selectedOrder) === 'SHOP'"
[class.order-type-badge--mixed]=" [class.order-type-badge--mixed]="
orderKind(selectedOrder) === 'MIXED' orderKind(selectedOrder) === 'MIXED'
" "
@@ -259,10 +257,7 @@
<p class="file-name"> <p class="file-name">
<strong>{{ itemDisplayName(item) }}</strong> <strong>{{ itemDisplayName(item) }}</strong>
</p> </p>
<span <span class="item-kind-badge">
class="item-kind-badge"
[class.item-kind-badge--shop]="isShopItem(item)"
>
{{ isShopItem(item) ? "Shop" : "Calcolatore" }} {{ isShopItem(item) ? "Shop" : "Calcolatore" }}
</span> </span>
</div> </div>

View File

@@ -224,12 +224,6 @@ tbody tr.no-results:hover {
white-space: nowrap; white-space: nowrap;
} }
.order-type-badge--shop,
.item-kind-badge--shop {
background: color-mix(in srgb, var(--color-brand) 12%, white);
color: var(--color-brand);
}
.order-type-badge--mixed { .order-type-badge--mixed {
background: color-mix(in srgb, #f59e0b 16%, white); background: color-mix(in srgb, #f59e0b 16%, white);
color: #9a5b00; color: #9a5b00;

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; import { Component, PLATFORM_ID, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
AdminOrder, AdminOrder,
@@ -16,6 +16,7 @@ import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.d
styleUrl: './admin-dashboard.component.scss', styleUrl: './admin-dashboard.component.scss',
}) })
export class AdminDashboardComponent implements OnInit { export class AdminDashboardComponent implements OnInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminOrdersService = inject(AdminOrdersService); private readonly adminOrdersService = inject(AdminOrdersService);
orders: AdminOrder[] = []; orders: AdminOrder[] = [];
@@ -498,6 +499,9 @@ export class AdminDashboardComponent implements OnInit {
} }
private downloadBlob(blob: Blob, filename: string): void { private downloadBlob(blob: Blob, filename: string): void {
if (!this.isBrowser) {
return;
}
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;

View File

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

View File

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

View File

@@ -130,7 +130,7 @@
<button <button
*ngFor="let language of mediaLanguages" *ngFor="let language of mediaLanguages"
type="button" type="button"
class="language-toggle-btn ui-language-toolbar__button" class="ui-language-toolbar__button image-language-button"
[class.active]=" [class.active]="
getFormState(section.usageKey).activeLanguage === getFormState(section.usageKey).activeLanguage ===
language language
@@ -139,11 +139,28 @@
isLanguageComplete(section.usageKey, language) isLanguageComplete(section.usageKey, language)
" "
[class.incomplete]=" [class.incomplete]="
!isLanguageComplete(section.usageKey, language) isLanguageIncomplete(section.usageKey, language)
"
[class.empty]="
!isLanguageStarted(section.usageKey, language)
" "
(click)="setActiveLanguage(section.usageKey, language)" (click)="setActiveLanguage(section.usageKey, language)"
> >
{{ mediaLanguageLabels[language] }} <span class="image-language-button__label">
{{ mediaLanguageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isLanguageComplete(section.usageKey, language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isLanguageIncomplete(section.usageKey, language)"
>
...
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -27,6 +27,62 @@
gap: var(--space-1); gap: var(--space-1);
} }
.image-language-button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 3.15rem;
background: #ffffff;
color: var(--color-text-muted);
}
.image-language-button.empty {
opacity: 0.76;
}
.image-language-button.complete {
border-color: #b8ddc2;
}
.image-language-button.incomplete {
border-color: #e8c8c2;
}
.image-language-button.active {
background: #fff5b8;
border-color: var(--color-brand);
color: var(--color-text);
opacity: 1;
}
.image-language-button__label {
line-height: 1;
}
.image-language-button__state {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
font-size: 0.62rem;
font-weight: 800;
line-height: 1;
}
.image-language-button.complete .image-language-button__state {
background: #dcefdc;
color: #25603b;
}
.image-language-button.incomplete .image-language-button__state {
background: #f7ddd7;
color: #944329;
}
.form-field--wide { .form-field--wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

View File

@@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import {
Component,
PLATFORM_ID,
inject,
OnDestroy,
OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { of, switchMap } from 'rxjs'; import { of, switchMap } from 'rxjs';
import { import {
@@ -17,12 +23,16 @@ type HomeSectionKey =
| 'capability-prototyping' | 'capability-prototyping'
| 'capability-custom-parts' | 'capability-custom-parts'
| 'capability-small-series' | 'capability-small-series'
| 'capability-cad'; | 'capability-cad'
| 'joe'
| 'matteo';
type HomeMediaUsageType = 'HOME_SECTION' | 'ABOUT_MEMBER';
interface HomeMediaSectionConfig { interface HomeMediaSectionConfig {
usageType: 'HOME_SECTION'; usageType: HomeMediaUsageType;
usageKey: HomeSectionKey; usageKey: HomeSectionKey;
groupId: 'galleries' | 'capabilities'; groupId: 'galleries' | 'capabilities' | 'about-members';
title: string; title: string;
preferredVariantName: 'card' | 'hero'; preferredVariantName: 'card' | 'hero';
} }
@@ -81,6 +91,7 @@ const MEDIA_LANGUAGE_LABELS: Readonly<Record<AdminMediaLanguage, string>> = {
styleUrl: './admin-home-media.component.scss', styleUrl: './admin-home-media.component.scss',
}) })
export class AdminHomeMediaComponent implements OnInit, OnDestroy { export class AdminHomeMediaComponent implements OnInit, OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminMediaService = inject(AdminMediaService); private readonly adminMediaService = inject(AdminMediaService);
readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES; readonly mediaLanguages = SUPPORTED_MEDIA_LANGUAGES;
readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS; readonly mediaLanguageLabels = MEDIA_LANGUAGE_LABELS;
@@ -94,6 +105,10 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
id: 'capabilities', id: 'capabilities',
title: 'Cosa puoi ottenere', title: 'Cosa puoi ottenere',
}, },
{
id: 'about-members',
title: 'Chi siamo',
},
]; ];
readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [ readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [
@@ -139,6 +154,20 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
title: 'Home: consulenza e CAD', title: 'Home: consulenza e CAD',
preferredVariantName: 'card', preferredVariantName: 'card',
}, },
{
usageType: 'ABOUT_MEMBER',
usageKey: 'joe',
groupId: 'about-members',
title: 'Chi siamo: Joe',
preferredVariantName: 'card',
},
{
usageType: 'ABOUT_MEMBER',
usageKey: 'matteo',
groupId: 'about-members',
title: 'Chi siamo: Matteo',
preferredVariantName: 'card',
},
]; ];
sections: HomeMediaSectionView[] = []; sections: HomeMediaSectionView[] = [];
@@ -155,6 +184,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
'capability-custom-parts': this.createEmptyFormState(), 'capability-custom-parts': this.createEmptyFormState(),
'capability-small-series': this.createEmptyFormState(), 'capability-small-series': this.createEmptyFormState(),
'capability-cad': this.createEmptyFormState(), 'capability-cad': this.createEmptyFormState(),
joe: this.createEmptyFormState(),
matteo: this.createEmptyFormState(),
}; };
get configuredSectionCount(): number { get configuredSectionCount(): number {
@@ -219,7 +250,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
this.revokePreviewUrl(formState.previewUrl); this.revokePreviewUrl(formState.previewUrl);
formState.file = file; formState.file = file;
formState.previewUrl = file ? URL.createObjectURL(file) : null; formState.previewUrl =
file && this.isBrowser ? URL.createObjectURL(file) : null;
if (file && this.areAllTitlesBlank(formState.translations)) { if (file && this.areAllTitlesBlank(formState.translations)) {
const nextTitle = this.deriveDefaultTitle(file.name); const nextTitle = this.deriveDefaultTitle(file.name);
@@ -432,6 +464,25 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
); );
} }
isLanguageStarted(
sectionKey: HomeSectionKey,
language: AdminMediaLanguage,
): boolean {
return this.isTranslationStarted(
this.getFormState(sectionKey).translations[language],
);
}
isLanguageIncomplete(
sectionKey: HomeSectionKey,
language: AdminMediaLanguage,
): boolean {
return (
this.isLanguageStarted(sectionKey, language) &&
!this.isLanguageComplete(sectionKey, language)
);
}
getItemTranslation( getItemTranslation(
item: HomeMediaItem, item: HomeMediaItem,
language: AdminMediaLanguage, language: AdminMediaLanguage,
@@ -540,6 +591,9 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
} }
private revokePreviewUrl(previewUrl: string | null): void { private revokePreviewUrl(previewUrl: string | null): void {
if (!this.isBrowser) {
return;
}
if (!previewUrl?.startsWith('blob:')) { if (!previewUrl?.startsWith('blob:')) {
return; return;
} }
@@ -619,6 +673,10 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
return !!translation.title.trim() && !!translation.altText.trim(); return !!translation.title.trim() && !!translation.altText.trim();
} }
private isTranslationStarted(translation: AdminMediaTranslation): boolean {
return !!translation.title.trim() || !!translation.altText.trim();
}
private validateTranslations( private validateTranslations(
translations: Record<AdminMediaLanguage, AdminMediaTranslation>, translations: Record<AdminMediaLanguage, AdminMediaTranslation>,
): string | null { ): string | null {

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; import { Component, PLATFORM_ID, inject, OnInit } from '@angular/core';
import { import {
AdminOperationsService, AdminOperationsService,
AdminQuoteSession, AdminQuoteSession,
@@ -15,6 +15,7 @@ import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.d
styleUrl: './admin-sessions.component.scss', styleUrl: './admin-sessions.component.scss',
}) })
export class AdminSessionsComponent implements OnInit { export class AdminSessionsComponent implements OnInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
sessions: AdminQuoteSession[] = []; sessions: AdminQuoteSession[] = [];
@@ -51,9 +52,11 @@ export class AdminSessionsComponent implements OnInit {
return; return;
} }
const confirmed = window.confirm( const confirmed =
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`, this.isBrowser &&
); window.confirm(
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`,
);
if (!confirmed) { if (!confirmed) {
return; return;
} }

View File

@@ -8,25 +8,29 @@
</p> </p>
</div> </div>
<div class="header-actions"> <div class="header-side ui-stack ui-stack--dense">
<article class="ui-stat-chip"> <div class="header-stats ui-row ui-row--wrap ui-row--end">
<strong>{{ products.length }}</strong> <article class="ui-stat-chip">
<span>prodotti</span> <strong>{{ products.length }}</strong>
</article> <span>prodotti</span>
<article class="ui-stat-chip"> </article>
<strong>{{ categories.length }}</strong> <article class="ui-stat-chip">
<span>categorie</span> <strong>{{ categories.length }}</strong>
</article> <span>categorie</span>
<button </article>
type="button" </div>
class="ui-button ui-button--ghost" <div class="header-actions ui-row ui-row--wrap ui-row--end">
(click)="loadWorkspace()" <button
> type="button"
Aggiorna class="ui-button ui-button--ghost"
</button> (click)="loadWorkspace()"
<button type="button" class="ui-button" (click)="startCreateProduct()"> >
Nuovo prodotto Aggiorna
</button> </button>
<button type="button" class="ui-button" (click)="startCreateProduct()">
Nuovo prodotto
</button>
</div>
</div> </div>
</header> </header>
@@ -202,17 +206,6 @@
/> />
</label> </label>
<label class="ui-form-field">
<span class="ui-form-caption">Nome categoria</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.name"
name="categoryName"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">Slug</span> <span class="ui-form-caption">Slug</span>
<div class="input-with-action"> <div class="input-with-action">
@@ -233,36 +226,6 @@
</div> </div>
</label> </label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">Descrizione</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.description"
name="categoryDescription"
rows="3"
></textarea>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO title</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitle"
name="categorySeoTitle"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO description</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoDescription"
name="categorySeoDescription"
/>
</label>
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">OG title</span> <span class="ui-form-caption">OG title</span>
<input <input
@@ -284,6 +247,141 @@
</label> </label>
</div> </div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua contenuti categoria</span>
<p>IT / EN / DE / FR</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategoryContentLanguageComplete(language)"
[class.incomplete]="
isCategoryContentLanguageIncomplete(language)
"
[class.empty]="!isCategoryContentLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
Nome categoria {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.names[activeContentLanguage]"
[name]="'category-name-' + activeContentLanguage"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
Descrizione {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.descriptions[activeContentLanguage]"
[name]="'category-description-' + activeContentLanguage"
rows="3"
></textarea>
</label>
</div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua SEO categoria</span>
<p>Stessa lingua attiva dell'editor</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategorySeoLanguageComplete(language)"
[class.incomplete]="isCategorySeoLanguageIncomplete(language)"
[class.empty]="!isCategorySeoLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
SEO title {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitles[activeContentLanguage]"
[name]="'category-seo-title-' + activeContentLanguage"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
SEO description {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control"
[(ngModel)]="
categoryForm.seoDescriptions[activeContentLanguage]
"
[name]="'category-seo-description-' + activeContentLanguage"
rows="3"
></textarea>
<span
class="seo-counter"
[class.seo-counter--danger]="
categorySeoDescriptionLength(activeContentLanguage) > 160
"
>
{{ categorySeoDescriptionLength(activeContentLanguage) }}/160
</span>
</label>
</div>
<div class="toggle-row"> <div class="toggle-row">
<label class="ui-checkbox"> <label class="ui-checkbox">
<input <input
@@ -473,7 +571,7 @@
</div> </div>
<form class="detail-stack" (submit)="$event.preventDefault()"> <form class="detail-stack" (submit)="$event.preventDefault()">
<section class="form-section"> <section class="form-section ui-subpanel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h3>Dati base</h3> <h3>Dati base</h3>
@@ -565,7 +663,7 @@
</div> </div>
</section> </section>
<section class="form-section"> <section class="form-section ui-subpanel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h3>Contenuti localizzati</h3> <h3>Contenuti localizzati</h3>
@@ -584,13 +682,28 @@
<button <button
*ngFor="let language of shopLanguages" *ngFor="let language of shopLanguages"
type="button" type="button"
class="ui-language-toolbar__button" class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language" [class.active]="activeContentLanguage === language"
[class.complete]="isContentLanguageComplete(language)" [class.complete]="isContentLanguageComplete(language)"
[class.incomplete]="!isContentLanguageComplete(language)" [class.incomplete]="isContentLanguageIncomplete(language)"
[class.empty]="!isContentLanguageStarted(language)"
(click)="setActiveContentLanguage(language)" (click)="setActiveContentLanguage(language)"
> >
{{ languageLabels[language] }} <span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isContentLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isContentLanguageIncomplete(language)"
>
...
</span>
</button> </button>
</div> </div>
</div> </div>
@@ -620,120 +733,232 @@
/> />
</label> </label>
<label class="ui-form-field form-field--wide"> <div class="ui-form-field form-field--wide">
<span class="ui-form-caption"> <span class="ui-form-caption">
Descrizione {{ languageLabels[activeContentLanguage] }} Descrizione {{ languageLabels[activeContentLanguage] }}
</span> </span>
<textarea <div class="rich-text-field">
class="ui-form-control textarea-control textarea-control--large" <div class="rich-text-toolbar" role="toolbar">
[(ngModel)]="productForm.descriptions[activeContentLanguage]" <button
[name]="'product-description-' + activeContentLanguage" type="button"
rows="6" class="rich-text-toolbar__button"
></textarea> (mousedown)="preventRichTextToolbarMouseDown($event)"
</label> (click)="formatDescription('bold')"
title="Grassetto"
aria-label="Grassetto"
>
<strong>B</strong>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescription('italic')"
title="Corsivo"
aria-label="Corsivo"
>
<em>I</em>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescription('underline')"
title="Sottolineato"
aria-label="Sottolineato"
>
<u>U</u>
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescriptionList('unordered')"
title="Lista puntata"
aria-label="Lista puntata"
>
• Lista
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="formatDescriptionList('ordered')"
title="Lista numerata"
aria-label="Lista numerata"
>
1. Lista
</button>
<button
type="button"
class="rich-text-toolbar__button"
(mousedown)="preventRichTextToolbarMouseDown($event)"
(click)="clearDescriptionFormatting()"
title="Rimuovi formattazione"
aria-label="Rimuovi formattazione"
>
Tx
</button>
</div>
<div
#descriptionEditorRef
class="ui-form-control textarea-control textarea-control--large rich-text-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
[attr.aria-label]="
'Descrizione ' + languageLabels[activeContentLanguage]
"
(input)="onDescriptionEditorInput($event)"
(blur)="onDescriptionEditorBlur($event)"
></div>
</div>
<span class="ui-form-caption">
Supporta grassetto, corsivo, liste puntate e numerate.
</span>
</div>
</div> </div>
</section> </section>
<section class="form-section"> <section class="form-section ui-subpanel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h3>SEO e social</h3> <h3>SEO localizzata</h3>
<p>Metadati globali per risultato organico e preview.</p> <p>
I campi seguono la lingua attiva. Open Graph usa gli stessi
valori della SEO.
</p>
</div>
</div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua SEO</span>
<p>Stessa lingua attiva dell'editor contenuti</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isSeoLanguageComplete(language)"
[class.incomplete]="isSeoLanguageIncomplete(language)"
[class.empty]="!isSeoLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isSeoLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isSeoLanguageIncomplete(language)"
>
...
</span>
</button>
</div> </div>
</div> </div>
<div class="ui-form-grid ui-form-grid--two"> <div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">SEO title</span> <span class="ui-form-caption">
SEO title {{ languageLabels[activeContentLanguage] }}
</span>
<input <input
class="ui-form-control" class="ui-form-control"
type="text" type="text"
[(ngModel)]="productForm.seoTitle" [(ngModel)]="productForm.seoTitles[activeContentLanguage]"
name="productSeoTitle" [name]="'product-seo-title-' + activeContentLanguage"
/> />
</label> </label>
<label class="ui-form-field"> <label class="ui-form-field form-field--wide">
<span class="ui-form-caption">SEO description</span> <span class="ui-form-caption">
<input SEO description {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control" class="ui-form-control"
type="text" [(ngModel)]="productForm.seoDescriptions[activeContentLanguage]"
[(ngModel)]="productForm.seoDescription" [name]="'product-seo-description-' + activeContentLanguage"
name="productSeoDescription" rows="3"
/> ></textarea>
</label> <span
class="seo-counter"
<label class="ui-form-field"> [class.seo-counter--danger]="
<span class="ui-form-caption">OG title</span> seoDescriptionLength(activeContentLanguage) > 160
<input "
class="ui-form-control" >
type="text" {{ seoDescriptionLength(activeContentLanguage) }}/160
[(ngModel)]="productForm.ogTitle" </span>
name="productOgTitle"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">OG description</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="productForm.ogDescription"
name="productOgDescription"
/>
</label> </label>
</div> </div>
</section> </section>
<section class="form-section"> <section class="form-section ui-subpanel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h3>Varianti</h3> <h3>Materiali e prezzi</h3>
<p>Colori, materiale interno, SKU e prezzi.</p> <p>
Scegli i materiali disponibili a stock. I colori vengono presi
automaticamente dai filamenti attivi a magazzino.
</p>
</div> </div>
<button <button
type="button" type="button"
class="ui-button ui-button--ghost" class="ui-button ui-button--ghost"
(click)="addVariant()" (click)="addMaterial()"
[disabled]="
productForm.materials.length >= stockMaterialCodes().length
"
> >
Aggiungi variante Aggiungi materiale
</button> </button>
</div> </div>
<div class="locked-panel" *ngIf="stockMaterialCodes().length === 0">
Nessun materiale attivo con stock disponibile.
</div>
<div class="variant-stack"> <div class="variant-stack">
<article <article
class="variant-card" class="variant-card"
*ngFor=" *ngFor="
let variant of productForm.variants; let material of productForm.materials;
let index = index; let index = index;
trackBy: trackVariant trackBy: trackMaterial
" "
> >
<div class="variant-card__header"> <div class="variant-card__header">
<div> <div>
<h4> <h4>
{{ {{ material.materialCode || "Nuovo materiale" }}
variant.variantLabel ||
variant.colorName ||
"Nuova variante"
}}
</h4> </h4>
<p>Ordine {{ variant.sortOrder }}</p> <p>
{{ materialColorCount(material.materialCode) }} colori da
stock · ordine {{ material.sortOrder }}
</p>
</div> </div>
<div class="variant-card__actions"> <div class="variant-card__actions">
<button <button
type="button" type="button"
class="ui-button ui-button--ghost" class="ui-button ui-button--ghost"
(click)="setDefaultVariant(index)" (click)="setDefaultMaterial(index)"
[disabled]="variant.isDefault" [disabled]="material.isDefault"
> >
{{ variant.isDefault ? "Default" : "Rendi default" }} {{ material.isDefault ? "Default" : "Rendi default" }}
</button> </button>
<button <button
type="button" type="button"
class="ui-button ui-button--ghost-danger" class="ui-button ui-button--ghost-danger"
(click)="removeVariant(index)" (click)="removeMaterial(index)"
[disabled]="productForm.variants.length <= 1" [disabled]="productForm.materials.length <= 1"
> >
Rimuovi Rimuovi
</button> </button>
@@ -742,62 +967,25 @@
<div class="ui-form-grid ui-form-grid--two"> <div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">Nome variante</span> <span class="ui-form-caption">Materiale</span>
<input <select
class="ui-form-control" class="ui-form-control"
type="text" [(ngModel)]="material.materialCode"
[(ngModel)]="variant.variantLabel" [name]="'material-code-' + index"
[name]="'variant-label-' + index" (ngModelChange)="onMaterialCodeChange(index, $event)"
/> >
</label> <option [ngValue]="''" disabled>Seleziona materiale</option>
<option
<label class="ui-form-field"> *ngFor="
<span class="ui-form-caption">SKU</span> let materialCode of availableMaterialChoices(
<input material.materialCode
class="ui-form-control" )
type="text" "
[(ngModel)]="variant.sku" [ngValue]="materialCode"
[name]="'variant-sku-' + index" >
autocomplete="off" {{ materialCode }}
autocapitalize="off" </option>
spellcheck="false" </select>
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">Colore</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="variant.colorName"
[name]="'variant-color-name-' + index"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">HEX colore</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="variant.colorHex"
[name]="'variant-color-hex-' + index"
placeholder="#1A1A1A"
(blur)="onColorHexBlur(variant)"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">Materiale interno</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="variant.internalMaterialCode"
[name]="'variant-material-' + index"
placeholder="PLA, PETG, TPU"
/>
</label> </label>
<label class="ui-form-field"> <label class="ui-form-field">
@@ -807,8 +995,8 @@
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
[(ngModel)]="variant.priceChf" [(ngModel)]="material.priceChf"
[name]="'variant-price-' + index" [name]="'material-price-' + index"
/> />
</label> </label>
@@ -817,18 +1005,42 @@
<input <input
class="ui-form-control" class="ui-form-control"
type="number" type="number"
[(ngModel)]="variant.sortOrder" [(ngModel)]="material.sortOrder"
[name]="'variant-sort-order-' + index" [name]="'material-sort-order-' + index"
/> />
</label> </label>
<label class="ui-form-field" *ngIf="material.isDefault">
<span class="ui-form-caption">Colore default</span>
<select
class="ui-form-control"
[(ngModel)]="material.defaultColorKey"
[name]="'material-default-color-' + index"
[disabled]="materialColorCount(material.materialCode) === 0"
>
<option [ngValue]="''" disabled>
Seleziona colore default
</option>
<option
*ngFor="
let option of materialColorOptions(
material.materialCode
)
"
[ngValue]="option.key"
>
{{ option.label }}
</option>
</select>
</label>
<div class="toggle-row toggle-row--compact"> <div class="toggle-row toggle-row--compact">
<label class="ui-checkbox"> <label class="ui-checkbox">
<input <input
type="checkbox" type="checkbox"
[(ngModel)]="variant.isDefault" [(ngModel)]="material.isDefault"
[name]="'variant-default-' + index" [name]="'material-default-' + index"
(ngModelChange)="setDefaultVariant(index)" (ngModelChange)="setDefaultMaterial(index)"
/> />
<span class="ui-checkbox__mark" aria-hidden="true"></span> <span class="ui-checkbox__mark" aria-hidden="true"></span>
<span>Default</span> <span>Default</span>
@@ -837,19 +1049,34 @@
<label class="ui-checkbox"> <label class="ui-checkbox">
<input <input
type="checkbox" type="checkbox"
[(ngModel)]="variant.isActive" [(ngModel)]="material.isActive"
[name]="'variant-active-' + index" [name]="'material-active-' + index"
/> />
<span class="ui-checkbox__mark" aria-hidden="true"></span> <span class="ui-checkbox__mark" aria-hidden="true"></span>
<span>Attiva</span> <span>Attiva</span>
</label> </label>
</div> </div>
</div> </div>
<div class="material-stock-summary" *ngIf="material.materialCode">
<strong>Colori disponibili:</strong>
<span *ngIf="materialColorCount(material.materialCode) > 0">
{{ materialColorPreview(material.materialCode).join(", ") }}
<ng-container
*ngIf="materialColorCount(material.materialCode) > 6"
>
+{{ materialColorCount(material.materialCode) - 6 }} altri
</ng-container>
</span>
<span *ngIf="materialColorCount(material.materialCode) === 0">
Nessun colore disponibile attualmente a stock.
</span>
</div>
</article> </article>
</div> </div>
</section> </section>
<section class="form-section"> <section class="form-section ui-subpanel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div>
<h3>Immagini e modello 3D</h3> <h3>Immagini e modello 3D</h3>
@@ -914,15 +1141,30 @@
<button <button
*ngFor="let language of mediaLanguages" *ngFor="let language of mediaLanguages"
type="button" type="button"
class="ui-language-toolbar__button" class="ui-language-toolbar__button image-language-button"
[class.active]=" [class.active]="
imageUploadState.activeLanguage === language imageUploadState.activeLanguage === language
" "
[class.complete]="isImageLanguageComplete(language)" [class.complete]="isImageLanguageComplete(language)"
[class.incomplete]="!isImageLanguageComplete(language)" [class.incomplete]="isImageLanguageIncomplete(language)"
[class.empty]="!isImageLanguageStarted(language)"
(click)="setActiveImageLanguage(language)" (click)="setActiveImageLanguage(language)"
> >
{{ languageLabels[language] }} <span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isImageLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isImageLanguageIncomplete(language)"
>
...
</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,62 @@
gap: var(--space-5); gap: var(--space-5);
} }
.admin-shop .image-language-button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 3.15rem;
background: #ffffff;
color: var(--color-text-muted);
}
.admin-shop .image-language-button.empty {
opacity: 0.76;
}
.admin-shop .image-language-button.complete {
border-color: #b8ddc2;
}
.admin-shop .image-language-button.incomplete {
border-color: #e8c8c2;
}
.admin-shop .image-language-button.active {
background: #fff5b8;
border-color: var(--color-brand);
color: var(--color-text);
opacity: 1;
}
.image-language-button__label {
line-height: 1;
}
.image-language-button__state {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 0.2rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
font-size: 0.62rem;
font-weight: 800;
line-height: 1;
}
.admin-shop .image-language-button.complete .image-language-button__state {
background: #dcefdc;
color: #25603b;
}
.admin-shop .image-language-button.incomplete .image-language-button__state {
background: #f7ddd7;
color: #944329;
}
.section-header { .section-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -24,13 +80,6 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.workspace { .workspace {
align-items: start; align-items: start;
} }
@@ -61,7 +110,6 @@
.category-manager, .category-manager,
.category-editor, .category-editor,
.detail-stack, .detail-stack,
.form-section,
.variant-stack, .variant-stack,
.image-stack, .image-stack,
.media-grid, .media-grid,
@@ -71,6 +119,12 @@
gap: var(--space-3); gap: var(--space-3);
} }
.form-section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.panel-heading { .panel-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -191,6 +245,62 @@
min-height: 136px; min-height: 136px;
} }
.rich-text-field {
display: grid;
gap: var(--space-2);
}
.rich-text-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-shop .rich-text-toolbar__button {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-sm) !important;
background: #fff !important;
color: var(--color-text) !important;
min-height: 2rem;
padding: 0.28rem 0.56rem !important;
font-size: 0.82rem;
}
.admin-shop .rich-text-toolbar__button:hover:not(:disabled) {
border-color: #cbb88a !important;
background: #fffdf4 !important;
}
.rich-text-editor {
overflow-y: auto;
padding: 0.62rem 0.72rem;
line-height: 1.62;
}
.rich-text-editor:focus-visible {
outline: none;
}
.rich-text-editor:empty::before {
content: "Scrivi la descrizione del prodotto...";
color: var(--color-text-muted);
}
.rich-text-editor p,
.rich-text-editor div {
margin: 0 0 0.62rem;
}
.rich-text-editor ul,
.rich-text-editor ol {
margin: 0 0 0.62rem 1.25rem;
padding: 0;
}
.rich-text-editor li + li {
margin-top: 0.22rem;
}
.toggle-row { .toggle-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -211,6 +321,17 @@
gap: var(--space-2); gap: var(--space-2);
} }
.seo-counter {
justify-self: end;
font-size: 0.72rem;
font-weight: 700;
color: var(--color-text-muted);
}
.seo-counter--danger {
color: var(--color-danger-500);
}
.product-cell { .product-cell {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -224,10 +345,6 @@ tbody tr.selected {
background: #fff4c0; background: #fff4c0;
} }
.detail-panel {
gap: var(--space-4);
}
.detail-panel .ui-meta-item { .detail-panel .ui-meta-item {
padding: var(--space-2); padding: var(--space-2);
} }
@@ -277,15 +394,16 @@ tbody tr.selected {
} }
.detail-stack { .detail-stack {
gap: var(--space-4); gap: var(--space-3);
} }
.variant-card, .variant-card,
.image-item { .image-item {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: linear-gradient(180deg, #fcfcfb 0%, #ffffff 100%); background: var(--color-bg-card);
padding: var(--space-3); padding: var(--space-3);
box-shadow: var(--shadow-sm);
} }
.variant-card { .variant-card {
@@ -316,6 +434,19 @@ tbody tr.selected {
justify-content: flex-end; justify-content: flex-end;
} }
.material-stock-summary {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: baseline;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.material-stock-summary strong {
color: var(--color-text);
}
.locked-panel, .locked-panel,
.loading-state, .loading-state,
.image-fallback { .image-fallback {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -30,9 +30,25 @@ export interface AdminShopCategory {
parentCategoryName: string | null; parentCategoryName: string | null;
slug: string; slug: string;
name: string; name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description: string | null; description: string | null;
descriptionIt: string | null;
descriptionEn: string | null;
descriptionDe: string | null;
descriptionFr: string | null;
seoTitle: string | null; seoTitle: string | null;
seoTitleIt: string | null;
seoTitleEn: string | null;
seoTitleDe: string | null;
seoTitleFr: string | null;
seoDescription: string | null; seoDescription: string | null;
seoDescriptionIt: string | null;
seoDescriptionEn: string | null;
seoDescriptionDe: string | null;
seoDescriptionFr: string | null;
ogTitle: string | null; ogTitle: string | null;
ogDescription: string | null; ogDescription: string | null;
indexable: boolean; indexable: boolean;
@@ -54,9 +70,25 @@ export interface AdminUpsertShopCategoryPayload {
parentCategoryId?: string | null; parentCategoryId?: string | null;
slug: string; slug: string;
name: string; name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description?: string; description?: string;
descriptionIt?: string;
descriptionEn?: string;
descriptionDe?: string;
descriptionFr?: string;
seoTitle?: string; seoTitle?: string;
seoTitleIt?: string;
seoTitleEn?: string;
seoTitleDe?: string;
seoTitleFr?: string;
seoDescription?: string; seoDescription?: string;
seoDescriptionIt?: string;
seoDescriptionEn?: string;
seoDescriptionDe?: string;
seoDescriptionFr?: string;
ogTitle?: string; ogTitle?: string;
ogDescription?: string; ogDescription?: string;
indexable: boolean; indexable: boolean;
@@ -69,6 +101,10 @@ export interface AdminShopProductVariant {
sku: string | null; sku: string | null;
variantLabel: string; variantLabel: string;
colorName: string; colorName: string;
colorLabelIt: string;
colorLabelEn: string;
colorLabelDe: string;
colorLabelFr: string;
colorHex: string | null; colorHex: string | null;
internalMaterialCode: string; internalMaterialCode: string;
priceChf: number; priceChf: number;
@@ -131,7 +167,15 @@ export interface AdminShopProduct {
descriptionDe: string | null; descriptionDe: string | null;
descriptionFr: string | null; descriptionFr: string | null;
seoTitle: string | null; seoTitle: string | null;
seoTitleIt: string | null;
seoTitleEn: string | null;
seoTitleDe: string | null;
seoTitleFr: string | null;
seoDescription: string | null; seoDescription: string | null;
seoDescriptionIt: string | null;
seoDescriptionEn: string | null;
seoDescriptionDe: string | null;
seoDescriptionFr: string | null;
ogTitle: string | null; ogTitle: string | null;
ogDescription: string | null; ogDescription: string | null;
indexable: boolean; indexable: boolean;
@@ -162,6 +206,10 @@ export interface AdminUpsertShopProductVariantPayload {
sku?: string; sku?: string;
variantLabel?: string; variantLabel?: string;
colorName: string; colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
colorHex?: string; colorHex?: string;
internalMaterialCode: string; internalMaterialCode: string;
priceChf: number; priceChf: number;
@@ -189,7 +237,15 @@ export interface AdminUpsertShopProductPayload {
descriptionDe?: string; descriptionDe?: string;
descriptionFr?: string; descriptionFr?: string;
seoTitle?: string; seoTitle?: string;
seoTitleIt?: string;
seoTitleEn?: string;
seoTitleDe?: string;
seoTitleFr?: string;
seoDescription?: string; seoDescription?: string;
seoDescriptionIt?: string;
seoDescriptionEn?: string;
seoDescriptionDe?: string;
seoDescriptionFr?: string;
ogTitle?: string; ogTitle?: string;
ogDescription?: string; ogDescription?: string;
indexable: boolean; indexable: boolean;

View File

@@ -4,9 +4,12 @@ import {
signal, signal,
ViewChild, ViewChild,
ElementRef, ElementRef,
Inject,
OnInit, OnInit,
Optional,
PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
@@ -53,6 +56,7 @@ type TrackedPrintSettings = {
styleUrl: './calculator-page.component.scss', styleUrl: './calculator-page.component.scss',
}) })
export class CalculatorPageComponent implements OnInit { export class CalculatorPageComponent implements OnInit {
private readonly isBrowser: boolean;
mode = signal<'easy' | 'advanced'>('easy'); mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
@@ -85,7 +89,10 @@ export class CalculatorPageComponent implements OnInit {
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private languageService: LanguageService, private languageService: LanguageService,
) {} @Optional() @Inject(PLATFORM_ID) platformId?: Object,
) {
this.isBrowser = isPlatformBrowser(platformId ?? 'browser');
}
ngOnInit() { ngOnInit() {
this.route.data.subscribe((data) => { this.route.data.subscribe((data) => {
@@ -260,7 +267,7 @@ export class CalculatorPageComponent implements OnInit {
// Auto-scroll on mobile to make analysis visible // Auto-scroll on mobile to make analysis visible
setTimeout(() => { setTimeout(() => {
if (this.resultCol && window.innerWidth < 768) { if (this.isBrowser && this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({ this.resultCol.nativeElement.scrollIntoView({
behavior: 'smooth', behavior: 'smooth',
block: 'start', block: 'start',

View File

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

View File

@@ -1,5 +1,5 @@
<div class="checkout-page"> <div class="checkout-page">
<div class="container ui-page-hero"> <div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1> <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 Servizio CAD
@@ -265,14 +265,16 @@
</span> </span>
<span *ngIf="itemVariantLabel(item) as variantLabel"> <span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ "SHOP.VARIANT" | translate }}:
{{ variantLabel }} {{ variantLabel | translate }}
</span> </span>
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'"> <span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
<span <span
class="color-dot" class="color-dot"
[style.background-color]="itemColorSwatch(item)" [style.background-color]="itemColorSwatch(item)"
></span> ></span>
<span class="color-name">{{ itemColorLabel(item) }}</span> <span class="color-name">{{
itemColorLabel(item) | translate
}}</span>
</span> </span>
</div> </div>
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)"> <div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">

View File

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

View File

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

View File

@@ -1,5 +1,12 @@
import { Component, signal, effect, inject, OnDestroy } from '@angular/core'; import {
import { CommonModule } from '@angular/common'; Component,
signal,
effect,
inject,
OnDestroy,
PLATFORM_ID,
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { import {
ReactiveFormsModule, ReactiveFormsModule,
FormBuilder, FormBuilder,
@@ -16,6 +23,7 @@ import {
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
import { QuoteRequestService } from '../../../../core/services/quote-request.service'; import { QuoteRequestService } from '../../../../core/services/quote-request.service';
import { LanguageService } from '../../../../core/services/language.service'; import { LanguageService } from '../../../../core/services/language.service';
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
interface FilePreview { interface FilePreview {
file: File; file: File;
@@ -23,8 +31,6 @@ interface FilePreview {
type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other'; type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other';
} }
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
@Component({ @Component({
selector: 'app-contact-form', selector: 'app-contact-form',
standalone: true, standalone: true,
@@ -41,6 +47,7 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta
styleUrl: './contact-form.component.scss', styleUrl: './contact-form.component.scss',
}) })
export class ContactFormComponent implements OnDestroy { export class ContactFormComponent implements OnDestroy {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
form: FormGroup; form: FormGroup;
sent = signal(false); sent = signal(false);
files = signal<FilePreview[]>([]); files = signal<FilePreview[]>([]);
@@ -127,9 +134,10 @@ export class ContactFormComponent implements OnDestroy {
return { return {
file: f, file: f,
type, type,
url: this.shouldCreatePreview(type) url:
? URL.createObjectURL(f) this.isBrowser && this.shouldCreatePreview(type)
: undefined, ? URL.createObjectURL(f)
: undefined,
}; };
}); });
this.files.set(filePreviews); this.files.set(filePreviews);
@@ -185,9 +193,10 @@ export class ContactFormComponent implements OnDestroy {
const preview: FilePreview = { const preview: FilePreview = {
file, file,
type, type,
url: this.shouldCreatePreview(type) url:
? URL.createObjectURL(file) this.isBrowser && this.shouldCreatePreview(type)
: undefined, ? URL.createObjectURL(file)
: undefined,
}; };
this.files.update((files) => [...files, preview]); this.files.update((files) => [...files, preview]);
}); });
@@ -354,6 +363,9 @@ export class ContactFormComponent implements OnDestroy {
} }
private revokePreviewUrl(file: FilePreview): void { private revokePreviewUrl(file: FilePreview): void {
if (!this.isBrowser) {
return;
}
if (file.url?.startsWith('blob:')) { if (file.url?.startsWith('blob:')) {
URL.revokeObjectURL(file.url); URL.revokeObjectURL(file.url);
} }

View File

@@ -1,6 +1,8 @@
<main class="home-page"> <main class="home-page">
<section class="hero"> <section class="hero">
<div class="container hero-grid ui-content-grid ui-content-grid--spacious"> <div
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
>
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p> <p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
<h1 <h1
@@ -25,6 +27,30 @@
}}</app-button> }}</app-button>
</div> </div>
</div> </div>
<aside class="hero-swiss-card">
<div class="hero-swiss-head">
<span class="hero-swiss-emblem" aria-hidden="true">
<span class="hero-swiss-cross"></span>
</span>
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
{{ "HOME.HERO_SWISS_TITLE" | translate }}
</p>
</div>
<p class="hero-swiss-copy">
{{ "HOME.HERO_SWISS_COPY" | translate }}
</p>
<div class="hero-swiss-locations">
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_1" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_2" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_3" | translate
}}</span>
</div>
</aside>
</div> </div>
</section> </section>

View File

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

View File

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

View File

@@ -1,3 +1,7 @@
.order-hero {
padding-top: calc(var(--space-12) + var(--space-5));
}
.subtitle { .subtitle {
font-size: 1.125rem; font-size: 1.125rem;
} }
@@ -6,6 +10,11 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.payment-layout--summary-only {
grid-template-columns: minmax(0, 440px);
justify-content: center;
}
.payment-details { .payment-details {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
@@ -115,9 +124,52 @@
top: var(--space-6); top: var(--space-6);
} }
.payment-summary {
display: grid;
gap: var(--space-6);
}
.summary-items-section {
margin-top: var(--space-6);
padding-top: var(--space-5);
border-top: 1px solid var(--color-border);
}
.summary-items-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
h4 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(16, 24, 32, 0.06);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 700;
}
}
.order-items { .order-items {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-2);
max-height: 420px;
overflow-y: auto;
padding-right: var(--space-1);
scrollbar-width: thin;
} }
.order-item { .order-item {
@@ -125,7 +177,7 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3); padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg-card); background: var(--color-bg-card);
@@ -145,7 +197,7 @@
} }
.order-item-name { .order-item-name {
font-size: 1rem; font-size: 0.96rem;
line-height: 1.35; line-height: 1.35;
} }
@@ -172,7 +224,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem 0.9rem; gap: 0.5rem 0.9rem;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.92rem; font-size: 0.88rem;
} }
.item-color-chip { .item-color-chip {
@@ -190,13 +242,13 @@
} }
.order-item-tech { .order-item-tech {
font-size: 0.86rem; font-size: 0.82rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.order-item-total { .order-item-total {
white-space: nowrap; white-space: nowrap;
font-size: 1rem; font-size: 0.96rem;
} }
.order-summary-meta { .order-summary-meta {
@@ -244,16 +296,17 @@
.status-timeline { .status-timeline {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: var(--space-8); margin-top: var(--space-5);
margin-bottom: var(--space-10);
padding: 0 var(--space-2);
position: relative; position: relative;
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 15px; top: 15px;
left: 12.5%; left: calc(12.5% + var(--space-2));
right: 12.5%; right: calc(12.5% + var(--space-2));
height: 2px; height: 2px;
background: var(--color-border); background: var(--color-border);
z-index: 1; z-index: 1;
@@ -279,13 +332,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 600;
margin-bottom: var(--space-2); margin-bottom: var(--space-3);
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.label { .label {
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.2;
color: var(--color-text-muted); color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
} }
@@ -315,7 +369,18 @@
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.order-hero {
padding-top: calc(var(--space-8) + var(--space-4));
}
.payment-layout--summary-only {
grid-template-columns: 1fr;
}
.status-timeline { .status-timeline {
margin-top: var(--space-4);
margin-bottom: var(--space-8);
padding: 0;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: var(--space-4); gap: var(--space-4);
@@ -349,4 +414,10 @@
.order-summary-meta { .order-summary-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.order-items {
max-height: none;
overflow: visible;
padding-right: 0;
}
} }

View File

@@ -1,11 +1,15 @@
import { Component, OnInit, inject, signal } from '@angular/core'; import { Component, OnInit, PLATFORM_ID, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
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 { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
import { import {
PriceBreakdownComponent, PriceBreakdownComponent,
PriceBreakdownRow, PriceBreakdownRow,
@@ -25,9 +29,17 @@ interface PublicOrderItem {
shopProductName?: string; shopProductName?: string;
shopVariantLabel?: string; shopVariantLabel?: string;
shopVariantColorName?: string; shopVariantColorName?: string;
shopVariantColorLabelIt?: string;
shopVariantColorLabelEn?: string;
shopVariantColorLabelDe?: string;
shopVariantColorLabelFr?: string;
shopVariantColorHex?: string; shopVariantColorHex?: string;
filamentVariantDisplayName?: string; filamentVariantDisplayName?: string;
filamentColorName?: string; filamentColorName?: string;
filamentColorLabelIt?: string;
filamentColorLabelEn?: string;
filamentColorLabelDe?: string;
filamentColorLabelFr?: string;
filamentColorHex?: string; filamentColorHex?: string;
quality?: string; quality?: string;
nozzleDiameterMm?: number; nozzleDiameterMm?: number;
@@ -75,6 +87,7 @@ export class OrderComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private quoteService = inject(QuoteEstimatorService); private quoteService = inject(QuoteEstimatorService);
private translate = inject(TranslateService); private translate = inject(TranslateService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
orderId: string | null = null; orderId: string | null = null;
selectedPaymentMethod: 'twint' | 'bill' | null = 'twint'; selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
@@ -115,6 +128,9 @@ export class OrderComponent implements OnInit {
} }
downloadQrInvoice() { downloadQrInvoice() {
if (!this.isBrowser) {
return;
}
const orderId = this.orderId; const orderId = this.orderId;
if (!orderId) return; if (!orderId) return;
this.quoteService.getOrderConfirmation(orderId).subscribe({ this.quoteService.getOrderConfirmation(orderId).subscribe({
@@ -153,7 +169,7 @@ export class OrderComponent implements OnInit {
openTwintPayment(): void { openTwintPayment(): void {
const openUrl = this.twintOpenUrl(); const openUrl = this.twintOpenUrl();
if (typeof window !== 'undefined' && openUrl) { if (this.isBrowser && openUrl) {
window.open(openUrl, '_blank'); window.open(openUrl, '_blank');
} }
} }
@@ -274,23 +290,16 @@ export class OrderComponent implements OnInit {
return variantLabel; return variantLabel;
} }
const colorName = String(item?.shopVariantColorName ?? '').trim(); return this.localizedColorLabel(item, 'shop');
return colorName || null;
} }
itemColorLabel(item: PublicOrderItem): string { itemColorLabel(item: PublicOrderItem): string {
const shopColor = String(item?.shopVariantColorName ?? '').trim(); return (
if (shopColor) { this.localizedColorLabel(item, 'shop') ||
return shopColor; this.localizedColorLabel(item, 'filament') ||
} String(item?.colorCode ?? '').trim() ||
this.translate.instant('ORDER.NOT_AVAILABLE')
const filamentColor = String(item?.filamentColorName ?? '').trim(); );
if (filamentColor) {
return filamentColor;
}
const rawColor = String(item?.colorCode ?? '').trim();
return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE');
} }
itemColorHex(item: PublicOrderItem): string | null { itemColorHex(item: PublicOrderItem): string | null {
@@ -309,7 +318,7 @@ export class OrderComponent implements OnInit {
return rawColor; return rawColor;
} }
return null; return findColorHex(rawColor);
} }
showItemMaterial(item: PublicOrderItem): boolean { showItemMaterial(item: PublicOrderItem): boolean {
@@ -320,6 +329,29 @@ export class OrderComponent implements OnInit {
return !this.isShopItem(item); return !this.isShopItem(item);
} }
private localizedColorLabel(
item: PublicOrderItem,
source: 'shop' | 'filament',
): string | null {
if (source === 'shop') {
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.shopVariantColorName,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
});
}
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.filamentColorName ?? item.colorCode,
it: item.filamentColorLabelIt,
en: item.filamentColorLabelEn,
de: item.filamentColorLabelDe,
fr: item.filamentColorLabelFr,
});
}
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
const items = order?.items ?? []; const items = order?.items ?? [];
const hasShop = items.some((item) => this.isShopItem(item)); const hasShop = items.some((item) => this.isShopItem(item));

View File

@@ -1,5 +1,5 @@
<article class="product-card"> <article class="product-card">
<a class="media" [routerLink]="productLink()"> <a class="media" [routerLink]="productLink()" [state]="navigationState()">
@if (imageUrl(); as imageUrl) { @if (imageUrl(); as imageUrl) {
<img <img
[src]="imageUrl" [src]="imageUrl"
@@ -13,6 +13,11 @@
} }
<div class="card-badges"> <div class="card-badges">
@if (hasModelPreview()) {
<span class="badge badge-model">
{{ "SHOP.MODEL_3D" | translate }}
</span>
}
@if (cartQuantity() > 0) { @if (cartQuantity() > 0) {
<span class="badge badge-cart"> <span class="badge badge-cart">
{{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }} {{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }}
@@ -24,13 +29,12 @@
<div class="content"> <div class="content">
<div class="meta"> <div class="meta">
<span class="category">{{ product().category.name }}</span> <span class="category">{{ product().category.name }}</span>
@if (product().model3d) {
<span class="model-pill">{{ "SHOP.MODEL_3D" | translate }}</span>
}
</div> </div>
<h3 class="name"> <h3 class="name">
<a [routerLink]="productLink()">{{ product().name }}</a> <a [routerLink]="productLink()" [state]="navigationState()">{{
product().name
}}</a>
</h3> </h3>
<p class="excerpt"> <p class="excerpt">
@@ -45,9 +49,23 @@
} }
</div> </div>
<a [routerLink]="productLink()" class="view-btn">{{ <div class="footer-actions">
"SHOP.DETAILS" | translate <button
}}</a> type="button"
class="cart-btn"
(click)="addToCart()"
[disabled]="!defaultVariantId() || addingToCart()"
>
{{ (addingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART") | translate }}
</button>
<a
[routerLink]="productLink()"
[state]="navigationState()"
class="view-btn"
>{{ "SHOP.DETAILS" | translate }}</a
>
</div>
</div> </div>
</div> </div>
</article> </article>

View File

@@ -21,8 +21,9 @@
.media { .media {
position: relative; position: relative;
display: block; display: block;
min-height: 244px; aspect-ratio: 4 / 3;
background: #f2eee5; background: #f2eee5;
overflow: hidden;
} }
.media img { .media img {
@@ -35,7 +36,6 @@
.image-fallback { .image-fallback {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 244px;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
padding: var(--space-5); padding: var(--space-5);
@@ -87,6 +87,12 @@
color: #fff; color: #fff;
} }
.badge-model {
background: rgba(255, 255, 255, 0.92);
color: var(--color-text-muted);
border: 1px solid rgba(16, 24, 32, 0.08);
}
.content { .content {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
@@ -100,8 +106,7 @@
gap: 0.55rem; gap: 0.55rem;
} }
.category, .category {
.model-pill {
font-size: 0.74rem; font-size: 0.74rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -112,15 +117,6 @@
font-weight: 700; font-weight: 700;
} }
.model-pill {
display: inline-flex;
padding: 0.18rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(16, 24, 32, 0.08);
color: var(--color-text-muted);
background: rgba(255, 255, 255, 0.72);
}
.name { .name {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
@@ -135,7 +131,13 @@
.excerpt { .excerpt {
margin: 0; margin: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
line-height: 1.55; line-height: 1.45;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
} }
.footer { .footer {
@@ -145,6 +147,12 @@
gap: var(--space-4); gap: var(--space-4);
} }
.footer-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.pricing { .pricing {
display: grid; display: grid;
gap: 0.1rem; gap: 0.1rem;
@@ -160,27 +168,49 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.cart-btn,
.view-btn { .view-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
min-height: 2.35rem; min-height: 2.35rem;
padding: 0 0.9rem; padding: 0 0.9rem;
border-radius: 999px; border-radius: 999px;
background: rgba(16, 24, 32, 0.06);
color: var(--color-neutral-900);
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
border: 1px solid transparent;
}
.cart-btn {
background: var(--color-brand);
color: var(--color-neutral-900);
border-color: color-mix(in srgb, var(--color-brand) 72%, #d5ac00);
cursor: pointer;
}
.cart-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.view-btn {
background: rgba(16, 24, 32, 0.06);
color: var(--color-neutral-900);
text-decoration: none; text-decoration: none;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.media,
.image-fallback {
min-height: 220px;
}
.footer { .footer {
align-items: start; align-items: start;
flex-direction: column; flex-direction: column;
} }
.footer-actions {
width: 100%;
}
.cart-btn,
.view-btn {
flex: 1;
}
} }

View File

@@ -1,8 +1,10 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { finalize } from 'rxjs';
import { ShopProductSummary, ShopService } from '../../services/shop.service'; import { ShopProductSummary, ShopService } from '../../services/shop.service';
import { ShopRouteService } from '../../services/shop-route.service';
@Component({ @Component({
selector: 'app-product-card', selector: 'app-product-card',
@@ -12,16 +14,17 @@ import { ShopProductSummary, ShopService } from '../../services/shop.service';
styleUrl: './product-card.component.scss', styleUrl: './product-card.component.scss',
}) })
export class ProductCardComponent { export class ProductCardComponent {
private readonly router = inject(Router);
private readonly shopService = inject(ShopService); private readonly shopService = inject(ShopService);
private readonly shopRouteService = inject(ShopRouteService);
readonly product = input.required<ShopProductSummary>(); readonly product = input.required<ShopProductSummary>();
readonly cartQuantity = input(0); readonly cartQuantity = input(0);
readonly addingToCart = signal(false);
readonly productLink = computed(() => [ readonly productLink = computed(() =>
'/shop', this.shopRouteService.productCommands(this.product()),
this.product().category.slug, );
this.product().slug,
]);
readonly imageUrl = computed(() => { readonly imageUrl = computed(() => {
const image = this.product().primaryImage; const image = this.product().primaryImage;
@@ -32,6 +35,11 @@ export class ProductCardComponent {
); );
}); });
readonly hasModelPreview = computed(() => {
const model = this.product().model3d;
return !!(model?.url && model.originalFilename);
});
priceLabel(): number { priceLabel(): number {
return this.product().priceFromChf; return this.product().priceFromChf;
} }
@@ -39,4 +47,31 @@ export class ProductCardComponent {
hasPriceRange(): boolean { hasPriceRange(): boolean {
return this.product().priceFromChf !== this.product().priceToChf; return this.product().priceFromChf !== this.product().priceToChf;
} }
defaultVariantId(): string | null {
return this.product().defaultVariant?.id ?? null;
}
addToCart(): void {
const variantId = this.defaultVariantId();
if (!variantId || this.addingToCart()) {
return;
}
this.addingToCart.set(true);
this.shopService
.addToCart(variantId, 1)
.pipe(finalize(() => this.addingToCart.set(false)))
.subscribe({
error: () => {
// Keep card UX simple: product detail handles error messaging in depth.
},
});
}
navigationState(): { shopReturnUrl: string } {
return {
shopReturnUrl: this.router.url,
};
}
} }

View File

@@ -1,8 +1,8 @@
<section class="product-page"> <section class="product-page">
<div class="container wrapper"> <div class="container wrapper">
<a [routerLink]="productLinkRoot()" class="back-link"> <button type="button" class="back-link" (click)="goBackToShop()">
← {{ "SHOP.BACK" | translate }} ← {{ "SHOP.BACK" | translate }}
</a> </button>
@if (loading()) { @if (loading()) {
<div class="detail-grid skeleton-grid"> <div class="detail-grid skeleton-grid">
@@ -15,16 +15,41 @@
} @else { } @else {
@if (product(); as p) { @if (product(); as p) {
<nav class="breadcrumbs"> <nav class="breadcrumbs">
<a routerLink="/shop">{{ "SHOP.BREADCRUMB_ROOT" | translate }}</a> <a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
"SHOP.BREADCRUMB_ROOT" | translate
}}</a>
@for (crumb of p.breadcrumbs; track crumb.id) { @for (crumb of p.breadcrumbs; track crumb.id) {
<span>/</span> <span class="breadcrumbs__separator">/</span>
<a [routerLink]="['/shop', crumb.slug]">{{ crumb.name }}</a> <a
class="breadcrumbs__item"
[routerLink]="categoryLink(crumb.slug)"
>{{ crumb.name }}</a
>
} }
</nav> </nav>
<div class="detail-grid"> <div class="detail-grid">
<section class="visual-column"> <section class="visual-column">
<div class="hero-media"> <div class="hero-media">
@if (galleryImages().length > 1) {
<button
type="button"
class="hero-nav hero-nav-prev"
(click)="showPreviousImage()"
[attr.aria-label]="'SHOP.PREVIOUS_IMAGE' | translate"
>
</button>
<button
type="button"
class="hero-nav hero-nav-next"
(click)="showNextImage()"
[attr.aria-label]="'SHOP.NEXT_IMAGE' | translate"
>
</button>
}
@if (imageUrl(selectedImage()); as imageUrl) { @if (imageUrl(selectedImage()); as imageUrl) {
<img <img
[src]="imageUrl" [src]="imageUrl"
@@ -39,7 +64,7 @@
</div> </div>
@if (galleryImages().length > 1) { @if (galleryImages().length > 1) {
<div class="thumb-grid"> <div class="thumb-strip">
@for (image of galleryImages(); track image.mediaAssetId) { @for (image of galleryImages(); track image.mediaAssetId) {
<button <button
type="button" type="button"
@@ -60,15 +85,12 @@
} }
@if (p.model3d) { @if (p.model3d) {
<app-card class="viewer-card"> <div class="model-launch-row">
<div class="viewer-head"> <div>
<div> <p class="viewer-kicker">
<p class="viewer-kicker"> {{ "SHOP.MODEL_3D" | translate }}
{{ "SHOP.MODEL_3D" | translate }} </p>
</p> <div class="dimensions dimensions-inline">
<h3>{{ "SHOP.MODEL_TITLE" | translate }}</h3>
</div>
<div class="dimensions">
<span> <span>
X X
{{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
@@ -83,25 +105,14 @@
</span> </span>
</div> </div>
</div> </div>
<button
@if (modelLoading()) { type="button"
<div class="viewer-state"> class="model-open-btn"
{{ "SHOP.MODEL_LOADING" | translate }} (click)="openModelModal()"
</div> >
} @else if (modelError()) { {{ "SHOP.MODEL_OPEN" | translate }}
<div class="viewer-state viewer-state-error"> </button>
{{ "SHOP.MODEL_UNAVAILABLE" | translate }} </div>
</div>
} @else {
@if (modelFile(); as modelPreviewFile) {
<app-stl-viewer
[file]="modelPreviewFile"
[height]="360"
[color]="selectedVariant()?.colorHex || '#facf0a'"
></app-stl-viewer>
}
}
</app-card>
} }
</section> </section>
@@ -114,21 +125,40 @@
<p class="excerpt"> <p class="excerpt">
{{ {{
p.excerpt || p.excerpt ||
p.description || descriptionPlainText(p.description) ||
("SHOP.EXCERPT_FALLBACK" | translate) ("SHOP.EXCERPT_FALLBACK" | translate)
}} }}
</p> </p>
</div> </div>
<app-card> <app-card class="purchase-shell">
<div class="purchase-card"> <div class="purchase-card">
<div class="price-row"> <div class="offer-header">
<div> <div class="offer-price">
<p class="panel-kicker"> <p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }} {{ "SHOP.PRICE_LABEL" | translate }}
</p> </p>
<h3>{{ priceLabel() | currency: "CHF" }}</h3> <h3>{{ priceLabel() | currency: "CHF" }}</h3>
@if (selectedVariant(); as activeVariant) {
<p class="offer-caption">
@if (selectedMaterial()?.label) {
<span>{{ selectedMaterial()?.label }}</span>
}
@if (
colorLabel(activeVariant) !==
selectedMaterial()?.label
) {
@if (selectedMaterial()?.label) {
<span aria-hidden="true">·</span>
}
<span>{{
colorLabel(activeVariant) | translate
}}</span>
}
</p>
}
</div> </div>
@if (selectedVariantCartQuantity() > 0) { @if (selectedVariantCartQuantity() > 0) {
<span class="cart-pill"> <span class="cart-pill">
{{ {{
@@ -140,47 +170,175 @@
} }
</div> </div>
<div class="variant-grid"> @if (materialOptions().length > 1) {
@for (variant of p.variants; track variant.id) { <div class="material-section">
<button <div class="selector-head">
type="button" <p class="panel-kicker">
class="variant-option" {{ "SHOP.SELECT_MATERIAL" | translate }}
[class.active]="selectedVariant()?.id === variant.id" </p>
(click)="selectVariant(variant)" </div>
>
<span
class="variant-swatch"
[style.background-color]="colorHex(variant)"
></span>
<span class="variant-copy">
<strong>{{ colorLabel(variant) }}</strong>
@if (variant.variantLabel) {
<small>{{ variant.variantLabel }}</small>
}
</span>
<strong>{{
variant.priceChf | currency: "CHF"
}}</strong>
</button>
}
</div>
<div class="quantity-row"> <div class="material-grid">
<span>{{ "SHOP.QUANTITY" | translate }}</span> @for (
<div class="qty-control"> material of materialOptions();
<button type="button" (click)="decreaseQuantity()"> track material.key
- ) {
</button> <button
<span>{{ quantity() }}</span> type="button"
<button type="button" (click)="increaseQuantity()"> class="material-option"
+ [class.active]="
</button> selectedMaterial()?.key === material.key
"
(click)="selectMaterial(material.key)"
>
<span class="material-copy">
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</span>
<strong>{{
materialPriceLabel(material) | currency: "CHF"
}}</strong>
</button>
}
</div>
</div>
} @else {
@if (selectedMaterial(); as material) {
<div class="material-summary">
<div class="material-summary__copy">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
</p>
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</div>
</div>
}
}
@if (
materialOptions().length > 1 &&
selectedMaterialProperties().length > 0
) {
<div class="property-grid">
@for (
property of selectedMaterialProperties();
track property.labelKey
) {
<div
class="property-pill"
[ngClass]="'tone-' + property.tone"
>
<span>{{ property.labelKey | translate }}</span>
<strong>{{ property.valueKey | translate }}</strong>
</div>
}
</div>
}
<div class="selector-layout">
<div class="selector-card color-selector-block">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }}
</p>
</div>
@if (selectedVariant(); as activeVariant) {
<button
type="button"
class="color-trigger"
[class.open]="colorPopupOpen()"
(click)="toggleColorPopup()"
>
<span class="color-trigger__ring">
<span
class="color-trigger__swatch"
[style.background-color]="colorHex(activeVariant)"
></span>
</span>
<span class="color-trigger__copy">
<strong>{{
colorLabel(activeVariant) | translate
}}</strong>
<small>{{ selectedMaterial()?.label }}</small>
</span>
</button>
}
@if (colorPopupOpen()) {
<button
type="button"
class="color-popup-backdrop"
(click)="closeColorPopup()"
></button>
<div class="color-popup">
<div class="color-popup__category">
{{ selectedMaterial()?.label || "" | uppercase }}
</div>
<div class="color-popup__grid">
@for (variant of colorOptions(); track variant.id) {
<button
type="button"
class="color-popup__item"
(click)="selectVariant(variant)"
>
<span
class="color-popup__ring"
[class.active]="
selectedVariant()?.id === variant.id
"
>
<span
class="color-popup__swatch"
[style.background-color]="colorHex(variant)"
></span>
</span>
<span class="color-popup__name">{{
colorLabel(variant) | translate
}}</span>
</button>
}
</div>
</div>
}
</div>
<div class="selector-card quantity-card">
<p class="panel-kicker">
{{ "SHOP.QUANTITY" | translate }}
</p>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">
-
</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="increaseQuantity()">
+
</button>
</div>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<app-button <app-button
variant="primary" variant="primary"
[fullWidth]="true"
[disabled]="isAddingToCart()" [disabled]="isAddingToCart()"
(click)="addToCart()" (click)="addToCart()"
> >
@@ -191,7 +349,11 @@
</app-button> </app-button>
@if (shopService.cartItemCount() > 0) { @if (shopService.cartItemCount() > 0) {
<app-button variant="outline" (click)="goToCheckout()"> <app-button
variant="outline"
[fullWidth]="true"
(click)="goToCheckout()"
>
{{ "SHOP.GO_TO_CHECKOUT" | translate }} {{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button> </app-button>
} }
@@ -205,14 +367,62 @@
</div> </div>
</app-card> </app-card>
@if (p.description) { @if (descriptionPlainText(p.description)) {
<div class="description-block"> <div class="description-block">
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2> <h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
<p>{{ p.description }}</p> <div
class="description-block__content"
[innerHTML]="descriptionRichHtml(p.description)"
></div>
</div> </div>
} }
</section> </section>
</div> </div>
@if (modelModalOpen()) {
<button
type="button"
class="model-modal-backdrop"
(click)="closeModelModal()"
></button>
<div class="model-modal" role="dialog" aria-modal="true">
<div class="model-modal-head">
<div>
<p class="viewer-kicker">{{ "SHOP.MODEL_3D" | translate }}</p>
<h3>{{ "SHOP.MODEL_TITLE" | translate }}</h3>
</div>
<button
type="button"
class="model-close-btn"
(click)="closeModelModal()"
[attr.aria-label]="'SHOP.MODEL_CLOSE' | translate"
>
×
</button>
</div>
<div class="model-modal-body">
@if (modelLoading()) {
<div class="viewer-state viewer-loading">
<span class="spinner"></span>
<span>{{ "SHOP.MODEL_LOADING" | translate }}</span>
</div>
} @else if (modelError()) {
<div class="viewer-state viewer-state-error">
{{ "SHOP.MODEL_UNAVAILABLE" | translate }}
</div>
} @else {
@if (modelFile(); as modelPreviewFile) {
<app-stl-viewer
[file]="modelPreviewFile"
[height]="420"
[color]="colorHex(selectedVariant())"
></app-stl-viewer>
}
}
</div>
</div>
}
} }
} }
} }

View File

@@ -1,11 +1,11 @@
.product-page { .product-page {
padding: var(--space-8) 0 var(--space-12); padding: var(--space-5) 0 var(--space-10);
background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 15rem); background: var(--color-bg);
} }
.wrapper { .wrapper {
display: grid; display: grid;
gap: var(--space-6); gap: var(--space-4);
} }
.back-link, .back-link,
@@ -13,17 +13,63 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.back-link {
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
cursor: pointer;
text-align: left;
}
.back-link:hover {
color: var(--color-text);
}
.breadcrumbs { .breadcrumbs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; align-items: center;
font-size: 0.9rem; gap: 0.35rem;
font-size: 0.82rem;
}
.breadcrumbs__item {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.26rem 0.7rem;
border-radius: 999px;
border: 1px solid rgba(16, 24, 32, 0.1);
background: rgba(255, 255, 255, 0.92);
color: var(--color-secondary-600);
font-weight: 600;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
.breadcrumbs__item:hover {
color: var(--color-text);
border-color: rgba(16, 24, 32, 0.18);
background: #fff;
text-decoration: none;
}
.breadcrumbs__separator {
color: rgba(81, 77, 67, 0.64);
font-weight: 700;
} }
.detail-grid { .detail-grid {
display: grid; display: grid;
gap: var(--space-8); gap: var(--space-8);
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
align-items: start;
} }
.visual-column, .visual-column,
@@ -32,13 +78,24 @@
gap: var(--space-5); gap: var(--space-5);
} }
.visual-column {
align-content: start;
}
.info-column {
gap: var(--space-3);
align-content: start;
}
.hero-media { .hero-media {
min-height: 480px; position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden; overflow: hidden;
border-radius: 1.25rem; border-radius: 1.25rem;
border: 1px solid rgba(16, 24, 32, 0.08); border: 1px solid rgba(16, 24, 32, 0.12);
background: #f2eee5; background: #f2eee5;
box-shadow: 0 12px 28px rgba(16, 24, 32, 0.05); box-shadow: 0 6px 14px rgba(16, 24, 32, 0.04);
} }
.hero-image { .hero-image {
@@ -46,12 +103,13 @@
height: 100%; height: 100%;
display: block; display: block;
object-fit: cover; object-fit: cover;
object-position: center;
background: #f2eee5;
} }
.image-fallback { .image-fallback {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 480px;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
padding: var(--space-6); padding: var(--space-6);
@@ -76,24 +134,31 @@
} }
.thumb-grid { .thumb-grid {
display: grid; display: contents;
gap: var(--space-3); }
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
.thumb-strip {
display: flex;
gap: 0.65rem;
overflow-x: auto;
padding: 0.1rem;
scrollbar-width: thin;
} }
.thumb { .thumb {
min-height: 92px; flex: 0 0 96px;
aspect-ratio: 4 / 3;
overflow: hidden; overflow: hidden;
border-radius: 0.85rem; border-radius: 0.85rem;
border: 1px solid var(--color-border); border: 1px solid rgba(16, 24, 32, 0.12);
background: rgba(255, 255, 255, 0.78); background: #fff;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
} }
.thumb.active { .thumb.active {
border-color: rgba(250, 207, 10, 0.65); border-color: rgba(250, 207, 10, 0.9);
box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.22); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.2);
} }
.thumb img, .thumb img,
@@ -105,15 +170,33 @@
object-fit: cover; object-fit: cover;
} }
.viewer-card { .model-launch-row {
display: block;
}
.viewer-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: 0.8rem;
margin-bottom: var(--space-4); align-items: center;
width: min(100%, 440px);
justify-self: start;
padding: 0.72rem 0.82rem;
border: 1px solid rgba(16, 24, 32, 0.12);
border-radius: 1rem;
background: #fff;
}
.model-open-btn {
height: 2.1rem;
padding: 0 0.82rem;
border-radius: 0.65rem;
border: 1px solid rgba(16, 24, 32, 0.18);
background: #fff;
color: var(--color-text);
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.model-open-btn:hover {
border-color: rgba(16, 24, 32, 0.3);
} }
.viewer-kicker, .viewer-kicker,
@@ -134,22 +217,16 @@
text-align: right; text-align: right;
} }
.viewer-state { .dimensions-inline {
display: grid; display: flex;
place-items: center; gap: 0.58rem;
min-height: 220px; text-align: left;
border-radius: var(--radius-lg); font-size: 0.78rem;
background: rgba(16, 24, 32, 0.04);
color: var(--color-text-muted);
}
.viewer-state-error {
color: var(--color-danger-600);
} }
.title-block { .title-block {
display: grid; display: grid;
gap: var(--space-3); gap: 0.4rem;
} }
.title-meta { .title-meta {
@@ -171,27 +248,47 @@
} }
h1 { h1 {
font-size: clamp(2rem, 2vw + 1.2rem, 3.2rem); margin: 0;
font-size: clamp(1.9rem, 1.45vw + 1.15rem, 2.8rem);
line-height: 1.06;
} }
.excerpt, .excerpt {
.description-block p {
margin: 0; margin: 0;
color: var(--color-text-muted); color: var(--color-text-muted);
line-height: 1.7; line-height: 1.6;
} }
.purchase-card { .purchase-card {
display: grid; display: grid;
gap: var(--space-5); gap: 1rem;
} }
.price-row, .offer-header {
.quantity-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
align-items: start;
}
.offer-price {
display: grid;
gap: 0.12rem;
}
.offer-price h3 {
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
line-height: 1;
}
.offer-caption {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.38rem;
color: var(--color-text-muted);
font-size: 0.9rem;
} }
.cart-pill { .cart-pill {
@@ -206,57 +303,151 @@ h1 {
font-weight: 600; font-weight: 600;
} }
.variant-grid { .material-section {
display: grid; display: grid;
gap: 0.7rem; gap: 0.65rem;
} }
.variant-option { .material-grid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.material-option {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
padding: 0.9rem 1rem; padding: 0.68rem 0.8rem;
border-radius: 1rem; border-radius: 0.85rem;
border: 1px solid var(--color-border); border: 1px solid rgba(16, 24, 32, 0.14);
background: rgba(255, 255, 255, 0.86); background: #fff;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: transition:
border-color 0.18s ease, border-color 0.18s ease,
transform 0.18s ease, background 0.18s ease,
box-shadow 0.18s ease; box-shadow 0.18s ease;
} }
.variant-option.active { .material-option:hover {
border-color: rgba(250, 207, 10, 0.6); border-color: rgba(16, 24, 32, 0.26);
box-shadow: 0 10px 24px rgba(16, 24, 32, 0.08); box-shadow: 0 2px 8px rgba(16, 24, 32, 0.06);
transform: translateY(-1px);
} }
.variant-swatch { .material-option.active {
width: 1.2rem; border-color: rgba(250, 207, 10, 0.95);
height: 1.2rem; background: rgba(250, 207, 10, 0.1);
border-radius: 50%; box-shadow: 0 0 0 1px rgba(250, 207, 10, 0.35);
border: 1px solid rgba(16, 24, 32, 0.12);
flex: 0 0 auto;
} }
.variant-copy { .material-copy {
display: grid; display: grid;
gap: 0.12rem; gap: 0.14rem;
flex: 1;
} }
.variant-copy small { .material-copy strong {
font-size: 0.95rem;
}
.material-copy small {
font-size: 0.82rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.material-option > strong {
font-size: 1.04rem;
}
.material-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: #fff;
}
.material-summary__copy {
display: grid;
gap: 0.16rem;
}
.material-summary__copy strong {
font-size: 1rem;
}
.material-summary__copy small {
color: var(--color-text-muted);
font-size: 0.84rem;
}
.property-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.property-pill {
display: grid;
gap: 0.16rem;
padding: 0.58rem 0.65rem;
border-radius: 0.8rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: #fff;
}
.property-pill span {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
}
.property-pill strong {
font-size: 0.9rem;
}
.property-pill.tone-strong {
border-left: 3px solid rgba(4, 120, 87, 0.7);
}
.property-pill.tone-neutral {
border-left: 3px solid rgba(37, 99, 235, 0.7);
}
.property-pill.tone-soft {
border-left: 3px solid rgba(245, 158, 11, 0.7);
}
.selector-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
gap: 0.75rem;
align-items: stretch;
}
.selector-card {
position: relative;
display: grid;
gap: 0.5rem;
padding: 0.82rem 0.9rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: rgba(255, 255, 255, 0.9);
height: 100%;
}
.qty-control { .qty-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
padding: 0.2rem; padding: 0.2rem;
min-height: 3.2rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.82);
@@ -278,10 +469,20 @@ h1 {
font-weight: 700; font-weight: 700;
} }
.quantity-card {
justify-items: start;
align-content: start;
grid-template-rows: auto 1fr;
}
.quantity-card .qty-control {
align-self: center;
}
.actions { .actions {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3); gap: 0.75rem;
} }
.success-note { .success-note {
@@ -292,11 +493,42 @@ h1 {
.description-block { .description-block {
display: grid; display: grid;
gap: var(--space-3); gap: 0.55rem;
} }
.description-block h2 { .description-block h2 {
font-size: 1.2rem; margin: 0;
font-size: 1.45rem;
}
.description-block__content {
color: var(--color-text-muted);
font-size: 1.06rem;
line-height: 1.75;
}
.description-block__content p,
.description-block__content div {
margin: 0 0 0.7rem;
}
.description-block__content p:last-child,
.description-block__content div:last-child {
margin-bottom: 0;
}
.description-block__content ul,
.description-block__content ol {
margin: 0 0 0.7rem 1.3rem;
padding: 0;
}
.description-block__content li + li {
margin-top: 0.22rem;
}
:host ::ng-deep app-card.purchase-shell .card-body {
padding: 0.95rem 1rem;
} }
.state-card, .state-card,
@@ -339,18 +571,69 @@ h1 {
.skeleton-grid { .skeleton-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.selector-layout {
grid-template-columns: 1fr;
}
.property-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.purchase-card {
gap: 0.82rem;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.viewer-head, .offer-header,
.price-row, .material-summary {
.quantity-row {
flex-direction: column; flex-direction: column;
align-items: start; align-items: start;
} }
.hero-media, .hero-media--portrait .hero-image {
.image-fallback { object-fit: cover;
min-height: 320px; }
.thumb-strip {
gap: 0.5rem;
}
.thumb {
flex-basis: 84px;
}
.model-launch-row {
width: min(100%, 350px);
gap: 0.6rem;
padding: 0.58rem 0.65rem;
}
.model-open-btn {
height: 1.95rem;
padding: 0 0.7rem;
font-size: 0.8rem;
}
.dimensions-inline {
gap: 0.42rem;
font-size: 0.74rem;
}
.property-grid {
grid-template-columns: 1fr;
}
.material-grid {
grid-template-columns: 1fr;
}
.selector-card {
padding: 0.74rem 0.78rem;
}
:host ::ng-deep app-card.purchase-shell .card-body {
padding: 0.82rem 0.82rem;
} }
} }

View File

@@ -1,8 +1,9 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { import {
Component, Component,
DestroyRef, DestroyRef,
Injector, Injector,
PLATFORM_ID,
computed, computed,
inject, inject,
input, input,
@@ -14,6 +15,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs'; import { catchError, combineLatest, finalize, 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 { 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';
@@ -22,6 +24,20 @@ import {
ShopProductVariantOption, ShopProductVariantOption,
ShopService, ShopService,
} from './services/shop.service'; } from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
interface ShopMaterialOption {
key: string;
label: string;
variants: ShopProductVariantOption[];
priceFromChf: number;
}
interface ShopMaterialProperty {
labelKey: string;
valueKey: string;
tone: 'neutral' | 'strong' | 'soft';
}
@Component({ @Component({
selector: 'app-product-detail', selector: 'app-product-detail',
@@ -38,12 +54,16 @@ import {
styleUrl: './product-detail.component.scss', styleUrl: './product-detail.component.scss',
}) })
export class ProductDetailComponent { export class ProductDetailComponent {
private static readonly HEX_COLOR_PATTERN =
/^#([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 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 isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly categorySlug = input<string | undefined>();
@@ -57,6 +77,9 @@ export class ProductDetailComponent {
readonly quantity = signal(1); readonly quantity = signal(1);
readonly isAddingToCart = signal(false); readonly isAddingToCart = signal(false);
readonly addSuccess = signal(false); readonly addSuccess = signal(false);
readonly selectedMaterialKey = signal<string | null>(null);
readonly colorPopupOpen = signal(false);
readonly modelModalOpen = signal(false);
readonly modelLoading = signal(false); readonly modelLoading = signal(false);
readonly modelError = signal(false); readonly modelError = signal(false);
@@ -76,6 +99,59 @@ export class ProductDetailComponent {
); );
}); });
readonly materialOptions = computed<ShopMaterialOption[]>(() => {
const product = this.product();
if (!product) {
return [];
}
const groups = new Map<string, ShopMaterialOption>();
for (const variant of product.variants) {
const label = this.materialLabelForVariant(variant);
const key = label.toLowerCase();
const group = groups.get(key);
if (group) {
group.variants.push(variant);
group.priceFromChf = Math.min(group.priceFromChf, variant.priceChf);
continue;
}
groups.set(key, {
key,
label,
variants: [variant],
priceFromChf: variant.priceChf,
});
}
return Array.from(groups.values());
});
readonly selectedMaterial = computed<ShopMaterialOption | null>(() => {
const selectedKey = this.selectedMaterialKey();
const materials = this.materialOptions();
if (!materials.length) {
return null;
}
return (
materials.find((material) => material.key === selectedKey) ??
materials.find((material) =>
material.variants.some(
(variant) => variant.id === this.selectedVariant()?.id,
),
) ??
materials[0]
);
});
readonly colorOptions = computed<ShopProductVariantOption[]>(
() => this.selectedMaterial()?.variants ?? [],
);
readonly selectedMaterialProperties = computed<ShopMaterialProperty[]>(() =>
this.materialPropertiesFor(this.selectedMaterial()?.label),
);
readonly galleryImages = computed(() => { readonly galleryImages = computed(() => {
const product = this.product(); const product = this.product();
if (!product) { if (!product) {
@@ -103,6 +179,15 @@ export class ProductDetailComponent {
); );
}); });
readonly selectedImageIndex = computed(() => {
const images = this.galleryImages();
const selectedAssetId = this.selectedImageAssetId();
const index = images.findIndex(
(image) => image.mediaAssetId === selectedAssetId,
);
return index >= 0 ? index : 0;
});
readonly selectedVariantCartQuantity = computed(() => readonly selectedVariantCartQuantity = computed(() =>
this.shopService.quantityForVariant(this.selectedVariant()?.id), this.shopService.quantityForVariant(this.selectedVariant()?.id),
); );
@@ -131,6 +216,8 @@ export class ProductDetailComponent {
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.modelModalOpen.set(false);
}), }),
switchMap(([productSlug]) => { switchMap(([productSlug]) => {
if (!productSlug) { if (!productSlug) {
@@ -139,11 +226,11 @@ export class ProductDetailComponent {
return of(null); return of(null);
} }
return this.shopService.getProduct(productSlug).pipe( return this.shopService.getProductByPublicPath(productSlug).pipe(
catchError((error) => { catchError((error) => {
this.product.set(null); this.product.set(null);
this.selectedVariantId.set(null); this.selectedVariantId.set(null);
this.selectedImageAssetId.set(null); this.setSelectedImageAssetId(null);
this.modelFile.set(null); this.modelFile.set(null);
this.error.set( this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
@@ -165,24 +252,22 @@ export class ProductDetailComponent {
this.selectedVariantId.set( this.selectedVariantId.set(
product.defaultVariant?.id ?? product.variants[0]?.id ?? null, product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
); );
this.selectedImageAssetId.set( this.selectedMaterialKey.set(
this.materialKeyForVariant(
product.defaultVariant ?? product.variants[0] ?? null,
),
);
this.setSelectedImageAssetId(
product.primaryImage?.mediaAssetId ?? product.primaryImage?.mediaAssetId ??
product.images[0]?.mediaAssetId ?? product.images[0]?.mediaAssetId ??
null, null,
); );
this.quantity.set(1); this.quantity.set(1);
this.syncPublicUrl(product);
this.applySeo(product); this.applySeo(product);
this.modelFile.set(null);
if (product.model3d?.url && product.model3d.originalFilename) { this.modelLoading.set(false);
this.loadModelPreview( this.modelError.set(false);
product.model3d.url,
product.model3d.originalFilename,
);
} else {
this.modelFile.set(null);
this.modelLoading.set(false);
this.modelError.set(false);
}
}); });
} }
@@ -198,11 +283,48 @@ export class ProductDetailComponent {
} }
selectImage(mediaAssetId: string): void { selectImage(mediaAssetId: string): void {
this.selectedImageAssetId.set(mediaAssetId); this.setSelectedImageAssetId(mediaAssetId);
}
showPreviousImage(): void {
const images = this.galleryImages();
if (images.length < 2) {
return;
}
const nextIndex =
(this.selectedImageIndex() - 1 + images.length) % images.length;
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
}
showNextImage(): void {
const images = this.galleryImages();
if (images.length < 2) {
return;
}
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
} }
selectVariant(variant: ShopProductVariantOption): void { selectVariant(variant: ShopProductVariantOption): void {
this.selectedVariantId.set(variant.id); this.selectedVariantId.set(variant.id);
this.selectedMaterialKey.set(this.materialKeyForVariant(variant));
this.colorPopupOpen.set(false);
this.addSuccess.set(false);
}
selectMaterial(materialKey: string): void {
this.selectedMaterialKey.set(materialKey);
this.colorPopupOpen.set(false);
const material = this.materialOptions().find(
(item) => item.key === materialKey,
);
const nextVariant =
material?.variants.find((variant) => variant.isDefault) ??
material?.variants[0] ??
null;
if (nextVariant) {
this.selectedVariantId.set(nextVariant.id);
}
this.addSuccess.set(false); this.addSuccess.set(false);
} }
@@ -256,16 +378,86 @@ export class ProductDetailComponent {
} }
colorLabel(variant: ShopProductVariantOption): string { colorLabel(variant: ShopProductVariantOption): string {
return variant.colorName || variant.variantLabel || '-'; return (
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
);
} }
colorHex(variant: ShopProductVariantOption): string { colorHex(variant: ShopProductVariantOption | null | undefined): string {
return variant.colorHex || '#d5d8de'; const normalizedHex = this.normalizeHexColor(variant?.colorHex);
if (normalizedHex) {
return normalizedHex;
}
const fallbackByName = this.colorHexFromName(variant?.colorName);
if (fallbackByName) {
return fallbackByName;
}
return '#d5d8de';
}
materialPriceLabel(material: ShopMaterialOption): number {
return material.priceFromChf;
}
materialColorCount(material: ShopMaterialOption): number {
return material.variants.length;
}
toggleColorPopup(): void {
this.colorPopupOpen.update((open) => !open);
}
closeColorPopup(): void {
this.colorPopupOpen.set(false);
}
openModelModal(): void {
const model = this.product()?.model3d;
if (!model) {
return;
}
this.colorPopupOpen.set(false);
this.modelModalOpen.set(true);
if (this.modelFile() || this.modelLoading()) {
return;
}
this.loadModelPreview(model.url, model.originalFilename);
}
closeModelModal(): void {
this.modelModalOpen.set(false);
}
shopRootLink(): string[] {
return this.shopRouteService.shopRootCommands();
}
categoryLink(slug: string | null | undefined): string[] {
return this.shopRouteService.shopRootCommands(slug);
} }
productLinkRoot(): string[] { productLinkRoot(): string[] {
const categorySlug = this.product()?.category.slug || this.categorySlug(); const categorySlug = this.product()?.category.slug || this.categorySlug();
return categorySlug ? ['/shop', categorySlug] : ['/shop']; return this.shopRouteService.shopRootCommands(categorySlug);
}
goBackToShop(): void {
const returnUrl =
this.isBrowser && typeof history.state?.shopReturnUrl === 'string'
? history.state.shopReturnUrl
: null;
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
void this.router.navigateByUrl(returnUrl);
return;
}
void this.router.navigate(this.productLinkRoot());
} }
private loadModelPreview(urlOrPath: string, filename: string): void { private loadModelPreview(urlOrPath: string, filename: string): void {
@@ -289,11 +481,34 @@ export class ProductDetailComponent {
}); });
} }
private setSelectedImageAssetId(mediaAssetId: string | null): void {
this.selectedImageAssetId.set(mediaAssetId);
}
private normalizeHexColor(value: string | null | undefined): string | null {
const raw = String(value ?? '').trim();
if (!raw) {
return null;
}
const withHash = raw.startsWith('#') ? raw : `#${raw}`;
if (!ProductDetailComponent.HEX_COLOR_PATTERN.test(withHash)) {
return null;
}
return withHash.toUpperCase();
}
private colorHexFromName(value: string | null | undefined): string | null {
return findColorHex(value);
}
private applySeo(product: ShopProductDetail): void { private applySeo(product: ShopProductDetail): void {
const title = product.seoTitle || `${product.name} | 3D fab`; const title = product.seoTitle || `${product.name} | 3D fab`;
const description = const description =
product.seoDescription || product.seoDescription ||
product.excerpt || product.excerpt ||
this.extractTextFromRichContent(product.description) ||
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';
@@ -318,4 +533,209 @@ export class ProductDetailComponent {
ogDescription: description, ogDescription: description,
}); });
} }
private materialLabelForVariant(
variant: ShopProductVariantOption | null,
): string {
return String(variant?.variantLabel || '').trim() || 'Standard';
}
descriptionPlainText(description: string | null | undefined): string {
return this.extractTextFromRichContent(description) ?? '';
}
descriptionRichHtml(description: string | null | undefined): string {
const normalized = String(description ?? '').trim();
if (!normalized) {
return '';
}
if (this.containsHtmlMarkup(normalized)) {
return normalized;
}
return normalized
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map(
(paragraph) =>
`<p>${this.escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`,
)
.join('');
}
private materialKeyForVariant(
variant: ShopProductVariantOption | null,
): string | null {
if (!variant) {
return null;
}
return this.materialLabelForVariant(variant).toLowerCase();
}
private materialPropertiesFor(
materialLabel: string | null | undefined,
): ShopMaterialProperty[] {
const normalized = String(materialLabel ?? '')
.trim()
.toUpperCase();
if (normalized.includes('ASA')) {
return [
{
labelKey: 'SHOP.PROPERTY_UV',
valueKey: 'SHOP.PROPERTY_HIGH',
tone: 'strong',
},
{
labelKey: 'SHOP.PROPERTY_WEATHER',
valueKey: 'SHOP.PROPERTY_HIGH',
tone: 'strong',
},
{
labelKey: 'SHOP.PROPERTY_RIGIDITY',
valueKey: 'SHOP.PROPERTY_RIGID',
tone: 'neutral',
},
];
}
if (normalized.includes('PETG') || normalized.includes('PC')) {
return [
{
labelKey: 'SHOP.PROPERTY_UV',
valueKey: 'SHOP.PROPERTY_MEDIUM',
tone: 'neutral',
},
{
labelKey: 'SHOP.PROPERTY_WEATHER',
valueKey: 'SHOP.PROPERTY_HIGH',
tone: 'strong',
},
{
labelKey: 'SHOP.PROPERTY_RIGIDITY',
valueKey: normalized.includes('PC')
? 'SHOP.PROPERTY_HIGH'
: 'SHOP.PROPERTY_RIGID',
tone: 'neutral',
},
];
}
if (normalized.includes('TPU')) {
return [
{
labelKey: 'SHOP.PROPERTY_UV',
valueKey: 'SHOP.PROPERTY_MEDIUM',
tone: 'neutral',
},
{
labelKey: 'SHOP.PROPERTY_WEATHER',
valueKey: 'SHOP.PROPERTY_MEDIUM',
tone: 'soft',
},
{
labelKey: 'SHOP.PROPERTY_RIGIDITY',
valueKey: 'SHOP.PROPERTY_FLEXIBLE',
tone: 'soft',
},
];
}
return [
{
labelKey: 'SHOP.PROPERTY_UV',
valueKey: 'SHOP.PROPERTY_LOW',
tone: 'soft',
},
{
labelKey: 'SHOP.PROPERTY_WEATHER',
valueKey: 'SHOP.PROPERTY_LOW',
tone: 'soft',
},
{
labelKey: 'SHOP.PROPERTY_RIGIDITY',
valueKey: 'SHOP.PROPERTY_RIGID',
tone: 'neutral',
},
];
}
private extractTextFromRichContent(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
if (!normalized) {
return null;
}
if (!this.containsHtmlMarkup(normalized)) {
return normalized;
}
if (!this.isBrowser) {
const text = normalized
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text || null;
}
const parser = new DOMParser();
const parsed = parser.parseFromString(
`<body>${normalized}</body>`,
'text/html',
);
const text = (parsed.body.textContent ?? '').replace(/\u00a0/g, ' ').trim();
return text || null;
}
private containsHtmlMarkup(value: string): boolean {
return /<\/?[a-z][\s\S]*>/i.test(value);
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
private syncPublicUrl(product: ShopProductDetail): void {
if (!this.isBrowser) {
return;
}
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
const targetProductSlug = this.shopRouteService.productPathSegment(product);
if (currentProductSlug === targetProductSlug) {
return;
}
const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree(
[
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
{
queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined,
},
);
if (
this.router.serializeUrl(targetTree) ===
this.router.serializeUrl(currentTree)
) {
return;
}
void this.router.navigateByUrl(targetTree, {
replaceUrl: true,
state: history.state,
});
}
} }

View File

@@ -0,0 +1,107 @@
import { Injectable } from '@angular/core';
import { LanguageService } from '../../../core/services/language.service';
export interface ShopProductRouteRef {
id: string | null | undefined;
name: string | null | undefined;
slug?: string | null | undefined;
}
export interface ShopProductLookup {
idPrefix: string | null;
slugHint: string | null;
}
@Injectable({
providedIn: 'root',
})
export class ShopRouteService {
constructor(private readonly languageService: LanguageService) {}
shopRootCommands(categorySlug?: string | null): string[] {
const lang = this.languageService.currentLang();
return categorySlug
? ['/', lang, 'shop', categorySlug]
: ['/', lang, 'shop'];
}
productCommands(product: ShopProductRouteRef): string[] {
const lang = this.languageService.currentLang();
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
}
productPathSegment(product: ShopProductRouteRef): string {
const idPrefix = this.productIdPrefix(product.id);
const tail =
this.slugify(product.name) || this.slugify(product.slug) || 'product';
return idPrefix ? `${idPrefix}-${tail}` : tail;
}
resolveProductLookup(
productPathSegment: string | null | undefined,
): ShopProductLookup {
const normalized = String(productPathSegment ?? '')
.trim()
.toLowerCase();
if (!normalized) {
return {
idPrefix: null,
slugHint: null,
};
}
const bareUuidMatch = normalized.match(/^([0-9a-f]{8})$/);
if (bareUuidMatch) {
return {
idPrefix: bareUuidMatch[1],
slugHint: null,
};
}
const publicSlugMatch = normalized.match(/^([0-9a-f]{8})-(.+)$/);
if (publicSlugMatch) {
return {
idPrefix: publicSlugMatch[1],
slugHint: this.slugify(publicSlugMatch[2]) || null,
};
}
return {
idPrefix: null,
slugHint: normalized,
};
}
isCatalogUrl(url: string | null | undefined): boolean {
if (!url) {
return false;
}
const normalized = url.split(/[?#]/, 1)[0] || '';
return /^\/(?:[a-z]{2}\/)?shop(?:\/[^/]+)?$/i.test(normalized);
}
slugify(value: string | null | undefined): string {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
private productIdPrefix(productId: string | null | undefined): string {
const normalized = String(productId ?? '')
.trim()
.toLowerCase();
const canonicalUuidMatch = normalized.match(/^([0-9a-f]{8})-/);
if (canonicalUuidMatch) {
return canonicalUuidMatch[1];
}
const compactUuidMatch = normalized.match(/^([0-9a-f]{8})/);
return compactUuidMatch?.[1] ?? '';
}
}

View File

@@ -3,7 +3,12 @@ import {
HttpClientTestingModule, HttpClientTestingModule,
HttpTestingController, HttpTestingController,
} from '@angular/common/http/testing'; } from '@angular/common/http/testing';
import { ShopCartResponse, ShopService } from './shop.service'; import {
ShopCartResponse,
ShopProductCatalogResponse,
ShopProductDetail,
ShopService,
} from './shop.service';
import { LanguageService } from '../../../core/services/language.service'; import { LanguageService } from '../../../core/services/language.service';
describe('ShopService', () => { describe('ShopService', () => {
@@ -85,6 +90,60 @@ 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 => ({
id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip',
name: 'Supporto cavo scrivania',
excerpt: 'Accessorio tecnico',
description: 'Descrizione prodotto',
seoTitle: null,
seoDescription: null,
ogTitle: null,
ogDescription: null,
indexable: true,
isFeatured: true,
sortOrder: 0,
category: {
id: 'category-1',
slug: 'accessori',
name: 'Accessori',
},
breadcrumbs: [],
priceFromChf: 9.9,
priceToChf: 12.5,
defaultVariant: null,
variants: [],
primaryImage: null,
images: [],
model3d: null,
});
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [HttpClientTestingModule], imports: [HttpClientTestingModule],
@@ -143,4 +202,96 @@ describe('ShopService', () => {
expect(service.cart()?.session?.id).toBe('session-1'); expect(service.cart()?.session?.id).toBe('session-1');
expect(service.cartItemCount()).toBe(3); expect(service.cartItemCount()).toBe(3);
}); });
it('resolves product detail from the public product slug', () => {
let response: ShopProductDetail | undefined;
service
.getProductByPublicPath('12345678-supporto-cavo-scrivania')
.subscribe((product) => {
response = product;
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
expect(response?.name).toBe('Supporto cavo scrivania');
});
it('resolves product detail from uuid prefix even when slug tail does not match', () => {
let response: ShopProductDetail | undefined;
service
.getProductByPublicPath('12345678-qualunque-nome')
.subscribe((product) => {
response = product;
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
});
it('resolves product detail from bare uuid prefix without slug tail', () => {
let response: ShopProductDetail | undefined;
service.getProductByPublicPath('12345678').subscribe((product) => {
response = product;
});
const catalogRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
});
}); });

View File

@@ -1,12 +1,13 @@
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, tap } from 'rxjs'; import { map, Observable, switchMap, 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';
export interface ShopCategoryRef { export interface ShopCategoryRef {
id: string; id: string;
@@ -54,6 +55,7 @@ export interface ShopProductVariantOption {
sku: string | null; sku: string | null;
variantLabel: string | null; variantLabel: string | null;
colorName: string | null; colorName: string | null;
colorLabel: string | null;
colorHex: string | null; colorHex: string | null;
priceChf: number; priceChf: number;
isDefault: boolean; isDefault: boolean;
@@ -137,6 +139,10 @@ export interface ShopCartItem {
shopProductName: string | null; shopProductName: string | null;
shopVariantLabel: string | null; shopVariantLabel: string | null;
shopVariantColorName: string | null; shopVariantColorName: string | null;
shopVariantColorLabelIt?: string | null;
shopVariantColorLabelEn?: string | null;
shopVariantColorLabelDe?: string | null;
shopVariantColorLabelFr?: string | null;
shopVariantColorHex: string | null; shopVariantColorHex: string | null;
materialCode: string | null; materialCode: string | null;
quality: string | null; quality: string | null;
@@ -179,6 +185,7 @@ 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);
@@ -268,6 +275,32 @@ export class ShopService {
); );
} }
getProductByPublicPath(
productPathSegment: string,
): Observable<ShopProductDetail> {
const lookup =
this.shopRouteService.resolveProductLookup(productPathSegment);
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(() => ({
status: 404,
}));
}
return this.getProduct(product.slug);
}),
);
}
loadCart(): Observable<ShopCartResponse> { loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true); this.cartLoading.set(true);
return this.http return this.http

View File

@@ -7,19 +7,14 @@
? selectedCategory()?.description || ? selectedCategory()?.description ||
("SHOP.CATEGORY_META" ("SHOP.CATEGORY_META"
| translate: { count: selectedCategory()?.productCount || 0 }) | translate: { count: selectedCategory()?.productCount || 0 })
: ("SHOP.CUSTOM_PART_CTA" | translate) : ("SHOP.SUBTITLE" | translate)
}} }}
</p> </p>
<div class="ui-simple-hero__actions hero-actions ui-inline-actions">
<app-button variant="outline" routerLink="/contact">
{{ "NAV.CONTACT" | translate }}
</app-button>
</div>
</div> </div>
<div class="container shop-layout"> <div class="container shop-layout">
<aside class="shop-sidebar"> <aside class="shop-sidebar">
<app-card> <app-card class="category-card">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<p class="panel-kicker"> <p class="panel-kicker">
@@ -37,7 +32,9 @@
[class.active]="!currentCategorySlug()" [class.active]="!currentCategorySlug()"
(click)="navigateToCategory()" (click)="navigateToCategory()"
> >
<span>{{ "SHOP.ALL_CATEGORIES" | translate }}</span> <span class="category-name">{{
"SHOP.ALL_CATEGORIES" | translate
}}</span>
</button> </button>
<div class="category-list"> <div class="category-list">
@@ -49,8 +46,8 @@
[style.--depth]="node.depth" [style.--depth]="node.depth"
(click)="navigateToCategory(node.slug)" (click)="navigateToCategory(node.slug)"
> >
<span>{{ node.name }}</span> <span class="category-name">{{ node.name }}</span>
<small>{{ node.productCount }}</small> <small class="category-count">{{ node.productCount }}</small>
</button> </button>
} }
</div> </div>
@@ -87,7 +84,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 }}</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">
@@ -95,7 +94,7 @@
class="color-dot" class="color-dot"
[style.background-color]="cartItemColorHex(item)" [style.background-color]="cartItemColorHex(item)"
></span> ></span>
<span>{{ color }}</span> <span>{{ color | translate }}</span>
</span> </span>
} }
</div> </div>
@@ -227,4 +226,23 @@
} }
</section> </section>
</div> </div>
<section class="container shop-custom-cta">
<app-card class="shop-custom-cta-card">
<div class="shop-custom-cta-inner">
<div class="shop-custom-cta-copy">
<p class="panel-kicker">
{{ "SHOP.CUSTOM_PART_FOOTER_TITLE" | translate }}
</p>
<h2 class="shop-custom-cta-title">
{{ "SHOP.CUSTOM_PART_FOOTER_TEXT" | translate }}
</h2>
</div>
<app-button variant="primary" routerLink="/contact">
{{ "NAV.CONTACT" | translate }}
</app-button>
</div>
</app-card>
</section>
</section> </section>

View File

@@ -7,16 +7,12 @@
line-height: 1.5; line-height: 1.5;
} }
.hero-actions {
gap: var(--space-3);
}
.shop-layout { .shop-layout {
display: grid; display: grid;
gap: var(--space-8); gap: var(--space-8);
align-items: start; align-items: start;
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
padding-bottom: var(--space-12); padding-bottom: var(--space-8);
padding-top: var(--space-6); padding-top: var(--space-6);
} }
@@ -48,40 +44,70 @@
font-size: 1.1rem; font-size: 1.1rem;
} }
.category-card {
display: block;
}
.category-list { .category-list {
display: grid; display: grid;
gap: 0.4rem; gap: 0.45rem;
margin-top: var(--space-2);
} }
.category-link { .category-link {
--depth: 0; --depth: 0;
position: relative;
display: flex; display: flex;
width: 100%; width: calc(100% - (var(--depth) * 0.7rem));
margin-left: calc(var(--depth) * 0.7rem);
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-2);
padding: 0.8rem 0.95rem 0.8rem calc(0.95rem + (var(--depth) * 0.95rem)); min-height: 2.8rem;
border: 1px solid transparent; padding: 0.55rem 0.75rem;
border-radius: 0.9rem; border: 1px solid rgba(16, 24, 32, 0.1);
background: transparent; border-radius: 0.8rem;
background: #fff;
color: var(--color-text); color: var(--color-text);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: transition:
background-color 0.18s ease, background-color 0.16s ease,
border-color 0.18s ease, border-color 0.16s ease,
transform 0.18s ease; box-shadow 0.16s ease;
}
.category-link:hover {
background: rgba(16, 24, 32, 0.03);
border-color: rgba(16, 24, 32, 0.14);
} }
.category-link:hover,
.category-link.active { .category-link.active {
background: rgba(250, 207, 10, 0.12); background: color-mix(in srgb, var(--color-brand) 14%, #fff);
border-color: rgba(16, 24, 32, 0.12); border-color: color-mix(in srgb, var(--color-brand) 42%, #d6dbe4);
transform: translateX(1px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-brand) 35%, #fff);
} }
.category-link small { .category-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.97rem;
font-weight: 500;
}
.category-count {
display: inline-flex;
min-width: 1.6rem;
height: 1.6rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(16, 24, 32, 0.06);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.8rem;
font-weight: 600;
} }
.cart-card { .cart-card {
@@ -226,6 +252,32 @@
gap: var(--space-5); gap: var(--space-5);
} }
.shop-custom-cta {
padding-bottom: var(--space-12);
}
.shop-custom-cta-card {
display: block;
}
.shop-custom-cta-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
}
.shop-custom-cta-copy {
display: grid;
gap: var(--space-1);
}
.shop-custom-cta-title {
margin: 0;
font-size: clamp(1.15rem, 0.45vw + 1.02rem, 1.45rem);
line-height: 1.25;
}
.section-title { .section-title {
margin: 0; margin: 0;
font-size: clamp(1.5rem, 1vw + 1.2rem, 2rem); font-size: clamp(1.5rem, 1vw + 1.2rem, 2rem);
@@ -284,6 +336,11 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.shop-custom-cta-inner {
align-items: start;
flex-direction: column;
}
.catalog-head, .catalog-head,
.cart-line-controls, .cart-line-controls,
.panel-head { .panel-head {

View File

@@ -22,6 +22,10 @@ import {
} from 'rxjs'; } 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 {
findColorHex,
resolveLocalizedColorLabel,
} 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 { ProductCardComponent } from './components/product-card/product-card.component'; import { ProductCardComponent } from './components/product-card/product-card.component';
@@ -33,6 +37,7 @@ import {
ShopProductSummary, ShopProductSummary,
ShopService, ShopService,
} from './services/shop.service'; } from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
@Component({ @Component({
selector: 'app-shop-page', selector: 'app-shop-page',
@@ -55,6 +60,7 @@ export class ShopPageComponent {
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);
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly categorySlug = input<string | undefined>();
@@ -155,20 +161,34 @@ export class ShopPageComponent {
} }
cartItemVariant(item: ShopCartItem): string | null { cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null; return item.shopVariantLabel || this.cartItemColor(item);
} }
cartItemColor(item: ShopCartItem): string | null { cartItemColor(item: ShopCartItem): string | null {
return item.shopVariantColorName || item.colorCode || null; return (
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: item.shopVariantColorName ?? item.colorCode,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
}) ??
item.shopVariantColorName ??
item.colorCode
);
} }
cartItemColorHex(item: ShopCartItem): string { cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6'; return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
} }
navigateToCategory(slug?: string | null): void { navigateToCategory(slug?: string | null): void {
const commands = slug ? ['/shop', slug] : ['/shop']; this.router.navigate(this.shopRouteService.shopRootCommands(slug));
this.router.navigate(commands);
} }
increaseQuantity(item: ShopCartItem): void { increaseQuantity(item: ShopCartItem): void {

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