Compare commits
106 Commits
feat/shop
...
bad5947fb5
| Author | SHA1 | Date | |
|---|---|---|---|
| bad5947fb5 | |||
| d27558a3ee | |||
| 81f6f78c49 | |||
| bf593445bd | |||
| aa032c0140 | |||
|
|
95e60692c0 | ||
| fda2cdbecb | |||
| a1cc9f18c4 | |||
| 084d35d605 | |||
|
|
02aac24a09 | ||
| 51c2bf6985 | |||
| 4e99d12be1 | |||
| 8b5d8f92e0 | |||
| d3c9dd6eb9 | |||
| 254ff36c50 | |||
| b317196217 | |||
| cc343ee27c | |||
| 74d1b16b7c | |||
| adf6889712 | |||
| 653082868a | |||
| 997e770256 | |||
| fb1a6456e6 | |||
| 43cd80600e | |||
|
|
23e1abdbbb | ||
| e575021f53 | |||
| 7e8c89ce45 | |||
| a40a8df894 | |||
|
|
41f36ed18a | ||
| e04189bbfe | |||
| 20988e425a | |||
| df63937406 | |||
| 4ba408859d | |||
| 996e95f93c | |||
|
|
c4bd0b5a67 | ||
| 5c43873ede | |||
| 249645619e | |||
| be9f303b37 | |||
| 6da8b3b6e4 | |||
| a3cd451575 | |||
|
|
6f700c923a | ||
| 46fd59ed71 | |||
| ba49463ee7 | |||
| 576380e9a0 | |||
| cac534ccbb | |||
| 2e68105da4 | |||
| ed7ed6636d | |||
| e190359041 | |||
|
|
bed94790d4 | ||
| d8ad61ec54 | |||
| 7e9a1482d6 | |||
| aa0adbf993 | |||
| 00af9a9701 | |||
| fcdede2dd6 | |||
| 5d17b23c3a | |||
| 1ec8a43a50 | |||
| 96cfa91c67 | |||
| 669ace82c0 | |||
| 93163ae6e8 | |||
| af2d506da1 | |||
| 637541994a | |||
|
|
63cd4c4f5e | ||
| fd4104da39 | |||
| 5bb23fbcfa | |||
| 6a22c54e9f | |||
| 3ac3262e77 | |||
| 18ecc07240 | |||
| cb468492b3 | |||
| 379a2161ca | |||
| c47a7e28c7 | |||
|
|
502126c915 | ||
| 2ace632022 | |||
| feee2b0bff | |||
| b7dfc53bc0 | |||
| d77c3c7a5c | |||
|
|
faaa59ae01 | ||
| 1bde2690b6 | |||
| 92fddd9e4a | |||
| f9414b2db7 | |||
| d1e7e7eaca | |||
| 75929a15f8 | |||
| c0585d7107 | |||
| aeeed1c138 | |||
| 10f05fabc9 | |||
| 78b719d3c2 | |||
|
|
052ade3e91 | ||
| 035851133e | |||
| 1ff8883d25 | |||
| 30ada043ef | |||
| e0aaa7c92e | |||
| edae13541f | |||
| d150c19f9f | |||
| 71890e4cc2 | |||
| 95494efcae | |||
| 8893a80c12 | |||
| a4facf74d7 | |||
| ed8fd89217 | |||
| 58869be9f7 | |||
| fda0174a77 | |||
|
|
52c6239f6d | ||
| c007cee093 | |||
| d011268d20 | |||
| 126d3ef4c4 | |||
| 3f228ef6e2 | |||
| 8733184dc5 | |||
| 6d491eb694 | |||
| 3ab518a9b6 |
@@ -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
|
||||||
@@ -205,9 +217,12 @@ jobs:
|
|||||||
ADMIN_TTL="${ADMIN_TTL:-480}"
|
ADMIN_TTL="${ADMIN_TTL:-480}"
|
||||||
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
||||||
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
||||||
|
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
|
||||||
|
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Preparing to send env file with variables:"
|
echo "Preparing to send env file with variables:"
|
||||||
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
|
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
|
||||||
|
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||||
|
|||||||
@@ -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,10 @@ 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.
|
||||||
|
|
||||||
|
### Deploy e traduzioni OpenAI
|
||||||
|
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AllowedOriginService {
|
||||||
|
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
|
||||||
|
public AllowedOriginService(
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
|
||||||
|
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
|
||||||
|
) {
|
||||||
|
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
|
||||||
|
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
|
||||||
|
|
||||||
|
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
|
||||||
|
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredOrigins.isEmpty()) {
|
||||||
|
throw new IllegalStateException("At least one allowed origin must be configured.");
|
||||||
|
}
|
||||||
|
this.allowedOrigins = List.copyOf(configuredOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAllowedOrigins() {
|
||||||
|
return allowedOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed(String rawOriginOrUrl) {
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
if (normalizedOrigin == null) {
|
||||||
|
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
|
||||||
|
}
|
||||||
|
configuredOrigins.add(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRequestOrigin(String rawOriginOrUrl) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(rawOriginOrUrl.trim());
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (scheme == null || host == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||||
|
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||||
|
int port = uri.getPort();
|
||||||
|
if (isDefaultPort(normalizedScheme, port) || port < 0) {
|
||||||
|
return normalizedScheme + "://" + normalizedHost;
|
||||||
|
}
|
||||||
|
return normalizedScheme + "://" + normalizedHost + ":" + port;
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDefaultPort(String scheme, int port) {
|
||||||
|
return ("http".equals(scheme) && port == 80)
|
||||||
|
|| ("https".equals(scheme) && port == 443);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig {
|
||||||
|
|
||||||
@Override
|
@Bean
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||||
registry.addMapping("/**")
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
.allowedOrigins(
|
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||||
"http://localhost",
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
"http://localhost:4200",
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
"http://localhost:80",
|
configuration.setAllowCredentials(true);
|
||||||
"http://127.0.0.1",
|
configuration.setMaxAge(3600L);
|
||||||
"https://dev.3d-fab.ch",
|
|
||||||
"https://int.3d-fab.ch",
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
"https://3d-fab.ch"
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
)
|
return source;
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity http,
|
HttpSecurity http,
|
||||||
|
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
|||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||||
}))
|
}))
|
||||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-path/{publicPath}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/products/{slug}/model")
|
@GetMapping("/products/{slug}/model")
|
||||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.math.BigDecimal;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
@@ -124,6 +125,9 @@ public class QuoteController {
|
|||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
if (!isSupportedInputFile(file)) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
|
||||||
|
}
|
||||||
|
|
||||||
// Scan for virus
|
// Scan for virus
|
||||||
clamAVService.scan(file.getInputStream());
|
clamAVService.scan(file.getInputStream());
|
||||||
@@ -153,4 +157,14 @@ public class QuoteController {
|
|||||||
Files.deleteIfExists(tempInput);
|
Files.deleteIfExists(tempInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSupportedInputFile(MultipartFile file) {
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
if (originalFilename == null || originalFilename.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = originalFilename.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
||||||
QuoteSession session = sessionRepo.findById(id)
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
import com.printcalculator.dto.AdminShopProductDto;
|
import com.printcalculator.dto.AdminShopProductDto;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
||||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -29,9 +32,12 @@ import java.util.UUID;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class AdminShopProductController {
|
public class AdminShopProductController {
|
||||||
private final AdminShopProductControllerService adminShopProductControllerService;
|
private final AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
private final AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) {
|
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
|
||||||
|
AdminShopProductTranslationService adminShopProductTranslationService) {
|
||||||
this.adminShopProductControllerService = adminShopProductControllerService;
|
this.adminShopProductControllerService = adminShopProductControllerService;
|
||||||
|
this.adminShopProductTranslationService = adminShopProductTranslationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -50,6 +56,11 @@ public class AdminShopProductController {
|
|||||||
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/translate")
|
||||||
|
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{productId}")
|
@PutMapping("/{productId}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductRequest {
|
||||||
|
private UUID categoryId;
|
||||||
|
private String sourceLanguage;
|
||||||
|
private Boolean overwriteExisting;
|
||||||
|
private List<String> materialCodes;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public UUID getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(UUID categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getOverwriteExisting() {
|
||||||
|
return overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOverwriteExisting(Boolean overwriteExisting) {
|
||||||
|
this.overwriteExisting = overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getMaterialCodes() {
|
||||||
|
return materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaterialCodes(List<String> materialCodes) {
|
||||||
|
this.materialCodes = materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductResponse {
|
||||||
|
private String sourceLanguage;
|
||||||
|
private List<String> targetLanguages;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getTargetLanguages() {
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetLanguages(List<String> targetLanguages) {
|
||||||
|
this.targetLanguages = targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.printcalculator.dto;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductDetailDto(
|
public record ShopProductDetailDto(
|
||||||
@@ -25,6 +26,8 @@ public record ShopProductDetailDto(
|
|||||||
List<ShopProductVariantOptionDto> variants,
|
List<ShopProductVariantOptionDto> variants,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
List<PublicMediaUsageDto> images,
|
List<PublicMediaUsageDto> images,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductSummaryDto(
|
public record ShopProductSummaryDto(
|
||||||
@@ -15,6 +16,8 @@ public record ShopProductSummaryDto(
|
|||||||
BigDecimal priceToChf,
|
BigDecimal priceToChf,
|
||||||
ShopProductVariantOptionDto defaultVariant,
|
ShopProductVariantOptionDto defaultVariant,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,10 +223,15 @@ public class OrderEmailListener {
|
|||||||
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
|
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
|
||||||
);
|
);
|
||||||
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
|
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
return templateData;
|
return templateData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildLogoUrl() {
|
||||||
|
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
||||||
|
}
|
||||||
|
|
||||||
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
|
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
|
||||||
return switch (language) {
|
return switch (language) {
|
||||||
case "en" -> {
|
case "en" -> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.repository;
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -8,9 +9,16 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
|
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
|
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"shopProductVariant"})
|
||||||
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
||||||
UUID quoteSessionId,
|
UUID quoteSessionId,
|
||||||
String lineItemType,
|
String lineItemType,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.printcalculator.security;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
|
||||||
|
|
||||||
|
private final AllowedOriginService allowedOriginService;
|
||||||
|
|
||||||
|
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
|
||||||
|
this.allowedOriginService = allowedOriginService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = resolvePath(request);
|
||||||
|
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
|
||||||
|
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String origin = request.getHeader(HttpHeaders.ORIGIN);
|
||||||
|
String referer = request.getHeader(HttpHeaders.REFERER);
|
||||||
|
|
||||||
|
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePath(HttpServletRequest request) {
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||||
|
return path.substring(contextPath.length());
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 +236,21 @@ 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)) {
|
||||||
|
if (requiresStoredSourceFile(qItem)) {
|
||||||
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||||
|
oItem.setStoredRelativePath(relativePath);
|
||||||
try {
|
try {
|
||||||
storageService.store(sourcePath, Paths.get(relativePath));
|
storageService.store(sourcePath, Paths.get(relativePath));
|
||||||
oItem.setFileSizeBytes(Files.size(sourcePath));
|
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
savedItems.add(oItem);
|
savedItems.add(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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,685 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AdminShopProductTranslationService {
|
||||||
|
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
|
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
|
||||||
|
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
|
||||||
|
.addAttributes("a", "href")
|
||||||
|
.addProtocols("a", "href", "http", "https", "mailto", "tel");
|
||||||
|
private static final String DEFAULT_SHOP_CONTEXT = """
|
||||||
|
3D fab is a Swiss-based 3D printing shop and technical service.
|
||||||
|
The tone must be practical, clear, technical, and trustworthy.
|
||||||
|
Avoid hype, avoid invented claims, and avoid vague marketing filler.
|
||||||
|
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
|
||||||
|
When the source field is empty, return an empty string rather than inventing content.
|
||||||
|
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
|
||||||
|
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ShopCategoryRepository shopCategoryRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String baseUrl;
|
||||||
|
private final String model;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final String promptCacheKeyPrefix;
|
||||||
|
private final String additionalBusinessContext;
|
||||||
|
|
||||||
|
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${openai.translation.api-key:}") String apiKey,
|
||||||
|
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
|
||||||
|
@Value("${openai.translation.model:gpt-5.4}") String model,
|
||||||
|
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
|
||||||
|
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
|
||||||
|
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
|
||||||
|
this.shopCategoryRepository = shopCategoryRepository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.apiKey = apiKey != null ? apiKey.trim() : "";
|
||||||
|
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
this.model = model != null ? model.trim() : "";
|
||||||
|
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
|
||||||
|
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
|
||||||
|
? promptCacheKeyPrefix.trim()
|
||||||
|
: "printcalc-shop-product-translation-v1";
|
||||||
|
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(this.timeout)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
|
||||||
|
ensureConfigured();
|
||||||
|
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
|
||||||
|
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
|
||||||
|
if (targetLanguages.isEmpty()) {
|
||||||
|
return emptyResponse(normalizedRequest.sourceLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
|
||||||
|
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
|
||||||
|
|
||||||
|
TranslationBundle generated = callOpenAiFunction(
|
||||||
|
"generate_product_translations",
|
||||||
|
"Generate translated product copy for the requested target languages.",
|
||||||
|
buildInstructions("Generate the first-pass translations.", businessContext),
|
||||||
|
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"generate"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
|
||||||
|
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
|
||||||
|
|
||||||
|
TranslationBundle reviewed = callOpenAiFunction(
|
||||||
|
"review_product_translations",
|
||||||
|
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
|
||||||
|
buildInstructions("Review and correct the generated translations.", businessContext),
|
||||||
|
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"review"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
|
||||||
|
ensureRequiredTranslations(finalBundle, targetLanguages);
|
||||||
|
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureConfigured() {
|
||||||
|
if (apiKey.isBlank() || model.isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"OpenAI translation is not configured on the backend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
|
||||||
|
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
|
||||||
|
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
|
||||||
|
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
|
||||||
|
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
|
||||||
|
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
|
||||||
|
|
||||||
|
if (names.get(sourceLanguage).isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"The active source language must have a product name before translation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> materialCodes = new LinkedHashSet<>();
|
||||||
|
if (payload.getMaterialCodes() != null) {
|
||||||
|
for (String materialCode : payload.getMaterialCodes()) {
|
||||||
|
String normalizedCode = normalizeOptional(materialCode);
|
||||||
|
if (normalizedCode != null) {
|
||||||
|
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NormalizedTranslationRequest(
|
||||||
|
payload.getCategoryId(),
|
||||||
|
sourceLanguage,
|
||||||
|
Boolean.TRUE.equals(payload.getOverwriteExisting()),
|
||||||
|
List.copyOf(materialCodes),
|
||||||
|
names,
|
||||||
|
excerpts,
|
||||||
|
descriptions,
|
||||||
|
seoTitles,
|
||||||
|
seoDescriptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
|
||||||
|
List<String> targetLanguages = new ArrayList<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
if (language.equals(request.sourceLanguage())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (request.overwriteExisting() || needsTranslation(request, language)) {
|
||||||
|
targetLanguages.add(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
|
||||||
|
return request.names().get(language).isBlank()
|
||||||
|
|| request.excerpts().get(language).isBlank()
|
||||||
|
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|
||||||
|
|| request.seoTitles().get(language).isBlank()
|
||||||
|
|| request.seoDescriptions().get(language).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryContext loadCategoryContext(UUID categoryId) {
|
||||||
|
if (categoryId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new CategoryContext(
|
||||||
|
category.getSlug(),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getNameIt()),
|
||||||
|
"en", safeValue(category.getNameEn()),
|
||||||
|
"de", safeValue(category.getNameDe()),
|
||||||
|
"fr", safeValue(category.getNameFr())
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getDescriptionIt()),
|
||||||
|
"en", safeValue(category.getDescriptionEn()),
|
||||||
|
"de", safeValue(category.getDescriptionDe()),
|
||||||
|
"fr", safeValue(category.getDescriptionFr())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
|
||||||
|
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
|
||||||
|
if (!additionalBusinessContext.isBlank()) {
|
||||||
|
context.append('\n').append(additionalBusinessContext.trim());
|
||||||
|
}
|
||||||
|
if (categoryContext != null) {
|
||||||
|
context.append("\nCategory slug: ").append(categoryContext.slug());
|
||||||
|
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
|
||||||
|
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
|
||||||
|
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (materialCodes != null && !materialCodes.isEmpty()) {
|
||||||
|
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
|
||||||
|
}
|
||||||
|
return context.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildInstructions(String task, String businessContext) {
|
||||||
|
return """
|
||||||
|
You are a senior ecommerce localization editor.
|
||||||
|
Task: %s
|
||||||
|
Return only the function call arguments that match the provided schema.
|
||||||
|
Always preserve meaning, HTML safety, and technical precision.
|
||||||
|
Never invent specifications or marketing claims not present in the source.
|
||||||
|
If a source field is empty, return an empty string for that field.
|
||||||
|
General context:
|
||||||
|
%s
|
||||||
|
""".formatted(task, businessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildGenerationInput(NormalizedTranslationRequest request,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.put("overwriteExisting", request.overwriteExisting());
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildReviewInput(NormalizedTranslationRequest request,
|
||||||
|
TranslationBundle generated,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext,
|
||||||
|
List<String> validationNotes) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
|
||||||
|
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
node.put("name", request.names().get(language));
|
||||||
|
node.put("excerpt", request.excerpts().get(language));
|
||||||
|
node.put("description", request.descriptions().get(language));
|
||||||
|
node.put("seoTitle", request.seoTitles().get(language));
|
||||||
|
node.put("seoDescription", request.seoDescriptions().get(language));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
node.set(language, localizedFieldNode(request, language));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
root.put("type", "object");
|
||||||
|
root.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode properties = root.putObject("properties");
|
||||||
|
ObjectNode translations = properties.putObject("translations");
|
||||||
|
translations.put("type", "object");
|
||||||
|
translations.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode translationProperties = translations.putObject("properties");
|
||||||
|
ArrayNode requiredTranslations = translations.putArray("required");
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
|
||||||
|
requiredTranslations.add(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayNode required = root.putArray("required");
|
||||||
|
required.add("translations");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationSchemaForLanguage(String language) {
|
||||||
|
ObjectNode languageSchema = objectMapper.createObjectNode();
|
||||||
|
languageSchema.put("type", "object");
|
||||||
|
languageSchema.put("additionalProperties", false);
|
||||||
|
languageSchema.put("description", "Localized product copy for language " + language);
|
||||||
|
|
||||||
|
ObjectNode properties = languageSchema.putObject("properties");
|
||||||
|
addSchemaString(properties, "name", "Translated product name. Never empty.");
|
||||||
|
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
|
||||||
|
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
|
||||||
|
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
|
||||||
|
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
|
||||||
|
|
||||||
|
ArrayNode required = languageSchema.putArray("required");
|
||||||
|
required.add("name");
|
||||||
|
required.add("excerpt");
|
||||||
|
required.add("description");
|
||||||
|
required.add("seoTitle");
|
||||||
|
required.add("seoDescription");
|
||||||
|
return languageSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSchemaString(ObjectNode properties, String name, String description) {
|
||||||
|
ObjectNode property = properties.putObject(name);
|
||||||
|
property.put("type", "string");
|
||||||
|
property.put("description", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle callOpenAiFunction(String functionName,
|
||||||
|
String functionDescription,
|
||||||
|
String instructions,
|
||||||
|
String input,
|
||||||
|
ObjectNode parametersSchema,
|
||||||
|
String cacheSuffix) {
|
||||||
|
ObjectNode requestPayload = objectMapper.createObjectNode();
|
||||||
|
requestPayload.put("model", model);
|
||||||
|
requestPayload.put("instructions", instructions);
|
||||||
|
requestPayload.put("input", input);
|
||||||
|
requestPayload.put("tool_choice", "required");
|
||||||
|
requestPayload.put("temperature", 0.2);
|
||||||
|
requestPayload.put("store", false);
|
||||||
|
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
|
||||||
|
|
||||||
|
ArrayNode tools = requestPayload.putArray("tools");
|
||||||
|
ObjectNode tool = tools.addObject();
|
||||||
|
tool.put("type", "function");
|
||||||
|
tool.put("name", functionName);
|
||||||
|
tool.put("description", functionDescription);
|
||||||
|
tool.put("strict", true);
|
||||||
|
tool.set("parameters", parametersSchema);
|
||||||
|
|
||||||
|
JsonNode responseNode = postResponsesRequest(requestPayload);
|
||||||
|
JsonNode output = responseNode.path("output");
|
||||||
|
if (output.isArray()) {
|
||||||
|
for (JsonNode item : output) {
|
||||||
|
if ("function_call".equals(item.path("type").asText())) {
|
||||||
|
String arguments = item.path("arguments").asText("");
|
||||||
|
if (arguments.isBlank()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode argumentsNode = objectMapper.readTree(arguments);
|
||||||
|
JsonNode translationsNode = argumentsNode.path("translations");
|
||||||
|
if (!translationsNode.isObject()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned a function call without translations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TranslationBundle.fromJson(translationsNode);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned invalid JSON arguments",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return the expected function call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/responses"))
|
||||||
|
.timeout(timeout)
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
JsonNode body = readJson(response.body());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
String message = body.path("error").path("message").asText("").trim();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
message.isBlank() ? "OpenAI translation request failed" : message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Unable to read the OpenAI translation response",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"The OpenAI translation request was interrupted",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
List<String> notes = new ArrayList<>();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
notes.add(language + ": translated name is empty and must be fixed");
|
||||||
|
}
|
||||||
|
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
|
||||||
|
if (seoDescription.length() > 160) {
|
||||||
|
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
|
||||||
|
}
|
||||||
|
String description = bundle.descriptions().getOrDefault(language, "");
|
||||||
|
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
|
||||||
|
notes.add(language + ": description lost meaningful text during sanitization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
names.put(language, safeValue(bundle.names().get(language)));
|
||||||
|
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
|
||||||
|
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
|
||||||
|
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
|
||||||
|
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
TranslationBundle bundle) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(targetLanguages);
|
||||||
|
response.setNames(bundle.names());
|
||||||
|
response.setExcerpts(bundle.excerpts());
|
||||||
|
response.setDescriptions(bundle.descriptions());
|
||||||
|
response.setSeoTitles(bundle.seoTitles());
|
||||||
|
response.setSeoDescriptions(bundle.seoDescriptions());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(List.of());
|
||||||
|
response.setNames(Map.of());
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
|
||||||
|
Map<String, String> normalized = new LinkedHashMap<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
String value = rawValues != null ? rawValues.get(language) : null;
|
||||||
|
if (richText) {
|
||||||
|
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
|
||||||
|
} else {
|
||||||
|
normalized.put(language, safeValue(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeValue(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeDescription(String value) {
|
||||||
|
String normalized = normalizeRichTextOptional(value);
|
||||||
|
return normalized != null ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String limitSeoDescription(String value) {
|
||||||
|
String normalized = safeValue(value);
|
||||||
|
if (normalized.length() <= 160) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
int lastSpace = normalized.lastIndexOf(' ', 157);
|
||||||
|
if (lastSpace >= 120) {
|
||||||
|
return normalized.substring(0, lastSpace).trim();
|
||||||
|
}
|
||||||
|
return normalized.substring(0, 160).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeOptional(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isBlank() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase(Locale.ROOT);
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRichTextOptional(String value) {
|
||||||
|
String normalized = normalizeOptional(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitized = Jsoup.clean(
|
||||||
|
normalized,
|
||||||
|
"",
|
||||||
|
PRODUCT_DESCRIPTION_SAFELIST,
|
||||||
|
new Document.OutputSettings().prettyPrint(false)
|
||||||
|
).trim();
|
||||||
|
if (sanitized.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String plainText = Jsoup.parse(sanitized).text();
|
||||||
|
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeBaseUrl(String rawBaseUrl) {
|
||||||
|
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
|
||||||
|
? rawBaseUrl.trim()
|
||||||
|
: "https://api.openai.com/v1";
|
||||||
|
while (normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Unable to serialize translation payload",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readJson(String rawJson) throws IOException {
|
||||||
|
return objectMapper.readTree(rawJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NormalizedTranslationRequest(UUID categoryId,
|
||||||
|
String sourceLanguage,
|
||||||
|
boolean overwriteExisting,
|
||||||
|
List<String> materialCodes,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CategoryContext(String slug,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> descriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TranslationBundle(Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
static TranslationBundle fromJson(JsonNode translationsNode) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
translationsNode.fieldNames().forEachRemaining(language -> {
|
||||||
|
JsonNode localizedNode = translationsNode.path(language);
|
||||||
|
names.put(language, localizedNode.path("name").asText(""));
|
||||||
|
excerpts.put(language, localizedNode.path("excerpt").asText(""));
|
||||||
|
descriptions.put(language, localizedNode.path("description").asText(""));
|
||||||
|
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
|
||||||
|
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode toJsonNode(ObjectMapper objectMapper) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
ObjectNode translations = root.putObject("translations");
|
||||||
|
for (String language : names.keySet()) {
|
||||||
|
ObjectNode languageNode = translations.putObject(language);
|
||||||
|
languageNode.put("name", names.getOrDefault(language, ""));
|
||||||
|
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
|
||||||
|
languageNode.put("description", descriptions.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -72,10 +72,14 @@ public class QuoteSessionItemService {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "");
|
||||||
|
if (ext.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
|
||||||
|
}
|
||||||
|
|
||||||
clamAVService.scan(file.getInputStream());
|
clamAVService.scan(file.getInputStream());
|
||||||
|
|
||||||
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
|
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
|
||||||
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
|
|
||||||
String storedFilename = UUID.randomUUID() + "." + ext;
|
String storedFilename = UUID.randomUUID() + "." + ext;
|
||||||
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
|
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ public class QuoteStorageService {
|
|||||||
return switch (ext) {
|
return switch (ext) {
|
||||||
case "stl" -> "stl";
|
case "stl" -> "stl";
|
||||||
case "3mf" -> "3mf";
|
case "3mf" -> "3mf";
|
||||||
case "step", "stp" -> "step";
|
|
||||||
default -> fallback;
|
default -> fallback;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
@Value("${app.mail.contact-request.customer.enabled:true}")
|
@Value("${app.mail.contact-request.customer.enabled:true}")
|
||||||
private boolean contactRequestCustomerMailEnabled;
|
private boolean contactRequestCustomerMailEnabled;
|
||||||
|
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}")
|
||||||
|
private String frontendBaseUrl;
|
||||||
|
|
||||||
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
|
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
|
||||||
ContactRequestLocalizationService localizationService) {
|
ContactRequestLocalizationService localizationService) {
|
||||||
this.emailNotificationService = emailNotificationService;
|
this.emailNotificationService = emailNotificationService;
|
||||||
@@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
templateData.put("phone", safeValue(request.getPhone()));
|
templateData.put("phone", safeValue(request.getPhone()));
|
||||||
templateData.put("message", safeValue(request.getMessage()));
|
templateData.put("message", safeValue(request.getMessage()));
|
||||||
templateData.put("attachmentsCount", attachmentsCount);
|
templateData.put("attachmentsCount", attachmentsCount);
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
|
|
||||||
emailNotificationService.sendEmail(
|
emailNotificationService.sendEmail(
|
||||||
@@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
templateData.put("phone", safeValue(request.getPhone()));
|
templateData.put("phone", safeValue(request.getPhone()));
|
||||||
templateData.put("message", safeValue(request.getMessage()));
|
templateData.put("message", safeValue(request.getMessage()));
|
||||||
templateData.put("attachmentsCount", attachmentsCount);
|
templateData.put("attachmentsCount", attachmentsCount);
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
|
|
||||||
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
|
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
|
||||||
@@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildLogoUrl() {
|
||||||
|
String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
|
||||||
|
? "http://localhost:4200"
|
||||||
|
: frontendBaseUrl;
|
||||||
|
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -115,20 +126,41 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ShopProductDetailDto getProduct(String slug, String language) {
|
public ShopProductDetailDto getProduct(String slug, String language) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(language);
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesBySlug().get(slug),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ProductEntry entry = productContext.entriesBySlug().get(slug);
|
public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
|
||||||
if (entry == null) {
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
|
||||||
|
if (normalizedPublicPath == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
ShopCategory category = entry.product().getCategory();
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
}
|
productContext.entriesByPublicPath().get(normalizedPublicPath),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
return toProductDetailDto(entry, productContext.productMediaBySlug(), language);
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProductModelDownload getProductModelDownload(String slug) {
|
public ProductModelDownload getProductModelDownload(String slug) {
|
||||||
@@ -181,17 +213,52 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
||||||
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
||||||
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));
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
|
||||||
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug);
|
return new PublicProductContext(
|
||||||
|
entries,
|
||||||
|
entriesBySlug,
|
||||||
|
entriesByPublicPath,
|
||||||
|
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 +346,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,8 +426,12 @@ 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());
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
return new ShopProductSummaryDto(
|
return new ShopProductSummaryDto(
|
||||||
entry.product().getId(),
|
entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
@@ -361,66 +442,154 @@ 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),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
||||||
return new ShopProductDetailDto(
|
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||||
entry.product().getId(),
|
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
|
return new ShopProductDetailDto(entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
entry.product().getNameForLanguage(language),
|
entry.product().getNameForLanguage(language),
|
||||||
entry.product().getExcerptForLanguage(language),
|
entry.product().getExcerptForLanguage(language),
|
||||||
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,
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
|
||||||
|
if (entry == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShopCategory category = entry.product().getCategory();
|
||||||
|
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePublicPathSegment(String publicPathSegment) {
|
||||||
|
String normalized = trimToNull(publicPathSegment);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||||
|
if (raw.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
String normalized = trimToNull(language);
|
||||||
|
if (normalized == null) {
|
||||||
|
return "it";
|
||||||
|
}
|
||||||
|
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "en", "de", "fr" -> normalized;
|
||||||
|
default -> "it";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
|
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
|
||||||
if (entry.modelAsset() == null) {
|
if (entry.modelAsset() == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -492,7 +661,9 @@ 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, ProductEntry> entriesByPublicPath,
|
||||||
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.printcalculator.service.shop;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.ShopProduct;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
final class ShopPublicPathSupport {
|
||||||
|
private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/";
|
||||||
|
|
||||||
|
private ShopPublicPathSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildProductPathSegment(ShopProduct product, String language) {
|
||||||
|
String localizedName = product.getNameForLanguage(language);
|
||||||
|
String idPrefix = productIdPrefix(product.getId());
|
||||||
|
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
|
||||||
|
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String> buildLocalizedProductPaths(ShopProduct product) {
|
||||||
|
Map<String, String> localizedPaths = new LinkedHashMap<>();
|
||||||
|
for (String language : ShopProduct.SUPPORTED_LANGUAGES) {
|
||||||
|
localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language));
|
||||||
|
}
|
||||||
|
return localizedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String productIdPrefix(UUID productId) {
|
||||||
|
if (productId == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
|
||||||
|
int dashIndex = raw.indexOf('-');
|
||||||
|
if (dashIndex > 0) {
|
||||||
|
return raw.substring(0, dashIndex);
|
||||||
|
}
|
||||||
|
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String slugify(String rawValue) {
|
||||||
|
String safeValue = rawValue == null ? "" : rawValue;
|
||||||
|
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{M}+", "")
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
|
.replaceAll("^-+|-+$", "")
|
||||||
|
.replaceAll("-{2,}", "-");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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.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.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 static final Map<String, String> HREFLANG_BY_LANGUAGE = Map.of(
|
||||||
|
"it", "it-CH",
|
||||||
|
"en", "en-CH",
|
||||||
|
"de", "de-CH",
|
||||||
|
"fr", "fr-CH"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final ShopCategoryRepository shopCategoryRepository;
|
||||||
|
private final 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 = ShopPublicPathSupport.buildProductPathSegment(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(HREFLANG_BY_LANGUAGE.getOrDefault(language, 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 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,14 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
|
|||||||
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
||||||
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||||
|
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
|
||||||
|
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||||
|
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||||
|
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||||
|
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
|
||||||
|
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
|
||||||
|
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
|
||||||
|
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
|
||||||
|
|
||||||
# Admin back-office authentication
|
# Admin back-office authentication
|
||||||
admin.password=${ADMIN_PASSWORD}
|
admin.password=${ADMIN_PASSWORD}
|
||||||
|
|||||||
@@ -25,6 +25,21 @@
|
|||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: #444444;
|
color: #444444;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -63,7 +78,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1>Nuova richiesta di contatto</h1>
|
<h1>Nuova richiesta di contatto</h1>
|
||||||
|
</div>
|
||||||
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
|
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -25,6 +25,21 @@
|
|||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
color: #222222;
|
color: #222222;
|
||||||
@@ -69,7 +84,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">We received your contact request</h1>
|
<h1 th:text="${headlineText}">We received your contact request</h1>
|
||||||
|
</div>
|
||||||
<p th:text="${greetingText}">Hi customer,</p>
|
<p th:text="${greetingText}">Hi customer,</p>
|
||||||
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
|
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -67,6 +76,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
|
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
|
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
|
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
|
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.controller.admin.AdminAuthController;
|
import com.printcalculator.controller.admin.AdminAuthController;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(controllers = AdminAuthController.class)
|
@WebMvcTest(controllers = AdminAuthController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class
|
AdminLoginThrottleService.class
|
||||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminAuthSecurityTest {
|
class AdminAuthSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.1");
|
req.setRemoteAddr("10.0.0.1");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.2");
|
req.setRemoteAddr("10.0.0.2");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isTooManyRequests())
|
.andExpect(status().isTooManyRequests())
|
||||||
.andExpect(jsonPath("$.authenticated").value(false));
|
.andExpect(jsonPath("$.authenticated").value(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.30");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
|
||||||
|
mockMvc.perform(options("/api/admin/auth/login")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/auth/me"))
|
mockMvc.perform(get("/api/admin/auth/me"))
|
||||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.4");
|
req.setRemoteAddr("10.0.0.4");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminOrderControllerSecurityTest {
|
class AdminOrderControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
|
import com.printcalculator.config.SecurityConfig;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
|
import com.printcalculator.security.AdminSessionService;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
|
AdminSessionAuthenticationFilter.class,
|
||||||
|
AdminSessionService.class,
|
||||||
|
AdminLoginThrottleService.class,
|
||||||
|
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
|
||||||
|
})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"admin.password=test-admin-password",
|
||||||
|
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"admin.session.ttl-minutes=60"
|
||||||
|
})
|
||||||
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withAdminCookieAndMissingOrigin_shouldReturn403() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage("it");
|
||||||
|
response.setTargetLanguages(List.of("en", "de", "fr"));
|
||||||
|
response.setNames(Map.of("en", "Desk cable clip"));
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
|
||||||
|
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"sourceLanguage":"it",
|
||||||
|
"overwriteExisting":false,
|
||||||
|
"materialCodes":["PLA"],
|
||||||
|
"names":{"it":"Supporto cavo"},
|
||||||
|
"excerpts":{"it":"Accessorio tecnico"},
|
||||||
|
"descriptions":{"it":"<p>Descrizione</p>"},
|
||||||
|
"seoTitles":{"it":"SEO IT"},
|
||||||
|
"seoDescriptions":{"it":"SEO description IT"}
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.sourceLanguage").value("it"))
|
||||||
|
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
|
||||||
|
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie loginAndExtractCookie() throws Exception {
|
||||||
|
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.44");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||||
|
assertNotNull(setCookie);
|
||||||
|
String[] parts = setCookie.split(";", 2);
|
||||||
|
String[] keyValue = parts[0].split("=", 2);
|
||||||
|
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class TransactionTestConfig {
|
||||||
|
@Bean
|
||||||
|
PlatformTransactionManager transactionManager() {
|
||||||
|
return new AbstractPlatformTransactionManager() {
|
||||||
|
@Override
|
||||||
|
protected Object doGetTransaction() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doBegin(Object transaction, TransactionDefinition definition) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doCommit(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doRollback(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AdminShopProductTranslationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
List<JsonNode> capturedRequests = new CopyOnWriteArrayList<>();
|
||||||
|
AtomicInteger requestCounter = new AtomicInteger();
|
||||||
|
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||||
|
server.createContext("/v1/responses", exchange -> {
|
||||||
|
capturedRequests.add(readBody(objectMapper, exchange));
|
||||||
|
int currentRequest = requestCounter.incrementAndGet();
|
||||||
|
String functionName = currentRequest == 1
|
||||||
|
? "generate_product_translations"
|
||||||
|
: "review_product_translations";
|
||||||
|
String body = functionResponse(
|
||||||
|
objectMapper,
|
||||||
|
functionName,
|
||||||
|
Map.of(
|
||||||
|
"en", localized("Desk cable clip", "Technical desk accessory", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
|
||||||
|
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
|
||||||
|
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
writeJsonResponse(exchange, body);
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001")))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
"Use concise ecommerce wording."
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setMaterialCodes(List.of("pla", "petg"));
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania | 3D fab",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
|
||||||
|
assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages());
|
||||||
|
assertEquals("Desk cable clip", response.getNames().get("en"));
|
||||||
|
assertTrue(response.getDescriptions().get("en").contains("<p>"));
|
||||||
|
assertEquals(2, capturedRequests.size());
|
||||||
|
assertEquals("required", capturedRequests.get(0).path("tool_choice").asText());
|
||||||
|
assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText());
|
||||||
|
assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:65535/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "Desk cable clip",
|
||||||
|
"de", "Schreibtisch-Kabelhalter",
|
||||||
|
"fr", "Support de cable de bureau"
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "Technical desk accessory",
|
||||||
|
"de", "Technisches Schreibtisch-Zubehor",
|
||||||
|
"fr", "Accessoire technique de bureau"
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Descrizione</p>",
|
||||||
|
"en", "<p>Description</p>",
|
||||||
|
"de", "<p>Beschreibung</p>",
|
||||||
|
"fr", "<p>Description</p>"
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "SEO IT",
|
||||||
|
"en", "SEO EN",
|
||||||
|
"de", "SEO DE",
|
||||||
|
"fr", "SEO FR"
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "SEO description IT",
|
||||||
|
"en", "SEO description EN",
|
||||||
|
"de", "SEO description DE",
|
||||||
|
"fr", "SEO description FR"
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
assertTrue(response.getTargetLanguages().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException {
|
||||||
|
return objectMapper.readTree(exchange.getRequestBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeJsonResponse(HttpExchange exchange, String body) throws IOException {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream outputStream = exchange.getResponseBody()) {
|
||||||
|
outputStream.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String functionResponse(ObjectMapper objectMapper,
|
||||||
|
String functionName,
|
||||||
|
Map<String, Map<String, String>> translations) throws IOException {
|
||||||
|
Map<String, Object> arguments = Map.of("translations", translations);
|
||||||
|
Map<String, Object> item = Map.of(
|
||||||
|
"type", "function_call",
|
||||||
|
"name", functionName,
|
||||||
|
"arguments", objectMapper.writeValueAsString(arguments)
|
||||||
|
);
|
||||||
|
Map<String, Object> response = Map.of(
|
||||||
|
"id", "resp_test",
|
||||||
|
"output", List.of(item)
|
||||||
|
);
|
||||||
|
return objectMapper.writeValueAsString(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> localized(String name,
|
||||||
|
String excerpt,
|
||||||
|
String description,
|
||||||
|
String seoTitle,
|
||||||
|
String seoDescription) {
|
||||||
|
return Map.of(
|
||||||
|
"name", name,
|
||||||
|
"excerpt", excerpt,
|
||||||
|
"description", description,
|
||||||
|
"seoTitle", seoTitle,
|
||||||
|
"seoDescription", seoDescription
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.printcalculator.service.shop;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.ShopProductCatalogResponseDto;
|
||||||
|
import com.printcalculator.dto.ShopProductDetailDto;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.entity.ShopProduct;
|
||||||
|
import com.printcalculator.entity.ShopProductVariant;
|
||||||
|
import com.printcalculator.repository.FilamentVariantRepository;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductModelAssetRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductVariantRepository;
|
||||||
|
import com.printcalculator.service.media.PublicMediaQueryService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PublicShopCatalogServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductRepository shopProductRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductVariantRepository shopProductVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductModelAssetRepository shopProductModelAssetRepository;
|
||||||
|
@Mock
|
||||||
|
private FilamentVariantRepository filamentVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private PublicMediaQueryService publicMediaQueryService;
|
||||||
|
@Mock
|
||||||
|
private ShopStorageService shopStorageService;
|
||||||
|
|
||||||
|
private PublicShopCatalogService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new PublicShopCatalogService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
shopProductRepository,
|
||||||
|
shopProductVariantRepository,
|
||||||
|
shopProductModelAssetRepository,
|
||||||
|
filamentVariantRepository,
|
||||||
|
publicMediaQueryService,
|
||||||
|
shopStorageService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductCatalog_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
|
||||||
|
|
||||||
|
assertEquals(1, response.products().size());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProduct_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldResolveLocalizedSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(404, exception.getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
||||||
|
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
||||||
|
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
||||||
|
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
|
||||||
|
.thenReturn(List.of(variant));
|
||||||
|
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
|
||||||
|
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
|
||||||
|
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopCategory buildCategory() {
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
|
||||||
|
category.setSlug("accessori");
|
||||||
|
category.setName("Accessori");
|
||||||
|
category.setNameIt("Accessori");
|
||||||
|
category.setNameEn("Accessories");
|
||||||
|
category.setIsActive(true);
|
||||||
|
category.setSortOrder(0);
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProduct buildProduct(ShopCategory category) {
|
||||||
|
ShopProduct product = new ShopProduct();
|
||||||
|
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
|
||||||
|
product.setCategory(category);
|
||||||
|
product.setSlug("bike-wall-hanger");
|
||||||
|
product.setName("Bike Wall-Hanger");
|
||||||
|
product.setNameIt("Supporto bici");
|
||||||
|
product.setNameEn("Bike Wall-Hanger");
|
||||||
|
product.setIsActive(true);
|
||||||
|
product.setIsFeatured(true);
|
||||||
|
product.setSortOrder(0);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProductVariant buildVariant(ShopProduct product) {
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||||
|
variant.setProduct(product);
|
||||||
|
variant.setVariantLabel("PLA");
|
||||||
|
variant.setColorName("Grigio");
|
||||||
|
variant.setInternalMaterialCode("PLA");
|
||||||
|
variant.setPriceChf(new BigDecimal("29.90"));
|
||||||
|
variant.setIsActive(true);
|
||||||
|
variant.setIsDefault(true);
|
||||||
|
variant.setSortOrder(0);
|
||||||
|
return variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-CH\" 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-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
||||||
|
assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
||||||
|
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
|
||||||
|
assertTrue(xml.contains("<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
134
db.sql
@@ -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(),
|
||||||
|
|||||||
@@ -29,11 +29,17 @@ services:
|
|||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-}
|
||||||
|
- OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-}
|
||||||
|
- OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-}
|
||||||
|
- OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-}
|
||||||
|
- OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-}
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
||||||
- 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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
901
frontend/package-lock.json
generated
901
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 31 KiB |
@@ -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
|
||||||
|
|||||||
23
frontend/public/site.webmanifest
Normal file
23
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "3D fab",
|
||||||
|
"short_name": "3D fab",
|
||||||
|
"description": "Stampa 3D su misura con preventivo online immediato.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/images/Fav-icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/images/Fav-icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
330
frontend/public/sitemap-static.xml
Normal file
330
frontend/public/sitemap-static.xml
Normal 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-CH" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
|
@if (siteIntroState() !== "hidden") {
|
||||||
|
<div
|
||||||
|
class="site-intro"
|
||||||
|
[class.site-intro--closing]="siteIntroState() === 'closing'"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<app-brand-animation-logo
|
||||||
|
class="site-intro__logo"
|
||||||
|
variant="site-intro"
|
||||||
|
></app-brand-animation-logo>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.site-intro {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.24s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro--closing {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro__logo {
|
||||||
|
width: min(calc(100vw - 2rem), 23rem);
|
||||||
|
--brand-animation-width: 23rem;
|
||||||
|
--brand-animation-height: 7.1rem;
|
||||||
|
--brand-animation-letter-width: 3.75rem;
|
||||||
|
--brand-animation-scale: 0.88;
|
||||||
|
--brand-animation-width-mobile: 16.8rem;
|
||||||
|
--brand-animation-height-mobile: 5.3rem;
|
||||||
|
--brand-animation-letter-width-mobile: 2.8rem;
|
||||||
|
--brand-animation-scale-mobile: 0.68;
|
||||||
|
--brand-animation-site-intro-duration: 1.05s;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
transition:
|
||||||
|
opacity 0.24s ease-out,
|
||||||
|
transform 0.24s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro--closing .site-intro__logo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
Inject,
|
||||||
|
Optional,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { SeoService } from './core/services/seo.service';
|
import { SeoService } from './core/services/seo.service';
|
||||||
|
import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, BrandAnimationLogoComponent],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
readonly siteIntroState = signal<'hidden' | 'active' | 'closing'>('hidden');
|
||||||
|
|
||||||
|
constructor(@Optional() @Inject(PLATFORM_ID) platformId?: Object) {
|
||||||
|
if (!isPlatformBrowser(platformId ?? 'browser')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterNextRender(() => {
|
||||||
|
this.siteIntroState.set('active');
|
||||||
|
|
||||||
|
const closeTimeoutId = window.setTimeout(() => {
|
||||||
|
this.siteIntroState.set('closing');
|
||||||
|
}, 1020);
|
||||||
|
|
||||||
|
const hideTimeoutId = window.setTimeout(() => {
|
||||||
|
this.siteIntroState.set('hidden');
|
||||||
|
}, 1280);
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
window.clearTimeout(closeTimeoutId);
|
||||||
|
window.clearTimeout(hideTimeoutId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
frontend/src/app/app.config.server.ts
Normal file
9
frontend/src/app/app.config.server.ts
Normal 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);
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
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';
|
||||||
|
import {
|
||||||
|
getNavigatorLanguagePreferences,
|
||||||
|
parseAcceptLanguage,
|
||||||
|
resolveInitialLanguage,
|
||||||
|
SUPPORTED_LANGS,
|
||||||
|
} from './core/i18n/language-resolution';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -26,22 +43,73 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withComponentInputBinding(),
|
withComponentInputBinding(),
|
||||||
withViewTransitions(),
|
withViewTransitions(),
|
||||||
withInMemoryScrolling({
|
withInMemoryScrolling({
|
||||||
scrollPositionRestoration: 'top',
|
scrollPositionRestoration: 'enabled',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideHttpClient(withInterceptors([adminAuthInterceptor])),
|
provideHttpClient(
|
||||||
provideTranslateHttpLoader({
|
withInterceptors([serverOriginInterceptor, adminAuthInterceptor]),
|
||||||
prefix: './assets/i18n/',
|
),
|
||||||
suffix: '.json',
|
|
||||||
}),
|
|
||||||
importProvidersFrom(
|
importProvidersFrom(
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
defaultLanguage: 'it',
|
fallbackLang: '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;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
translate.addLangs([...SUPPORTED_LANGS]);
|
||||||
|
translate.setFallbackLang('it');
|
||||||
|
const requestedUrl =
|
||||||
|
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
||||||
|
const lang = resolveInitialLanguage({
|
||||||
|
url: requestedUrl,
|
||||||
|
preferredLanguages: request
|
||||||
|
? parseAcceptLanguage(readRequestHeader(request, 'accept-language'))
|
||||||
|
: getNavigatorLanguagePreferences(
|
||||||
|
typeof navigator === 'undefined' ? null : navigator,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
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()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readRequestHeader(
|
||||||
|
request: {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
} | null,
|
||||||
|
headerName: string,
|
||||||
|
): string | null {
|
||||||
|
if (!request?.headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = request.headers[headerName.toLowerCase()];
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: '3D fab | Stampa 3D su misura',
|
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
|
||||||
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,9 +26,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CALCULATOR_ROUTES,
|
(m) => m.CALCULATOR_ROUTES,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -28,9 +35,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Shop 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
|
||||||
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -38,19 +44,28 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Chi siamo | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||||
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/* {
|
||||||
|
path: 'materials',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/materials/materials-page.component').then(
|
||||||
|
(m) => m.MaterialsPageComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
|
||||||
|
},
|
||||||
|
},*/
|
||||||
{
|
{
|
||||||
path: 'contact',
|
path: 'contact',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Contatti | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
|
||||||
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,7 +75,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Checkout | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,7 +87,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Checkout | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,7 +97,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Ordine | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -89,7 +107,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Ordine | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -103,7 +122,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Admin | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,8 +134,34 @@ const appChildRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: ':lang/calculator/animation-test',
|
||||||
|
canMatch: [langPrefixCanMatch],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/calculator/calculator-animation-test.component').then(
|
||||||
|
(m) => m.CalculatorAnimationTestComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'calculator/animation-test',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/calculator/calculator-animation-test.component').then(
|
||||||
|
(m) => m.CalculatorAnimationTestComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':lang',
|
path: ':lang',
|
||||||
|
canMatch: [langPrefixCanMatch],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
|
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
|
||||||
children: appChildRoutes,
|
children: appChildRoutes,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
135
frontend/src/app/core/i18n/language-resolution.ts
Normal file
135
frontend/src/app/core/i18n/language-resolution.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGS: readonly SupportedLang[] = [
|
||||||
|
'it',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
];
|
||||||
|
|
||||||
|
type InitialLanguageOptions = {
|
||||||
|
url?: string | null;
|
||||||
|
preferredLanguages?: readonly string[] | null;
|
||||||
|
fallbackLang?: SupportedLang;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavigatorLike = {
|
||||||
|
language?: string;
|
||||||
|
languages?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveInitialLanguage({
|
||||||
|
url,
|
||||||
|
preferredLanguages,
|
||||||
|
fallbackLang = 'it',
|
||||||
|
}: InitialLanguageOptions): SupportedLang {
|
||||||
|
const explicitLang = resolveExplicitLanguageFromUrl(url);
|
||||||
|
if (explicitLang) {
|
||||||
|
return explicitLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of preferredLanguages ?? []) {
|
||||||
|
const normalized = normalizeSupportedLanguage(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAcceptLanguage(
|
||||||
|
header: string | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!header) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
|
.split(',')
|
||||||
|
.map((entry, index) => {
|
||||||
|
const [rawTag, ...params] = entry.split(';').map((part) => part.trim());
|
||||||
|
if (!rawTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityParam = params.find((param) => param.startsWith('q='));
|
||||||
|
const quality = qualityParam
|
||||||
|
? Number.parseFloat(qualityParam.slice(2))
|
||||||
|
: 1;
|
||||||
|
return {
|
||||||
|
tag: rawTag,
|
||||||
|
quality: Number.isFinite(quality) ? quality : 0,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
): entry is {
|
||||||
|
tag: string;
|
||||||
|
quality: number;
|
||||||
|
index: number;
|
||||||
|
} => entry !== null && entry.quality > 0,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) => right.quality - left.quality || left.index - right.index,
|
||||||
|
)
|
||||||
|
.map((entry) => entry.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigatorLanguagePreferences(
|
||||||
|
navigatorLike: NavigatorLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!navigatorLike) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedLanguages = [
|
||||||
|
...(Array.isArray(navigatorLike.languages) ? navigatorLike.languages : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof navigatorLike.language === 'string' &&
|
||||||
|
navigatorLike.language &&
|
||||||
|
!orderedLanguages.includes(navigatorLike.language)
|
||||||
|
) {
|
||||||
|
orderedLanguages.push(navigatorLike.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExplicitLanguageFromUrl(
|
||||||
|
url: string | null | undefined,
|
||||||
|
): SupportedLang | null {
|
||||||
|
const normalizedUrl = String(url ?? '/');
|
||||||
|
const [pathAndQuery] = normalizedUrl.split('#', 1);
|
||||||
|
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
|
||||||
|
const firstSegment = rawPath.split('/').filter(Boolean)[0];
|
||||||
|
const pathLanguage = normalizeSupportedLanguage(firstSegment);
|
||||||
|
if (pathLanguage) {
|
||||||
|
return pathLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryLanguage = new URLSearchParams(rawQuery ?? '').get('lang');
|
||||||
|
return normalizeSupportedLanguage(queryLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSupportedLanguage(
|
||||||
|
rawLanguage: string | null | undefined,
|
||||||
|
): SupportedLang | null {
|
||||||
|
if (typeof rawLanguage !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawLanguage.trim().toLowerCase();
|
||||||
|
if (!normalized || normalized === '*') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [baseLanguage] = normalized.split('-', 1);
|
||||||
|
return SUPPORTED_LANGS.includes(baseLanguage as SupportedLang)
|
||||||
|
? (baseLanguage as SupportedLang)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
93
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
93
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
TransferState,
|
||||||
|
inject,
|
||||||
|
makeStateKey,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
|
||||||
|
import { from, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
|
const FALLBACK_LANG: SupportedLang = 'it';
|
||||||
|
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
|
||||||
|
|
||||||
|
const translationLoaders: Record<
|
||||||
|
SupportedLang,
|
||||||
|
() => Promise<TranslationObject>
|
||||||
|
> = {
|
||||||
|
it: () =>
|
||||||
|
import('../../../assets/i18n/it.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
en: () =>
|
||||||
|
import('../../../assets/i18n/en.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
de: () =>
|
||||||
|
import('../../../assets/i18n/de.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
fr: () =>
|
||||||
|
import('../../../assets/i18n/fr.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StaticTranslateLoader implements TranslateLoader {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
|
||||||
|
getTranslation(lang: string): Observable<TranslationObject> {
|
||||||
|
const normalized = this.normalizeLanguage(lang);
|
||||||
|
return from(this.loadTranslation(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(lang: string): SupportedLang {
|
||||||
|
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
|
||||||
|
return normalized in translationLoaders
|
||||||
|
? (normalized as SupportedLang)
|
||||||
|
: FALLBACK_LANG;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
|
||||||
|
const transferStateKey = makeStateKey<TranslationObject>(
|
||||||
|
`i18n:${lang.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
isPlatformBrowser(this.platformId) &&
|
||||||
|
this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
const transferred = this.transferState.get(transferStateKey, {});
|
||||||
|
this.transferState.remove(transferStateKey);
|
||||||
|
return Promise.resolve(transferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = translationCache.get(lang);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = translationLoaders[lang]()
|
||||||
|
.then((translation) => {
|
||||||
|
if (
|
||||||
|
isPlatformServer(this.platformId) &&
|
||||||
|
!this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
this.transferState.set(transferStateKey, translation);
|
||||||
|
}
|
||||||
|
return translation;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
lang === FALLBACK_LANG
|
||||||
|
? Promise.resolve({})
|
||||||
|
: this.loadTranslation(FALLBACK_LANG),
|
||||||
|
);
|
||||||
|
|
||||||
|
translationCache.set(lang, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject, REQUEST } from '@angular/core';
|
||||||
|
import {
|
||||||
|
RequestLike,
|
||||||
|
resolveRequestOrigin,
|
||||||
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
const FORWARDED_REQUEST_HEADERS = [
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'accept-language',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
|
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(url: string): string {
|
||||||
|
const withoutDot = url.replace(/^\.\//, '');
|
||||||
|
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestHeader(
|
||||||
|
request: RequestLike | null,
|
||||||
|
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||||
|
): string | null {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const headerValue =
|
||||||
|
request?.headers?.[normalizedName] ?? request?.get?.(normalizedName);
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (isAbsoluteUrl(req.url)) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
||||||
|
const origin = resolveRequestOrigin(request);
|
||||||
|
if (!origin) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
||||||
|
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||||
|
Record<string, string>
|
||||||
|
>((headers, name) => {
|
||||||
|
if (req.headers.has(name)) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = readRequestHeader(request, name);
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return next(
|
||||||
|
req.clone({
|
||||||
|
url: absoluteUrl,
|
||||||
|
setHeaders: forwardedHeaders,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<span class="brand">3D fab</span>
|
<img
|
||||||
|
class="brand"
|
||||||
|
src="/assets/images/brand-logo-white.svg"
|
||||||
|
alt="3D Fab"
|
||||||
|
/>
|
||||||
<p class="copyright">© 2026 3D fab.</p>
|
<p class="copyright">© 2026 3D fab.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col links">
|
<div class="col links">
|
||||||
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
|
<a [routerLink]="languageService.localizedPath('/privacy')">{{
|
||||||
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
|
"FOOTER.PRIVACY" | translate
|
||||||
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
|
}}</a>
|
||||||
|
<a [routerLink]="languageService.localizedPath('/terms')">{{
|
||||||
|
"FOOTER.TERMS" | translate
|
||||||
|
}}</a>
|
||||||
|
<a [routerLink]="languageService.localizedPath('/contact')">{{
|
||||||
|
"FOOTER.CONTACT" | translate
|
||||||
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
|
||||||
color: white;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 1.85rem;
|
||||||
|
max-width: min(9.25rem, 46vw);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
.copyright {
|
.copyright {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
|
|||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrls: ['./footer.component.scss'],
|
styleUrls: ['./footer.component.scss'],
|
||||||
})
|
})
|
||||||
export class FooterComponent {}
|
export class FooterComponent {
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<header class="navbar">
|
<header class="navbar">
|
||||||
<div class="container navbar-inner">
|
<div class="container navbar-inner">
|
||||||
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
|
<a [routerLink]="langService.localizedPath('/')" class="brand">
|
||||||
|
<img
|
||||||
|
class="brand-logo"
|
||||||
|
ngSrc="/assets/images/Asset%202.svg"
|
||||||
|
alt="3D Fab"
|
||||||
|
width="380"
|
||||||
|
height="86"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mobile-toggle"
|
class="mobile-toggle"
|
||||||
@@ -14,27 +23,33 @@
|
|||||||
|
|
||||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||||
<a
|
<a
|
||||||
routerLink="/"
|
[routerLink]="langService.localizedPath('/')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.HOME" | translate }}</a
|
>{{ "NAV.HOME" | translate }}</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
routerLink="/calculator/basic"
|
[routerLink]="langService.localizedPath('/calculator/basic')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.CALCULATOR" | translate }}</a
|
>{{ "NAV.CALCULATOR" | translate }}</a
|
||||||
>
|
>
|
||||||
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
|
|
||||||
"NAV.SHOP" | translate
|
|
||||||
}}</a>
|
|
||||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
|
|
||||||
"NAV.ABOUT" | translate
|
|
||||||
}}</a>
|
|
||||||
<a
|
<a
|
||||||
routerLink="/contact"
|
[routerLink]="langService.localizedPath('/shop')"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeMenu()"
|
||||||
|
>{{ "NAV.SHOP" | translate }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="langService.localizedPath('/about')"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeMenu()"
|
||||||
|
>{{ "NAV.ABOUT" | translate }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="langService.localizedPath('/contact')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.CONTACT" | translate }}</a
|
>{{ "NAV.CONTACT" | translate }}</a
|
||||||
@@ -82,7 +97,10 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="icon-placeholder" routerLink="/admin">
|
<div
|
||||||
|
class="icon-placeholder"
|
||||||
|
[routerLink]="langService.localizedPath('/admin')"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="20"
|
||||||
@@ -130,7 +148,9 @@
|
|||||||
<div class="cart-line-copy">
|
<div class="cart-line-copy">
|
||||||
<strong>{{ cartItemName(item) }}</strong>
|
<strong>{{ cartItemName(item) }}</strong>
|
||||||
@if (cartItemVariant(item); as variant) {
|
@if (cartItemVariant(item); as variant) {
|
||||||
<span class="cart-line-meta">{{ variant }}</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 +158,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>
|
||||||
|
|||||||
@@ -14,13 +14,16 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.25rem;
|
display: inline-flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.highlight {
|
|
||||||
color: var(--color-brand);
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 2.1rem;
|
||||||
|
max-width: min(11rem, 40vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
NavigationStart,
|
NavigationStart,
|
||||||
@@ -15,11 +22,21 @@ 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',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
TranslateModule,
|
||||||
|
NgOptimizedImage,
|
||||||
|
],
|
||||||
templateUrl: './navbar.component.html',
|
templateUrl: './navbar.component.html',
|
||||||
styleUrls: ['./navbar.component.scss'],
|
styleUrls: ['./navbar.component.scss'],
|
||||||
})
|
})
|
||||||
@@ -54,16 +71,9 @@ export class NavbarComponent {
|
|||||||
];
|
];
|
||||||
|
|
||||||
constructor(public langService: LanguageService) {
|
constructor(public langService: LanguageService) {
|
||||||
if (!this.shopService.cartLoaded()) {
|
afterNextRender(() => {
|
||||||
this.shopService
|
this.scheduleCartWarmup();
|
||||||
.loadCart()
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe({
|
|
||||||
error: () => {
|
|
||||||
this.shopService.cart.set(null);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -92,6 +102,9 @@ export class NavbarComponent {
|
|||||||
toggleCart(): void {
|
toggleCart(): void {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
this.isCartOpen.update((open) => !open);
|
this.isCartOpen.update((open) => !open);
|
||||||
|
if (this.isCartOpen()) {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeCart(): void {
|
closeCart(): void {
|
||||||
@@ -129,7 +142,7 @@ export class NavbarComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.closeCart();
|
this.closeCart();
|
||||||
this.router.navigate(['/checkout'], {
|
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
},
|
},
|
||||||
@@ -143,15 +156,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 {
|
||||||
@@ -173,5 +201,44 @@ export class NavbarComponent {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleCartWarmup(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmup = () => this.loadCartIfNeeded();
|
||||||
|
const idleCallback = (
|
||||||
|
window as Window & {
|
||||||
|
requestIdleCallback?: (
|
||||||
|
callback: IdleRequestCallback,
|
||||||
|
options?: IdleRequestOptions,
|
||||||
|
) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
|
|
||||||
|
if (typeof idleCallback === 'function') {
|
||||||
|
idleCallback(() => warmup(), { timeout: 1500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(warmup, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCartIfNeeded(): void {
|
||||||
|
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shopService
|
||||||
|
.loadCart()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.shopService.cart.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly routes = routes;
|
protected readonly routes = routes;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user