Compare commits
161 Commits
7c82cf0d3f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00208f08c9 | ||
| b79068623f | |||
|
|
fd538f376f | ||
|
|
3eed3d251f | ||
|
|
cefb3c54c3 | ||
| 7dcc85ac95 | |||
| 79bd33fe41 | |||
| ec9eb0dc7d | |||
| 01cafd5904 | |||
| 321e2fd546 | |||
| 696e0ac817 | |||
| 888ddc1362 | |||
| 3026f0a13f | |||
| 52d17e5ad8 | |||
| 2803e910bd | |||
| 653ce83c33 | |||
| ce618deecf | |||
| 5331ce7866 | |||
|
|
6f6d033be3 | ||
|
|
ff8536b448 | ||
|
|
60593f6c11 | ||
|
|
1708c1bead | ||
|
|
dc33d762a1 | ||
|
|
e04cac3345 | ||
|
|
00f45ae6c7 | ||
|
|
1a5d3a570a | ||
|
|
9763289c2f | ||
|
|
fd6c730ae3 | ||
|
|
eb94697955 | ||
|
|
a72957648e | ||
|
|
b15c331295 | ||
|
|
503cbee641 | ||
|
|
078cef0585 | ||
|
|
48f7e84ef9 | ||
|
|
f975e57110 | ||
|
|
389beca604 | ||
|
|
fa7a1c2f26 | ||
|
|
df98dfe38e | ||
|
|
65559bbb48 | ||
|
|
f317d15ac5 | ||
|
|
68ccb164e2 | ||
|
|
a8f9c5f49a | ||
|
|
ff331e1630 | ||
|
|
d076286728 | ||
|
|
de942e0d96 | ||
|
|
ce3389f2e6 | ||
|
|
8680b2fc92 | ||
|
|
5068390a14 | ||
|
|
4fe16b0cb1 | ||
|
|
42c1e655f1 | ||
|
|
fdb6c40bb9 | ||
|
|
72f3791616 | ||
|
|
db8085c0aa | ||
|
|
177eb2eb5c | ||
|
|
bceedc8620 | ||
|
|
14a6f66742 | ||
|
|
28faf2ed2b | ||
|
|
02387e9a50 | ||
|
|
c09316189e | ||
|
|
ad441b8dbc | ||
|
|
9759f2cf8e | ||
|
|
16bd098954 | ||
|
|
7e5f75e482 | ||
|
|
d866924130 | ||
|
|
9eb8256fd7 | ||
|
|
e5411f2fdc | ||
|
|
d64fed8157 | ||
|
|
007cb34c81 | ||
|
|
aead87b1bc | ||
|
|
e30fb83043 | ||
|
|
44764a5f14 | ||
|
|
4c16e356a3 | ||
|
|
8a750b94d0 | ||
|
|
9ae60a087a | ||
|
|
9d2f89f805 | ||
|
|
d802418c29 | ||
| 504fb4fe8e | |||
|
|
5c42db7540 | ||
|
|
14e19ac2ea | ||
|
|
136f9c1732 | ||
|
|
60ce19f72f | ||
| 659b16f700 | |||
| 664123cc22 | |||
| fec615db26 | |||
| 6388db1026 | |||
| 35a5f0d755 | |||
| e6efbdeafe | |||
| dba9e19c6c | |||
| 9b2a4b55a2 | |||
| ad66b1bf31 | |||
| 785c482057 | |||
| bbd1b94524 | |||
| 9d07e4d14e | |||
| 8088d3efb7 | |||
|
|
e5adb9356f | ||
|
|
b8aa3e61ed | ||
|
|
411c407a40 | ||
| 9e350ec2b5 | |||
| 6427f08ed8 | |||
| 40eb58aa4d | |||
| c9d8186f21 | |||
| 8e5819db38 | |||
| 1659ac6ad2 | |||
| 0d68975f41 | |||
| 0d71596e70 | |||
| 03661021cc | |||
| 82de928f3e | |||
| bfa90c5b31 | |||
| 136532947f | |||
| 7028d1094b | |||
| 00fd7e2069 | |||
| d388ce2d1d | |||
| 939bb6159c | |||
| 3a6b26ac38 | |||
| dabfd03d0c | |||
| d8410b7463 | |||
| d526b8ab39 | |||
| 687400ebd9 | |||
| 44abcda2e8 | |||
| 6e47c4b4d9 | |||
| 6cc3423451 | |||
| 1cda6f4660 | |||
| 2a4b71f52f | |||
| edd8011efc | |||
| f2ba047fc4 | |||
| 9b8c8b05b3 | |||
| 30e740e70f | |||
| 669980ea93 | |||
| 1a670ae930 | |||
| b42b4fd015 | |||
| d6dba27b16 | |||
| 9a27cd3789 | |||
| b9c4a7fdb4 | |||
| b888089e22 | |||
|
|
bfe7176388 | ||
| facfd5d32b | |||
| 97f97450f4 | |||
| dd0478970b | |||
|
|
f44ca08f6a | ||
| dc21f3820c | |||
| 9a8e59e07e | |||
|
|
669a4cbe00 | ||
| b98995b7ae | |||
| a40fd2c7ac | |||
|
|
d73275572f | ||
|
|
ad6567efa2 | ||
| f8358594d5 | |||
| 1261e90fd7 | |||
| 60f6ac4823 | |||
| 734320a405 | |||
|
|
6f05f66ea6 | ||
|
|
e5cce7668f | ||
|
|
b16eff2e76 | ||
|
|
8cdfab9596 | ||
|
|
f9a9e81713 | ||
|
|
f4696b5f5b | ||
|
|
d94ce06d95 | ||
|
|
103e4c055d | ||
|
|
005bbcc678 | ||
|
|
b9320c7383 | ||
|
|
1efe158631 |
@@ -46,7 +46,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // autoriser les preflight
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/api/users/**").authenticated()
|
.requestMatchers("/api/users/**").authenticated()
|
||||||
.requestMatchers("/api/app/**").permitAll()
|
.requestMatchers("/api/app/**").authenticated()
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
@@ -61,16 +61,26 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
config.setAllowedOriginPatterns(Arrays.asList(
|
|
||||||
"http://localhost:4200",
|
// IMPORTANT : origins explicites, sans path
|
||||||
"http://127.0.0.1:4200",
|
config.setAllowedOrigins(Arrays.asList(
|
||||||
"https://dev.vincent-guillet.fr"
|
"http://localhost:4200",
|
||||||
));
|
"http://127.0.0.1:4200",
|
||||||
config.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE","OPTIONS"));
|
"https://dev.vincent-guillet.fr",
|
||||||
config.setAllowedHeaders(Arrays.asList("Authorization","Content-Type","Accept"));
|
"https://projets.vincent-guillet.fr"
|
||||||
config.setExposedHeaders(Arrays.asList("Authorization"));
|
));
|
||||||
|
|
||||||
config.setAllowCredentials(true);
|
config.setAllowCredentials(true);
|
||||||
|
|
||||||
|
// Autoriser tous les headers côté requête (plus robuste)
|
||||||
|
config.setAllowedHeaders(Arrays.asList("*"));
|
||||||
|
|
||||||
|
// Autoriser les méthodes classiques
|
||||||
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
|
||||||
|
// Headers que le client *voit* dans la réponse
|
||||||
|
config.setExposedHeaders(Arrays.asList("Authorization", "Content-Type"));
|
||||||
|
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
source.registerCorsConfiguration("/**", config);
|
source.registerCorsConfiguration("/**", config);
|
||||||
return source;
|
return source;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package fr.gameovergne.api.config.prestashop;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "prestashop")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class PrestashopProperties {
|
||||||
|
private String baseUrl;
|
||||||
|
private String apiKey;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package fr.gameovergne.api.controller.auth;
|
package fr.gameovergne.api.controller.auth;
|
||||||
|
|
||||||
|
import fr.gameovergne.api.dto.auth.AuthRequest;
|
||||||
|
import fr.gameovergne.api.dto.auth.AuthResponse;
|
||||||
|
import fr.gameovergne.api.dto.user.UserDTO;
|
||||||
|
import fr.gameovergne.api.mapper.user.UserMapper;
|
||||||
|
import fr.gameovergne.api.service.auth.AuthService;
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import fr.gameovergne.api.dto.auth.AuthResponse;
|
|
||||||
import fr.gameovergne.api.dto.user.UserDTO;
|
|
||||||
import fr.gameovergne.api.dto.auth.AuthRequest;
|
|
||||||
import fr.gameovergne.api.mapper.user.UserMapper;
|
|
||||||
import fr.gameovergne.api.service.auth.AuthService;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -25,39 +25,52 @@ public class AuthController {
|
|||||||
this.authService = authService;
|
this.authService = authService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== REGISTER =====================
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto) {
|
public ResponseEntity<AuthResponse> register(@RequestBody UserDTO dto,
|
||||||
|
HttpServletResponse response) {
|
||||||
return authService.register(UserMapper.fromDto(dto))
|
return authService.register(UserMapper.fromDto(dto))
|
||||||
.map((ResponseEntity::ok))
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
.orElse(ResponseEntity.badRequest().build());
|
.orElse(ResponseEntity.badRequest().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== LOGIN =====================
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request, HttpServletResponse response) {
|
public ResponseEntity<AuthResponse> authenticate(@RequestBody AuthRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
return authService.authenticate(request)
|
return authService.authenticate(request)
|
||||||
.map(authResponse -> createAuthResponse(authResponse, response))
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
.orElse(ResponseEntity.badRequest().build());
|
.orElse(ResponseEntity.badRequest().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== LOGOUT =====================
|
||||||
|
|
||||||
@GetMapping("/logout")
|
@GetMapping("/logout")
|
||||||
public ResponseEntity<Void> logout(HttpServletResponse response) {
|
public ResponseEntity<Void> logout(HttpServletResponse response) {
|
||||||
// Supprime le cookie de refresh token
|
// Supprime le cookie de refresh token
|
||||||
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
|
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.secure(false) // true en prod
|
.secure(false) // true en prod derrière HTTPS
|
||||||
.path("/")
|
.path("/")
|
||||||
.maxAge(0) // Expire immédiatement
|
.maxAge(0) // expire immédiatement
|
||||||
.sameSite("Lax")
|
.sameSite("Lax")
|
||||||
.build();
|
.build();
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== ME =====================
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
public ResponseEntity<UserDTO> getCurrentUser(HttpServletRequest request) {
|
public ResponseEntity<UserDTO> getCurrentUser(HttpServletRequest request) {
|
||||||
String username = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null;
|
String username = request.getUserPrincipal() != null
|
||||||
|
? request.getUserPrincipal().getName()
|
||||||
|
: null;
|
||||||
|
|
||||||
if (username == null) {
|
if (username == null) {
|
||||||
return ResponseEntity.status(401).build(); // Unauthorized
|
return ResponseEntity.status(401).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return authService.getCurrentUser(username)
|
return authService.getCurrentUser(username)
|
||||||
@@ -65,9 +78,26 @@ public class AuthController {
|
|||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== REFRESH =====================
|
||||||
|
|
||||||
|
// Accepte POST et GET pour être robuste aux proxies / redirects bizarres
|
||||||
|
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ResponseEntity<AuthResponse> refresh(HttpServletRequest request, HttpServletResponse response) {
|
public ResponseEntity<AuthResponse> refreshPost(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return handleRefresh(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/refresh")
|
||||||
|
public ResponseEntity<AuthResponse> refreshGet(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
return handleRefresh(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<AuthResponse> handleRefresh(HttpServletRequest request,
|
||||||
|
HttpServletResponse response) {
|
||||||
String refreshToken = null;
|
String refreshToken = null;
|
||||||
|
|
||||||
if (request.getCookies() != null) {
|
if (request.getCookies() != null) {
|
||||||
refreshToken = Arrays.stream(request.getCookies())
|
refreshToken = Arrays.stream(request.getCookies())
|
||||||
.filter(c -> "refreshToken".equals(c.getName()))
|
.filter(c -> "refreshToken".equals(c.getName()))
|
||||||
@@ -75,18 +105,23 @@ public class AuthController {
|
|||||||
.map(Cookie::getValue)
|
.map(Cookie::getValue)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshToken == null) {
|
if (refreshToken == null) {
|
||||||
// Pas de cookie -> pas d'erreur réseau
|
// Pas de cookie -> on ne casse pas le front, juste 204
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
return authService.refresh(refreshToken)
|
return authService.refresh(refreshToken)
|
||||||
.map(authResponse -> createAuthResponse(authResponse, response))
|
.map(authResponse -> createAuthResponse(authResponse, response))
|
||||||
// Token inconnu/expiré -> pas d'erreur réseau non plus
|
// token expiré / invalide -> 204 aussi (pas d’erreur réseau)
|
||||||
.orElse(ResponseEntity.noContent().build());
|
.orElse(ResponseEntity.noContent().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse, HttpServletResponse response) {
|
// ===================== UTILITAIRE =====================
|
||||||
|
|
||||||
|
private ResponseEntity<AuthResponse> createAuthResponse(AuthResponse authResponse,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
// Cookie HTTP-only pour le refresh
|
||||||
ResponseCookie cookie = ResponseCookie.from("refreshToken", authResponse.refreshToken())
|
ResponseCookie cookie = ResponseCookie.from("refreshToken", authResponse.refreshToken())
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.secure(false) // true en prod
|
.secure(false) // true en prod
|
||||||
@@ -96,7 +131,7 @@ public class AuthController {
|
|||||||
.build();
|
.build();
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
|
|
||||||
// Ne pas renvoyer le refresh token dans le body
|
// On ne renvoie pas le refresh token dans le body
|
||||||
return ResponseEntity.ok(new AuthResponse(
|
return ResponseEntity.ok(new AuthResponse(
|
||||||
authResponse.username(),
|
authResponse.username(),
|
||||||
authResponse.accessToken(),
|
authResponse.accessToken(),
|
||||||
|
|||||||
@@ -2,79 +2,142 @@ package fr.gameovergne.api.controller.prestashop;
|
|||||||
|
|
||||||
import fr.gameovergne.api.service.prestashop.PrestashopClient;
|
import fr.gameovergne.api.service.prestashop.PrestashopClient;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.AntPathMatcher;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.servlet.HandlerMapping;
|
||||||
|
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/ps")
|
@RequestMapping("/api/ps")
|
||||||
public class PrestashopProxyController {
|
public class PrestashopProxyController {
|
||||||
|
|
||||||
|
Logger log = LoggerFactory.getLogger(PrestashopProxyController.class);
|
||||||
|
|
||||||
private final PrestashopClient prestashopClient;
|
private final PrestashopClient prestashopClient;
|
||||||
|
|
||||||
public PrestashopProxyController(PrestashopClient prestashopClient) {
|
public PrestashopProxyController(PrestashopClient prestashopClient) {
|
||||||
this.prestashopClient = prestashopClient;
|
this.prestashopClient = prestashopClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utilitaire pour extraire /products, /products/446, etc.
|
// --- Helpers communs ---
|
||||||
|
|
||||||
private String extractPath(HttpServletRequest request) {
|
private String extractPath(HttpServletRequest request) {
|
||||||
String fullPath = request.getRequestURI(); // ex: /api/ps/products/446
|
String fullPath = (String) request.getAttribute(
|
||||||
String contextPath = request.getContextPath(); // souvent ""
|
HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
|
||||||
String relative = fullPath.substring(contextPath.length()); // /api/ps/products/446
|
);
|
||||||
return relative.replaceFirst("^/api/ps", ""); // => /products/446
|
String bestMatchPattern = (String) request.getAttribute(
|
||||||
|
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE
|
||||||
|
);
|
||||||
|
|
||||||
|
String relativePath = new AntPathMatcher()
|
||||||
|
.extractPathWithinPattern(bestMatchPattern, fullPath);
|
||||||
|
|
||||||
|
// On renvoie toujours avec un "/" devant
|
||||||
|
return "/" + relativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractDecodedQuery(HttpServletRequest request) {
|
||||||
|
String rawQuery = request.getQueryString();
|
||||||
|
if (rawQuery != null) {
|
||||||
|
rawQuery = URLDecoder.decode(rawQuery, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
return rawQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GET ----------
|
||||||
@GetMapping("/**")
|
@GetMapping("/**")
|
||||||
public ResponseEntity<String> proxyGet(HttpServletRequest request) {
|
public ResponseEntity<String> proxyGet(HttpServletRequest request) {
|
||||||
String path = extractPath(request);
|
String path = extractPath(request);
|
||||||
String query = request.getQueryString();
|
String rawQuery = extractDecodedQuery(request);
|
||||||
|
|
||||||
String body = prestashopClient.get(path, query);
|
ResponseEntity<String> prestaResponse =
|
||||||
|
prestashopClient.getWithRawQuery(path, rawQuery);
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.ok()
|
.status(prestaResponse.getStatusCode())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.body(body);
|
.body(prestaResponse.getBody());
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/**")
|
|
||||||
public ResponseEntity<String> proxyPut(HttpServletRequest request,
|
|
||||||
@RequestBody String body) {
|
|
||||||
String path = extractPath(request);
|
|
||||||
String query = request.getQueryString();
|
|
||||||
|
|
||||||
String responseBody = prestashopClient.put(path, query, body);
|
|
||||||
|
|
||||||
return ResponseEntity
|
|
||||||
.ok()
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.body(responseBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- POST ----------
|
||||||
@PostMapping("/**")
|
@PostMapping("/**")
|
||||||
public ResponseEntity<String> proxyPost(HttpServletRequest request,
|
public ResponseEntity<String> proxyPost(HttpServletRequest request,
|
||||||
@RequestBody String body) {
|
@RequestBody String xmlBody) {
|
||||||
String path = extractPath(request);
|
String path = extractPath(request);
|
||||||
String query = request.getQueryString();
|
String rawQuery = extractDecodedQuery(request);
|
||||||
|
|
||||||
String responseBody = prestashopClient.post(path, query, body);
|
log.info("XML envoyé à Presta:\n{}", xmlBody);
|
||||||
|
|
||||||
|
ResponseEntity<String> prestaResponse =
|
||||||
|
prestashopClient.postWithRawQuery(path, rawQuery, xmlBody);
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.ok()
|
.status(prestaResponse.getStatusCode())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
.body(responseBody);
|
.body(prestaResponse.getBody());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- PUT ----------
|
||||||
|
@PutMapping("/**")
|
||||||
|
public ResponseEntity<String> proxyPut(HttpServletRequest request,
|
||||||
|
@RequestBody String xmlBody) {
|
||||||
|
String path = extractPath(request);
|
||||||
|
String rawQuery = extractDecodedQuery(request);
|
||||||
|
|
||||||
|
log.info("XML envoyé à Presta:\n{}", xmlBody);
|
||||||
|
|
||||||
|
ResponseEntity<String> prestaResponse =
|
||||||
|
prestashopClient.putWithRawQuery(path, rawQuery, xmlBody);
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.status(prestaResponse.getStatusCode())
|
||||||
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
|
.body(prestaResponse.getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DELETE ----------
|
||||||
@DeleteMapping("/**")
|
@DeleteMapping("/**")
|
||||||
public ResponseEntity<String> proxyDelete(HttpServletRequest request) {
|
public ResponseEntity<String> proxyDelete(HttpServletRequest request) {
|
||||||
String path = extractPath(request);
|
String path = extractPath(request);
|
||||||
String query = request.getQueryString();
|
String rawQuery = extractDecodedQuery(request);
|
||||||
|
|
||||||
String responseBody = prestashopClient.delete(path, query);
|
ResponseEntity<String> prestaResponse =
|
||||||
|
prestashopClient.deleteWithRawQuery(path, rawQuery);
|
||||||
|
|
||||||
return ResponseEntity
|
return ResponseEntity
|
||||||
.ok()
|
.status(prestaResponse.getStatusCode())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
.body(responseBody);
|
.body(prestaResponse.getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload d'une image produit :
|
||||||
|
* Front → (multipart/form-data) → /api/ps/images/products/{productId}
|
||||||
|
* Backend → (bytes) → https://shop.gameovergne.fr/api/images/products/{productId}
|
||||||
|
*/
|
||||||
|
@PostMapping(
|
||||||
|
path = "/images/products/{productId}",
|
||||||
|
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||||
|
produces = MediaType.APPLICATION_XML_VALUE
|
||||||
|
)
|
||||||
|
public ResponseEntity<String> uploadProductImage(
|
||||||
|
jakarta.servlet.http.HttpServletRequest request,
|
||||||
|
@PathVariable("productId") String productId,
|
||||||
|
@RequestPart("image") MultipartFile imageFile
|
||||||
|
) {
|
||||||
|
String rawQuery = request.getQueryString(); // si jamais tu ajoutes des options côté Presta
|
||||||
|
|
||||||
|
log.info("[Proxy] Upload image produit {} (size={} bytes, ct={})",
|
||||||
|
productId, imageFile.getSize(), imageFile.getContentType());
|
||||||
|
|
||||||
|
return prestashopClient.uploadProductImage(productId, rawQuery, imageFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,225 +1,366 @@
|
|||||||
package fr.gameovergne.api.service.prestashop;
|
package fr.gameovergne.api.service.prestashop;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpStatusCodeException;
|
import org.springframework.util.MultiValueMap;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestClientResponseException;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import java.util.List;
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class PrestashopClient {
|
public class PrestashopClient {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(PrestashopClient.class);
|
private static final MediaType APPLICATION_XML_UTF8 =
|
||||||
|
new MediaType("application", "xml", StandardCharsets.UTF_8);
|
||||||
private final RestTemplate restTemplate = new RestTemplate();
|
|
||||||
|
|
||||||
|
private final RestClient client;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
private final String basicAuth; // base64 SANS le "Basic "
|
private final String basicAuthHeader;
|
||||||
|
|
||||||
public PrestashopClient(
|
public PrestashopClient(
|
||||||
@Value("${prestashop.base-url}") String baseUrl,
|
@Value("${prestashop.base-url}") String baseUrl,
|
||||||
@Value("${prestashop.basic-auth}") String basicAuth
|
@Value("${prestashop.api-key}") String apiKey
|
||||||
) {
|
) {
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.basicAuth = basicAuth;
|
|
||||||
|
String basicAuth = Base64.getEncoder()
|
||||||
|
.encodeToString((apiKey + ":").getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
this.basicAuthHeader = "Basic " + basicAuth; // <--- mémorisé pour HttpURLConnection
|
||||||
|
|
||||||
|
this.client = RestClient.builder()
|
||||||
|
.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuthHeader)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("[PrestaShop] Base URL = {}", baseUrl);
|
||||||
|
log.info("[PrestaShop] API key length = {}", apiKey.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
private String buildUri(String path, MultiValueMap<String, String> params) {
|
||||||
// GET : on force JSON + full
|
|
||||||
// =========================
|
|
||||||
public String get(String path, String ignoredQuery) {
|
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder
|
UriComponentsBuilder builder = UriComponentsBuilder
|
||||||
.fromHttpUrl(baseUrl)
|
.fromHttpUrl(baseUrl + path);
|
||||||
.path("/api")
|
if (params != null && !params.isEmpty()) {
|
||||||
.path(path)
|
builder.queryParams(params);
|
||||||
.queryParam("output_format", "JSON")
|
}
|
||||||
.queryParam("display", "full");
|
return builder.build(true).toUriString();
|
||||||
|
}
|
||||||
|
|
||||||
String url = builder.build(true).toUriString();
|
// -------- Méthodes "typed" JSON / XML utilisées par ps-admin --------
|
||||||
|
|
||||||
log.info("[PrestaShop] GET {}", url);
|
public String getJson(String path, MultiValueMap<String, String> params) {
|
||||||
|
String uri = buildUri(path, params);
|
||||||
|
log.info("[PrestaShop] GET JSON {}", uri);
|
||||||
|
return client.get()
|
||||||
|
.uri(uri)
|
||||||
|
.accept(MediaType.APPLICATION_JSON)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
public String getXml(String path, MultiValueMap<String, String> params) {
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
|
String uri = buildUri(path, params);
|
||||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
log.info("[PrestaShop] GET XML {}", uri);
|
||||||
|
return client.get()
|
||||||
|
.uri(uri)
|
||||||
|
.accept(MediaType.APPLICATION_XML)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
public String postXml(String path, MultiValueMap<String, String> params, String xmlBody) {
|
||||||
|
String uri = buildUri(path, params);
|
||||||
|
log.info("[PrestaShop] POST XML {}", uri);
|
||||||
|
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return client.post()
|
||||||
|
.uri(uri)
|
||||||
|
.contentType(APPLICATION_XML_UTF8)
|
||||||
|
.body(bodyBytes)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String putXml(String path, MultiValueMap<String, String> params, String xmlBody) {
|
||||||
|
String uri = buildUri(path, params);
|
||||||
|
log.info("[PrestaShop] PUT XML {}", uri);
|
||||||
|
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
|
||||||
|
return client.put()
|
||||||
|
.uri(uri)
|
||||||
|
.contentType(APPLICATION_XML_UTF8)
|
||||||
|
.body(bodyBytes)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String path, MultiValueMap<String, String> params) {
|
||||||
|
String uri = buildUri(path, params);
|
||||||
|
log.info("[PrestaShop] DELETE {}", uri);
|
||||||
|
client.delete()
|
||||||
|
.uri(uri)
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Méthodes génériques utilisées par le proxy /api/ps/** --------
|
||||||
|
|
||||||
|
public ResponseEntity<String> getWithRawQuery(String path, String rawQuery) {
|
||||||
|
String uri = baseUrl + path;
|
||||||
|
if (rawQuery != null && !rawQuery.isBlank()) {
|
||||||
|
uri = uri + "?" + rawQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[PrestaShop] GET (proxy) {}", uri);
|
||||||
|
|
||||||
|
return client.get()
|
||||||
|
.uri(uri)
|
||||||
|
.accept(MediaType.APPLICATION_JSON)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResponseEntity<String> postWithRawQuery(String path, String rawQuery, String xmlBody) {
|
||||||
|
String uri = baseUrl + path;
|
||||||
|
if (rawQuery != null && !rawQuery.isBlank()) {
|
||||||
|
uri = uri + "?" + rawQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[PrestaShop] POST (proxy) {}", uri);
|
||||||
|
log.info("[PrestaShop] XML envoyé (proxy POST):\n{}", xmlBody);
|
||||||
|
|
||||||
|
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
return client.post()
|
||||||
url,
|
.uri(uri)
|
||||||
HttpMethod.GET,
|
.contentType(APPLICATION_XML_UTF8)
|
||||||
entity,
|
.body(bodyBytes)
|
||||||
String.class
|
.retrieve()
|
||||||
);
|
.toEntity(String.class);
|
||||||
|
} catch (RestClientResponseException ex) {
|
||||||
log.info("[PrestaShop] Réponse GET {} pour {}", response.getStatusCode(), url);
|
// On propage tel quel le status + le body XML renvoyé par Presta
|
||||||
|
log.error("[PrestaShop] POST error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
|
||||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
return ResponseEntity
|
||||||
throw new RuntimeException("PrestaShop returned non-2xx status: "
|
.status(ex.getRawStatusCode())
|
||||||
+ response.getStatusCode() + " for URL " + url);
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
}
|
.body(ex.getResponseBodyAsString());
|
||||||
|
} catch (RestClientException ex) {
|
||||||
return response.getBody();
|
// Cas réseau, timeout, etc.
|
||||||
} catch (RestClientException e) {
|
log.error("[PrestaShop] POST technical error", ex);
|
||||||
log.error("[PrestaShop] Erreur GET {}", url, e);
|
return ResponseEntity
|
||||||
throw new RuntimeException("Erreur GET PrestaShop", e);
|
.status(502)
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.body("Error while calling PrestaShop WebService");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
public ResponseEntity<String> putWithRawQuery(String path, String rawQuery, String xmlBody) {
|
||||||
// PUT : on respecte la query du front
|
String uri = baseUrl + path;
|
||||||
// =========================
|
if (rawQuery != null && !rawQuery.isBlank()) {
|
||||||
public String put(String path, String query, String body) {
|
uri = uri + "?" + rawQuery;
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder
|
|
||||||
.fromHttpUrl(baseUrl)
|
|
||||||
.path("/api")
|
|
||||||
.path(path);
|
|
||||||
|
|
||||||
if (query != null && !query.isBlank()) {
|
|
||||||
// on laisse Angular décider (ex: output_format=JSON)
|
|
||||||
builder.query(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String url = builder.build(true).toUriString();
|
log.info("[PrestaShop] PUT (proxy) {}", uri);
|
||||||
log.info("[PrestaShop] PUT {}", url);
|
log.info("[PrestaShop] XML envoyé (proxy PUT):\n{}", xmlBody);
|
||||||
log.debug("[PrestaShop] PUT body = {}", body);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
byte[] bodyBytes = xmlBody.getBytes(StandardCharsets.UTF_8);
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
HttpEntity<String> entity = new HttpEntity<>(body, headers);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
return client.put()
|
||||||
url,
|
.uri(uri)
|
||||||
HttpMethod.PUT,
|
.contentType(APPLICATION_XML_UTF8)
|
||||||
entity,
|
.body(bodyBytes)
|
||||||
String.class
|
.retrieve()
|
||||||
);
|
.toEntity(String.class);
|
||||||
|
} catch (RestClientResponseException ex) {
|
||||||
log.info("[PrestaShop] Réponse PUT {} pour {}", response.getStatusCode(), url);
|
// On propage tel quel le status + le body XML renvoyé par Presta
|
||||||
|
log.error("[PrestaShop] PUT error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
|
||||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
return ResponseEntity
|
||||||
throw new RuntimeException("PrestaShop returned non-2xx status: "
|
.status(ex.getRawStatusCode())
|
||||||
+ response.getStatusCode() + " for URL " + url);
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
}
|
.body(ex.getResponseBodyAsString());
|
||||||
|
} catch (RestClientException ex) {
|
||||||
return response.getBody();
|
// Cas réseau, timeout, etc.
|
||||||
} catch (HttpStatusCodeException e) {
|
log.error("[PrestaShop] PUT technical error", ex);
|
||||||
// Ici on log le body d'erreur de Presta !
|
return ResponseEntity
|
||||||
log.error("[PrestaShop] Erreur PUT {} status={} body={}",
|
.status(502)
|
||||||
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
throw new RuntimeException("Erreur PUT PrestaShop", e);
|
.body("Error while calling PrestaShop WebService");
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.error("[PrestaShop] Erreur PUT {}", url, e);
|
|
||||||
throw new RuntimeException("Erreur PUT PrestaShop", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
public ResponseEntity<String> deleteWithRawQuery(String path, String rawQuery) {
|
||||||
// POST : création
|
String uri = baseUrl + path;
|
||||||
// =========================
|
if (rawQuery != null && !rawQuery.isBlank()) {
|
||||||
public String post(String path, String query, String body) {
|
uri = uri + "?" + rawQuery;
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder
|
|
||||||
.fromHttpUrl(baseUrl)
|
|
||||||
.path("/api")
|
|
||||||
.path(path);
|
|
||||||
|
|
||||||
if (query != null && !query.isBlank()) {
|
|
||||||
builder.query(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String url = builder.build(true).toUriString();
|
log.info("[PrestaShop] DELETE (proxy) {}", uri);
|
||||||
log.info("[PrestaShop] POST {}", url);
|
|
||||||
log.debug("[PrestaShop] POST body = {}", body);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
HttpEntity<String> entity = new HttpEntity<>(body, headers);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
return client.delete()
|
||||||
url,
|
.uri(uri)
|
||||||
HttpMethod.POST,
|
.retrieve()
|
||||||
entity,
|
.toEntity(String.class);
|
||||||
String.class
|
} catch (RestClientResponseException ex) {
|
||||||
);
|
// On propage tel quel le status + le body XML renvoyé par Presta
|
||||||
|
log.error("[PrestaShop] DELETE error {} : {}", ex.getRawStatusCode(), ex.getResponseBodyAsString());
|
||||||
log.info("[PrestaShop] Réponse POST {} pour {}", response.getStatusCode(), url);
|
return ResponseEntity
|
||||||
|
.status(ex.getRawStatusCode())
|
||||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
throw new RuntimeException("PrestaShop returned non-2xx status: "
|
.body(ex.getResponseBodyAsString());
|
||||||
+ response.getStatusCode() + " for URL " + url);
|
} catch (RestClientException ex) {
|
||||||
}
|
// Cas réseau, timeout, etc.
|
||||||
|
log.error("[PrestaShop] DELETE technical error", ex);
|
||||||
return response.getBody();
|
return ResponseEntity
|
||||||
} catch (HttpStatusCodeException e) {
|
.status(502)
|
||||||
log.error("[PrestaShop] Erreur POST {} status={} body={}",
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
|
.body("Error while calling PrestaShop WebService");
|
||||||
throw new RuntimeException("Erreur POST PrestaShop", e);
|
|
||||||
} catch (RestClientException e) {
|
|
||||||
log.error("[PrestaShop] Erreur POST {}", url, e);
|
|
||||||
throw new RuntimeException("Erreur POST PrestaShop", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
|
||||||
// DELETE : suppression
|
|
||||||
// =========================
|
|
||||||
public String delete(String path, String query) {
|
|
||||||
UriComponentsBuilder builder = UriComponentsBuilder
|
|
||||||
.fromHttpUrl(baseUrl)
|
|
||||||
.path("/api")
|
|
||||||
.path(path);
|
|
||||||
|
|
||||||
if (query != null && !query.isBlank()) {
|
/**
|
||||||
builder.query(query);
|
* Upload d'une image produit vers PrestaShop :
|
||||||
}
|
* POST /api/images/products/{productId}
|
||||||
|
*/
|
||||||
String url = builder.build(true).toUriString();
|
|
||||||
log.info("[PrestaShop] DELETE {}", url);
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + basicAuth);
|
|
||||||
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
|
||||||
|
|
||||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
|
||||||
|
|
||||||
|
public ResponseEntity<String> uploadProductImage(
|
||||||
|
String productId,
|
||||||
|
String rawQuery,
|
||||||
|
MultipartFile imageFile
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
// Construire l’URL Presta (comme avant)
|
||||||
url,
|
StringBuilder urlBuilder = new StringBuilder(baseUrl)
|
||||||
HttpMethod.DELETE,
|
.append("/images/products/")
|
||||||
entity,
|
.append(productId);
|
||||||
String.class
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info("[PrestaShop] Réponse DELETE {} pour {}", response.getStatusCode(), url);
|
if (rawQuery != null && !rawQuery.isBlank()) {
|
||||||
|
urlBuilder.append('?').append(rawQuery);
|
||||||
|
}
|
||||||
|
String url = urlBuilder.toString();
|
||||||
|
|
||||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
byte[] fileBytes = imageFile.getBytes();
|
||||||
throw new RuntimeException("PrestaShop returned non-2xx status: "
|
|
||||||
+ response.getStatusCode() + " for URL " + url);
|
String originalFilename = imageFile.getOriginalFilename();
|
||||||
|
if (originalFilename == null || originalFilename.isBlank()) {
|
||||||
|
originalFilename = "image.jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.getBody();
|
String contentType = imageFile.getContentType();
|
||||||
} catch (HttpStatusCodeException e) {
|
if (contentType == null || contentType.isBlank()) {
|
||||||
log.error("[PrestaShop] Erreur DELETE {} status={} body={}",
|
contentType = "application/octet-stream";
|
||||||
url, e.getStatusCode(), e.getResponseBodyAsString(), e);
|
}
|
||||||
throw new RuntimeException("Erreur DELETE PrestaShop", e);
|
|
||||||
} catch (RestClientException e) {
|
log.info(
|
||||||
log.error("[PrestaShop] Erreur DELETE {}", url, e);
|
"[PrestaShop] POST (image multipart - manual) {} (size={} bytes, contentType={}, filename={})",
|
||||||
throw new RuntimeException("Erreur DELETE PrestaShop", e);
|
url, fileBytes.length, contentType, originalFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------- Construction du multipart "à la main" --------
|
||||||
|
String boundary = "----PrestashopBoundary" + System.currentTimeMillis();
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
OutputStreamWriter osw = new OutputStreamWriter(baos, StandardCharsets.UTF_8);
|
||||||
|
PrintWriter writer = new PrintWriter(osw, true);
|
||||||
|
|
||||||
|
// Début de la part "image"
|
||||||
|
writer.append("--").append(boundary).append("\r\n");
|
||||||
|
writer.append("Content-Disposition: form-data; name=\"image\"; filename=\"")
|
||||||
|
.append(originalFilename)
|
||||||
|
.append("\"\r\n");
|
||||||
|
writer.append("Content-Type: ").append(contentType).append("\r\n");
|
||||||
|
writer.append("\r\n");
|
||||||
|
writer.flush();
|
||||||
|
|
||||||
|
// Données binaires du fichier
|
||||||
|
baos.write(fileBytes);
|
||||||
|
baos.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// Fin du multipart
|
||||||
|
writer.append("--").append(boundary).append("--").append("\r\n");
|
||||||
|
writer.flush();
|
||||||
|
|
||||||
|
byte[] multipartBytes = baos.toByteArray();
|
||||||
|
|
||||||
|
// -------- Envoi via HttpURLConnection --------
|
||||||
|
URL targetUrl = URI.create(url).toURL();
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) targetUrl.openConnection();
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||||
|
conn.setRequestProperty("Accept", "application/xml, text/xml, */*;q=0.1");
|
||||||
|
conn.setRequestProperty("Authorization", basicAuthHeader);
|
||||||
|
|
||||||
|
// Important : pas de chunked, on envoie une taille fixe
|
||||||
|
conn.setFixedLengthStreamingMode(multipartBytes.length);
|
||||||
|
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(multipartBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
int status = conn.getResponseCode();
|
||||||
|
|
||||||
|
InputStream is = (status >= 200 && status < 300)
|
||||||
|
? conn.getInputStream()
|
||||||
|
: conn.getErrorStream();
|
||||||
|
|
||||||
|
String responseBody;
|
||||||
|
if (is != null) {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
sb.append(line).append("\n");
|
||||||
|
}
|
||||||
|
responseBody = sb.toString();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responseBody = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[PrestaShop] Image upload response status={}, body={}", status, responseBody);
|
||||||
|
|
||||||
|
// ---------- Mapping vers une réponse propre pour le front ----------
|
||||||
|
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
// Succès : on renvoie un petit JSON que Angular sait lire
|
||||||
|
return ResponseEntity
|
||||||
|
.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body("{\"success\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpStatus springStatus = HttpStatus.resolve(status);
|
||||||
|
if (springStatus == null) {
|
||||||
|
springStatus = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// En cas d’erreur Presta, on propage l’XML pour debug
|
||||||
|
return ResponseEntity
|
||||||
|
.status(springStatus)
|
||||||
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
|
.body(responseBody);
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("[PrestaShop] Erreur lors de l'upload d'image", e);
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.BAD_GATEWAY)
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.body("Erreur lors de l'upload d'image vers PrestaShop");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,10 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
||||||
spring.jpa.show-sql=true
|
spring.jpa.show-sql=true
|
||||||
|
|
||||||
|
spring.servlet.multipart.max-file-size=15MB
|
||||||
|
spring.servlet.multipart.max-request-size=15MB
|
||||||
|
|
||||||
jwt.secret=a23ac96ce968bf13099d99410b951dd498118851bdfc996a3f844bd68b1b2afd
|
jwt.secret=a23ac96ce968bf13099d99410b951dd498118851bdfc996a3f844bd68b1b2afd
|
||||||
|
|
||||||
prestashop.base-url=https://shop.gameovergne.fr
|
prestashop.base-url=https://shop.gameovergne.fr/api
|
||||||
prestashop.basic-auth=MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI=
|
prestashop.api-key=${PRESTASHOP_API_KEY}
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
# Build Angular
|
# ===== STAGE 1 : build Angular =====
|
||||||
FROM node:20 AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dépendances
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
|
# Code source
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
# Build Angular en mode prod
|
||||||
|
RUN npm run build -- --configuration production
|
||||||
|
|
||||||
# NGINX final
|
# ===== STAGE 2 : Nginx pour servir le build =====
|
||||||
FROM nginx:stable
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /app/dist/client/browser /usr/share/nginx/html
|
# On nettoie la racine Nginx
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
# ⚠ On copie TOUT dist/client dans l'image
|
||||||
|
# => ça crée /usr/share/nginx/html/browser/index.html
|
||||||
|
COPY --from=build /app/dist/client/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# Conf Nginx spécifique à l'app
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -62,8 +62,21 @@
|
|||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
|
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
|
||||||
|
"options": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 4200,
|
||||||
|
|
||||||
|
"allowedHosts": [
|
||||||
|
"dev.vincent-guillet.fr"
|
||||||
|
],
|
||||||
|
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
|
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "client:build:production"
|
"buildTarget": "client:build:production"
|
||||||
@@ -74,9 +87,11 @@
|
|||||||
},
|
},
|
||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
|
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
},
|
},
|
||||||
|
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
|
||||||
|
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html/browser;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Angular SPA
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"secure": true,
|
"secure": true,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug",
|
"logLevel": "debug",
|
||||||
"pathRewrite": { "^/ps": "/api" },
|
"pathRewrite": {
|
||||||
|
"^/ps": "/api"
|
||||||
|
},
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Basic MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI="
|
"Authorization": "Basic MkFRUEcxM01KOFgxMTdVNkZKNU5HSFBTOTNIRTM0QUI="
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
import {APP_INITIALIZER, ApplicationConfig, inject, provideZoneChangeDetection} from '@angular/core';
|
import {
|
||||||
|
APP_INITIALIZER,
|
||||||
|
ApplicationConfig,
|
||||||
|
inject,
|
||||||
|
provideZoneChangeDetection,
|
||||||
|
importProvidersFrom
|
||||||
|
} from '@angular/core';
|
||||||
import {provideRouter} from '@angular/router';
|
import {provideRouter} from '@angular/router';
|
||||||
|
import {BrowserModule} from '@angular/platform-browser';
|
||||||
|
import {APP_BASE_HREF} from '@angular/common';
|
||||||
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 {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
||||||
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
|
import {authTokenInterceptor} from './interceptors/auth-token.interceptor';
|
||||||
import {AuthService} from './services/auth.service';
|
import {AuthService} from './services/auth.service';
|
||||||
import {catchError, firstValueFrom, of} from 'rxjs';
|
import {catchError, firstValueFrom, of} from 'rxjs';
|
||||||
|
import {environment} from '../environments/environment';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({eventCoalescing: true}),
|
provideZoneChangeDetection({eventCoalescing: true}),
|
||||||
|
importProvidersFrom(BrowserModule),
|
||||||
|
{provide: APP_BASE_HREF, useValue: environment.hrefBase},
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
provideHttpClient(withInterceptors([
|
provideHttpClient(withInterceptors([authTokenInterceptor])),
|
||||||
authTokenInterceptor
|
|
||||||
])
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
multi: true,
|
multi: true,
|
||||||
useFactory: () => {
|
useFactory: () => {
|
||||||
const auth = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
return () => firstValueFrom(auth.bootstrapSession().pipe(
|
return () =>
|
||||||
catchError(err => of(null))
|
firstValueFrom(
|
||||||
)
|
auth.bootstrapSession().pipe(
|
||||||
);
|
catchError(() => of(null))
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, provideAnimationsAsync()
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {LoginComponent} from './pages/auth/login/login.component';
|
|||||||
import {ProfileComponent} from './pages/profile/profile.component';
|
import {ProfileComponent} from './pages/profile/profile.component';
|
||||||
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
|
import {guestOnlyCanActivate, guestOnlyCanMatch} from './guards/guest-only.guard';
|
||||||
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
import {adminOnlyCanActivate, adminOnlyCanMatch} from './guards/admin-only.guard';
|
||||||
import {authOnlyCanMatch} from './guards/auth-only.guard';
|
import {authOnlyCanActivate, authOnlyCanMatch} from './guards/auth-only.guard';
|
||||||
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
import {PsAdminComponent} from './pages/admin/ps-admin/ps-admin.component';
|
||||||
import {ProductsComponent} from './pages/products/products.component';
|
import {ProductsComponent} from './pages/products/products.component';
|
||||||
|
|
||||||
@@ -40,13 +40,13 @@ export const routes: Routes = [
|
|||||||
path: 'profile',
|
path: 'profile',
|
||||||
component: ProfileComponent,
|
component: ProfileComponent,
|
||||||
canMatch: [authOnlyCanMatch],
|
canMatch: [authOnlyCanMatch],
|
||||||
canActivate: [authOnlyCanMatch]
|
canActivate: [authOnlyCanActivate]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'products',
|
path: 'products',
|
||||||
component: ProductsComponent,
|
component: ProductsComponent,
|
||||||
canMatch: [authOnlyCanMatch],
|
canMatch: [adminOnlyCanMatch],
|
||||||
canActivate: [authOnlyCanMatch]
|
canActivate: [adminOnlyCanActivate]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
/* src/app/components/main-navbar/main-navbar.component.css */
|
||||||
|
/* Ajout prise en charge safe-area et meilleure gestion des overflow */
|
||||||
|
|
||||||
|
.mat-toolbar {
|
||||||
|
/* protège contre les zones sensibles (notch / status bar) */
|
||||||
|
padding-top: constant(safe-area-inset-top);
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wrapper principal */
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -5,20 +18,107 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 56px; /* assure une hauteur minimale utile sur mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* marque / titre */
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
min-width: 0; /* autorise le shrink */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* actions (boutons, menu utilisateur) */
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: 0; /* important pour permettre la réduction des enfants */
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* icône dans mat-menu */
|
||||||
.mat-menu-item mat-icon {
|
.mat-menu-item mat-icon {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Empêcher les boutons de dépasser et couper le texte avec ellipsis */
|
||||||
|
.nav-actions button {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Angular Material place le texte dans .mat-button-wrapper — on le tronque proprement */
|
||||||
|
.nav-actions button .mat-button-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: calc(100% - 56px); /* espace pour icônes + padding */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustements spécifiques pour petits écrans */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.mat-toolbar {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
order: 1;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: 4px; /* espace pour le scroll horizontal */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions button {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions button .mat-button-wrapper {
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar .filter {
|
.toolbar .filter {
|
||||||
@@ -14,8 +16,21 @@
|
|||||||
min-width: 360px;
|
min-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-elevation-z2 {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 800px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prod-cell {
|
.prod-cell {
|
||||||
@@ -31,3 +46,50 @@ table {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-list-root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-list-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-paginator {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.toolbar {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .filter {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 0;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prod-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 720px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
<section class="crud">
|
<section class="crud">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button mat-raised-button color="primary" (click)="create()">
|
<button mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="create()"
|
||||||
|
[disabled]="isLoading">
|
||||||
<mat-icon>add</mat-icon> Nouveau produit
|
<mat-icon>add</mat-icon> Nouveau produit
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="filter">
|
<mat-form-field appearance="outline" class="filter">
|
||||||
<mat-label>Filtrer</mat-label>
|
<mat-label>Filtrer</mat-label>
|
||||||
<input matInput [formControl]="filterCtrl" placeholder="Nom, ID, catégorie, marque, fournisseur…">
|
<input matInput
|
||||||
|
[formControl]="filterCtrl"
|
||||||
|
placeholder="Nom, ID, catégorie, marque, fournisseur…"
|
||||||
|
[disabled]="isLoading">
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mat-elevation-z2">
|
<div class="mat-elevation-z2 product-list-root">
|
||||||
|
<!-- Overlay de chargement -->
|
||||||
|
<div class="product-list-loading-overlay" *ngIf="isLoading">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table mat-table [dataSource]="dataSource" matSort>
|
<table mat-table [dataSource]="dataSource" matSort>
|
||||||
|
|
||||||
<ng-container matColumnDef="id">
|
<ng-container matColumnDef="id">
|
||||||
@@ -51,8 +62,19 @@
|
|||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||||
<td mat-cell *matCellDef="let el">
|
<td mat-cell *matCellDef="let el">
|
||||||
<button mat-icon-button (click)="edit(el)" aria-label="edit"><mat-icon>edit</mat-icon></button>
|
<button mat-icon-button
|
||||||
<button mat-icon-button color="warn" (click)="remove(el)" aria-label="delete"><mat-icon>delete</mat-icon></button>
|
aria-label="edit"
|
||||||
|
(click)="edit(el)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button
|
||||||
|
color="warn"
|
||||||
|
aria-label="delete"
|
||||||
|
(click)="remove(el)"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@@ -60,10 +82,17 @@
|
|||||||
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayed;"></tr>
|
||||||
|
|
||||||
<tr class="mat-row" *matNoDataRow>
|
<tr class="mat-row" *matNoDataRow>
|
||||||
<td class="mat-cell" [attr.colspan]="displayed.length">Aucune donnée.</td>
|
<td class="mat-cell" [attr.colspan]="displayed.length">
|
||||||
|
Aucune donnée.
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<mat-paginator [pageSizeOptions]="[5,10,25,100]" [pageSize]="10" aria-label="Pagination"></mat-paginator>
|
<mat-paginator
|
||||||
|
[pageSizeOptions]="[5,10,25,100]"
|
||||||
|
[pageSize]="10"
|
||||||
|
aria-label="Pagination"
|
||||||
|
[disabled]="isLoading">
|
||||||
|
</mat-paginator>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {MatButton, MatIconButton} from '@angular/material/button';
|
|||||||
import {MatIcon} from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
|
import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
|
||||||
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
import {MatDialog, MatDialogModule} from '@angular/material/dialog';
|
||||||
import {forkJoin} from 'rxjs';
|
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||||
|
import {forkJoin, finalize} from 'rxjs';
|
||||||
|
|
||||||
import {PsItem} from '../../interfaces/ps-item';
|
import {PsItem} from '../../interfaces/ps-item';
|
||||||
import {ProductListItem} from '../../interfaces/product-list-item';
|
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||||
@@ -32,7 +33,8 @@ import {ProductDialogData, PsProductDialogComponent} from '../ps-product-dialog/
|
|||||||
MatSortModule, MatPaginatorModule,
|
MatSortModule, MatPaginatorModule,
|
||||||
MatFormField, MatLabel, MatInput,
|
MatFormField, MatLabel, MatInput,
|
||||||
MatButton, MatIconButton, MatIcon,
|
MatButton, MatIconButton, MatIcon,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
MatProgressSpinnerModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PsProductCrudComponent implements OnInit {
|
export class PsProductCrudComponent implements OnInit {
|
||||||
@@ -40,26 +42,24 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
private readonly ps = inject(PrestashopService);
|
private readonly ps = inject(PrestashopService);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
// référentiels
|
|
||||||
categories: PsItem[] = [];
|
categories: PsItem[] = [];
|
||||||
manufacturers: PsItem[] = [];
|
manufacturers: PsItem[] = [];
|
||||||
suppliers: PsItem[] = [];
|
suppliers: PsItem[] = [];
|
||||||
|
|
||||||
// maps d’affichage
|
|
||||||
private catMap = new Map<number, string>();
|
private catMap = new Map<number, string>();
|
||||||
private manMap = new Map<number, string>();
|
private manMap = new Map<number, string>();
|
||||||
private supMap = new Map<number, string>();
|
private supMap = new Map<number, string>();
|
||||||
|
|
||||||
// table
|
|
||||||
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
|
displayed: string[] = ['id', 'name', 'category', 'manufacturer', 'supplier', 'priceTtc', 'quantity', 'actions'];
|
||||||
dataSource = new MatTableDataSource<any>([]);
|
dataSource = new MatTableDataSource<any>([]);
|
||||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||||
@ViewChild(MatSort) sort!: MatSort;
|
@ViewChild(MatSort) sort!: MatSort;
|
||||||
@ViewChild(MatTable) table!: MatTable<any>;
|
@ViewChild(MatTable) table!: MatTable<any>;
|
||||||
|
|
||||||
// filtre
|
|
||||||
filterCtrl = this.fb.control<string>('');
|
filterCtrl = this.fb.control<string>('');
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
forkJoin({
|
forkJoin({
|
||||||
cats: this.ps.list('categories'),
|
cats: this.ps.list('categories'),
|
||||||
@@ -73,6 +73,7 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
|
this.manMap = new Map(this.manufacturers.map(x => [x.id, x.name]));
|
||||||
this.suppliers = sups ?? [];
|
this.suppliers = sups ?? [];
|
||||||
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
|
this.supMap = new Map(this.suppliers.map(x => [x.id, x.name]));
|
||||||
|
|
||||||
this.reload();
|
this.reload();
|
||||||
},
|
},
|
||||||
error: err => {
|
error: err => {
|
||||||
@@ -80,7 +81,6 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// filtre client
|
|
||||||
this.filterCtrl.valueChanges.subscribe(v => {
|
this.filterCtrl.valueChanges.subscribe(v => {
|
||||||
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
|
this.dataSource.filter = (v ?? '').toString().trim().toLowerCase();
|
||||||
if (this.paginator) this.paginator.firstPage();
|
if (this.paginator) this.paginator.firstPage();
|
||||||
@@ -133,10 +133,24 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.ps.listProducts().subscribe(p => this.bindProducts(p));
|
this.isLoading = true;
|
||||||
|
this.ps.listProducts()
|
||||||
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: p => this.bindProducts(p),
|
||||||
|
error: err => {
|
||||||
|
console.error('Erreur lors du chargement des produits', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
|
||||||
const data: ProductDialogData = {
|
const data: ProductDialogData = {
|
||||||
mode: 'create',
|
mode: 'create',
|
||||||
refs: {
|
refs: {
|
||||||
@@ -145,12 +159,16 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
suppliers: this.suppliers
|
suppliers: this.suppliers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
|
||||||
if (ok) this.reload();
|
.afterClosed()
|
||||||
});
|
.subscribe(ok => {
|
||||||
|
if (ok) this.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(row: ProductListItem & { priceHt?: number }) {
|
edit(row: ProductListItem & { priceHt?: number }) {
|
||||||
|
if (this.isLoading) return;
|
||||||
|
|
||||||
const data: ProductDialogData = {
|
const data: ProductDialogData = {
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
productRow: row,
|
productRow: row,
|
||||||
@@ -160,16 +178,29 @@ export class PsProductCrudComponent implements OnInit {
|
|||||||
suppliers: this.suppliers
|
suppliers: this.suppliers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.dialog.open(PsProductDialogComponent, {width: '900px', data}).afterClosed().subscribe(ok => {
|
this.dialog.open(PsProductDialogComponent, {width: '900px', data})
|
||||||
if (ok) this.reload();
|
.afterClosed()
|
||||||
});
|
.subscribe(ok => {
|
||||||
|
if (ok) this.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(row: ProductListItem) {
|
remove(row: ProductListItem) {
|
||||||
|
if (this.isLoading) return;
|
||||||
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
|
if (!confirm(`Supprimer le produit "${row.name}" (#${row.id}) ?`)) return;
|
||||||
this.ps.deleteProduct(row.id).subscribe({
|
|
||||||
next: () => this.reload(),
|
this.isLoading = true;
|
||||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
this.ps.deleteProduct(row.id)
|
||||||
});
|
.pipe(
|
||||||
|
finalize(() => {
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.reload(),
|
||||||
|
error: (e: unknown) => {
|
||||||
|
this.isLoading = false;
|
||||||
|
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,24 @@
|
|||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bouton de suppression (croix rouge) */
|
||||||
|
.carousel-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-delete-btn mat-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bandeau de vignettes */
|
/* Bandeau de vignettes */
|
||||||
|
|
||||||
.carousel-thumbs {
|
.carousel-thumbs {
|
||||||
@@ -85,15 +103,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb-item {
|
.thumb-item {
|
||||||
|
position: relative;
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden; /* tu peux laisser comme ça */
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bouton de suppression sur les vignettes */
|
||||||
|
.thumb-delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-delete-btn mat-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
|
||||||
|
color: #e53935; /* rouge discret mais lisible */
|
||||||
|
}
|
||||||
|
|
||||||
.thumb-item.active {
|
.thumb-item.active {
|
||||||
border-color: #1976d2;
|
border-color: #1976d2;
|
||||||
}
|
}
|
||||||
@@ -118,3 +163,19 @@
|
|||||||
.thumb-placeholder mat-icon {
|
.thumb-placeholder mat-icon {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay plein écran dans le dialog pendant la sauvegarde */
|
||||||
|
.dialog-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,137 +1,183 @@
|
|||||||
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
|
<h2 mat-dialog-title>{{ mode === 'create' ? 'Nouveau produit' : 'Modifier le produit' }}</h2>
|
||||||
|
|
||||||
<div mat-dialog-content class="grid" [formGroup]="form">
|
<div class="dialog-root">
|
||||||
|
<!-- Overlay de chargement -->
|
||||||
|
@if (isSaving) {
|
||||||
|
<div class="dialog-loading-overlay">
|
||||||
|
<mat-spinner diameter="48"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- CARROUSEL IMAGES -->
|
<div mat-dialog-content class="grid" [formGroup]="form">
|
||||||
<div class="col-12 carousel">
|
|
||||||
<div class="carousel-main">
|
|
||||||
|
|
||||||
<!-- Bouton précédent -->
|
<!-- CARROUSEL IMAGES -->
|
||||||
<button mat-icon-button
|
<div class="col-12 carousel">
|
||||||
class="carousel-nav-btn left"
|
<div class="carousel-main">
|
||||||
(click)="prev()"
|
|
||||||
[disabled]="carouselItems.length <= 1">
|
|
||||||
<mat-icon>chevron_left</mat-icon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Image principale ou placeholder -->
|
<!-- Bouton précédent -->
|
||||||
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
|
<button mat-icon-button
|
||||||
<img [src]="carouselItems[currentIndex].src" alt="Produit">
|
class="carousel-nav-btn left"
|
||||||
} @else {
|
(click)="prev()"
|
||||||
<div class="carousel-placeholder" (click)="fileInput.click()">
|
[disabled]="carouselItems.length <= 1">
|
||||||
<mat-icon>add_photo_alternate</mat-icon>
|
<mat-icon>chevron_left</mat-icon>
|
||||||
<span>Ajouter des images</span>
|
</button>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Bouton suivant -->
|
<!-- Image principale ou placeholder -->
|
||||||
<button mat-icon-button
|
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
|
||||||
class="carousel-nav-btn right"
|
<img [src]="carouselItems[currentIndex].src" alt="Produit">
|
||||||
(click)="next()"
|
} @else {
|
||||||
[disabled]="carouselItems.length <= 1">
|
<div class="carousel-placeholder" (click)="fileInput.click()">
|
||||||
<mat-icon>chevron_right</mat-icon>
|
<mat-icon>add_photo_alternate</mat-icon>
|
||||||
</button>
|
<span>Ajouter des images</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bouton suivant -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-nav-btn right"
|
||||||
|
(click)="next()"
|
||||||
|
[disabled]="carouselItems.length <= 1">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bouton de suppression (croix rouge) -->
|
||||||
|
@if (carouselItems.length && !carouselItems[currentIndex].isPlaceholder) {
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-delete-btn"
|
||||||
|
(click)="onDeleteCurrentImage()">
|
||||||
|
<mat-icon>delete</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bouton suivant -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="carousel-nav-btn right"
|
||||||
|
(click)="next()"
|
||||||
|
[disabled]="carouselItems.length <= 1">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau de vignettes -->
|
||||||
|
<div class="carousel-thumbs">
|
||||||
|
@for (item of carouselItems; let i = $index; track item) {
|
||||||
|
<div class="thumb-item"
|
||||||
|
[class.active]="i === currentIndex"
|
||||||
|
(click)="onThumbClick(i)">
|
||||||
|
@if (!item.isPlaceholder) {
|
||||||
|
|
||||||
|
<!-- Bouton suppression vignette -->
|
||||||
|
<button mat-icon-button
|
||||||
|
class="thumb-delete-btn"
|
||||||
|
(click)="onDeleteThumb(i, $event)">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
|
||||||
|
} @else {
|
||||||
|
<div class="thumb-placeholder" (click)="fileInput.click()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input réel, caché -->
|
||||||
|
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bandeau de vignettes -->
|
<!-- Input pour le nom du produit -->
|
||||||
<div class="carousel-thumbs">
|
<mat-form-field class="col-12">
|
||||||
@for (item of carouselItems; let i = $index; track item) {
|
<mat-label>Nom du produit</mat-label>
|
||||||
<div class="thumb-item"
|
<input matInput formControlName="name" autocomplete="off">
|
||||||
[class.active]="i === currentIndex"
|
</mat-form-field>
|
||||||
(click)="onThumbClick(i)">
|
|
||||||
@if (!item.isPlaceholder) {
|
<!-- Textarea pour la description -->
|
||||||
<img class="thumb-img" [src]="item.src" alt="Vignette produit">
|
<mat-form-field class="col-12">
|
||||||
} @else {
|
<mat-label>Description</mat-label>
|
||||||
<div class="thumb-placeholder" (click)="fileInput.click()">
|
<textarea matInput rows="4" formControlName="description"></textarea>
|
||||||
<mat-icon>add</mat-icon>
|
</mat-form-field>
|
||||||
</div>
|
|
||||||
}
|
<!-- Sélecteur pour la catégorie -->
|
||||||
</div>
|
<mat-form-field class="col-6">
|
||||||
}
|
<mat-label>Catégorie</mat-label>
|
||||||
|
<mat-select formControlName="categoryId">
|
||||||
|
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||||
|
@for (c of categories; track c.id) {
|
||||||
|
<mat-option [value]="c.id">{{ c.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sélecteur pour l'état du produit -->
|
||||||
|
<mat-form-field class="col-6">
|
||||||
|
<mat-label>État</mat-label>
|
||||||
|
<mat-select formControlName="conditionLabel">
|
||||||
|
@for (opt of conditionOptions; track opt) {
|
||||||
|
<mat-option [value]="opt">{{ opt }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sélecteur pour la marque -->
|
||||||
|
<mat-form-field class="col-6">
|
||||||
|
<mat-label>Marque</mat-label>
|
||||||
|
<mat-select formControlName="manufacturerId">
|
||||||
|
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||||
|
@for (m of manufacturers; track m.id) {
|
||||||
|
<mat-option [value]="m.id">{{ m.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Sélecteur pour la plateforme (Fournisseur) -->
|
||||||
|
<mat-form-field class="col-6">
|
||||||
|
<mat-label>Plateforme</mat-label>
|
||||||
|
<mat-select formControlName="supplierId">
|
||||||
|
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
||||||
|
@for (s of suppliers; track s.id) {
|
||||||
|
<mat-option [value]="s.id">{{ s.name }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Checkboxes pour Complet/Notice -->
|
||||||
|
<div class="col-12 flags">
|
||||||
|
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
|
||||||
|
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input réel, caché -->
|
<!-- Inputs pour le prix -->
|
||||||
<input #fileInput type="file" multiple hidden (change)="onFiles($event)">
|
<mat-form-field class="col-4">
|
||||||
|
<mat-label>Prix TTC (€)</mat-label>
|
||||||
|
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Input pour la quantité -->
|
||||||
|
<mat-form-field class="col-4">
|
||||||
|
<mat-label>Quantité</mat-label>
|
||||||
|
<input matInput type="number" step="1" min="0" formControlName="quantity">
|
||||||
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input pour le nom du produit -->
|
|
||||||
<mat-form-field class="col-12">
|
|
||||||
<mat-label>Nom du produit</mat-label>
|
|
||||||
<input matInput formControlName="name" autocomplete="off">
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Textarea pour la description -->
|
|
||||||
<mat-form-field class="col-12">
|
|
||||||
<mat-label>Description</mat-label>
|
|
||||||
<textarea matInput rows="4" formControlName="description"></textarea>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Sélecteur pour la catégorie -->
|
|
||||||
<mat-form-field class="col-6">
|
|
||||||
<mat-label>Catégorie</mat-label>
|
|
||||||
<mat-select formControlName="categoryId">
|
|
||||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
|
||||||
@for (c of categories; track c.id) {
|
|
||||||
<mat-option [value]="c.id">{{ c.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Sélecteur pour l'état du produit -->
|
|
||||||
<mat-form-field class="col-6">
|
|
||||||
<mat-label>État</mat-label>
|
|
||||||
<mat-select formControlName="conditionLabel">
|
|
||||||
@for (opt of conditionOptions; track opt) {
|
|
||||||
<mat-option [value]="opt">{{ opt }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Sélecteur pour la marque -->
|
|
||||||
<mat-form-field class="col-6">
|
|
||||||
<mat-label>Marque</mat-label>
|
|
||||||
<mat-select formControlName="manufacturerId">
|
|
||||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
|
||||||
@for (m of manufacturers; track m.id) {
|
|
||||||
<mat-option [value]="m.id">{{ m.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Sélecteur pour la plateforme (Fournisseur) -->
|
|
||||||
<mat-form-field class="col-6">
|
|
||||||
<mat-label>Plateforme</mat-label>
|
|
||||||
<mat-select formControlName="supplierId">
|
|
||||||
<mat-option [value]="null" disabled>Choisir…</mat-option>
|
|
||||||
@for (s of suppliers; track s.id) {
|
|
||||||
<mat-option [value]="s.id">{{ s.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Checkboxes pour Complet/Notice -->
|
|
||||||
<div class="col-12 flags">
|
|
||||||
<mat-checkbox formControlName="complete">Complet</mat-checkbox>
|
|
||||||
<mat-checkbox formControlName="hasManual">Notice</mat-checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inputs pour le prix -->
|
|
||||||
<mat-form-field class="col-4">
|
|
||||||
<mat-label>Prix TTC (€)</mat-label>
|
|
||||||
<input matInput type="number" step="0.01" min="0" formControlName="priceTtc">
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<!-- Input pour la quantité -->
|
|
||||||
<mat-form-field class="col-4">
|
|
||||||
<mat-label>Quantité</mat-label>
|
|
||||||
<input matInput type="number" step="1" min="0" formControlName="quantity">
|
|
||||||
</mat-form-field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div mat-dialog-actions>
|
<mat-dialog-actions align="end">
|
||||||
<button mat-button (click)="close()">Annuler</button>
|
<button mat-button
|
||||||
<button mat-raised-button color="primary" (click)="save()" [disabled]="form.invalid">
|
(click)="close()"
|
||||||
{{ mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
[disabled]="isSaving">
|
||||||
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
<button mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="save()"
|
||||||
|
[disabled]="form.invalid || isSaving">
|
||||||
|
@if (!isSaving) {
|
||||||
|
Enregistrer
|
||||||
|
} @else {
|
||||||
|
Enregistrement...
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</mat-dialog-actions>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core';
|
import {Component, Inject, OnInit, inject, OnDestroy} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
import {MatFormField, MatLabel} from '@angular/material/form-field';
|
||||||
import { MatInput } from '@angular/material/input';
|
import {MatInput} from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import {MatSelectModule} from '@angular/material/select';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import {MatCheckbox} from '@angular/material/checkbox';
|
||||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||||
import {
|
import {
|
||||||
MatDialogRef,
|
MatDialogRef,
|
||||||
MAT_DIALOG_DATA,
|
MAT_DIALOG_DATA,
|
||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogTitle
|
MatDialogTitle
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import {MatIcon} from '@angular/material/icon';
|
||||||
|
|
||||||
import { catchError, forkJoin, of, Observable } from 'rxjs';
|
import {catchError, forkJoin, of, Observable, finalize} from 'rxjs';
|
||||||
|
|
||||||
import { PsItem } from '../../interfaces/ps-item';
|
import {PsItem} from '../../interfaces/ps-item';
|
||||||
import { ProductListItem } from '../../interfaces/product-list-item';
|
import {ProductListItem} from '../../interfaces/product-list-item';
|
||||||
import { PrestashopService } from '../../services/prestashop.serivce';
|
import {PrestashopService} from '../../services/prestashop.serivce';
|
||||||
|
import {MatProgressSpinner} from '@angular/material/progress-spinner';
|
||||||
|
|
||||||
export type ProductDialogData = {
|
export type ProductDialogData = {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
@@ -38,7 +39,7 @@ type CarouselItem = { src: string; isPlaceholder: boolean };
|
|||||||
CommonModule, ReactiveFormsModule,
|
CommonModule, ReactiveFormsModule,
|
||||||
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
|
MatFormField, MatLabel, MatInput, MatSelectModule, MatCheckbox,
|
||||||
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
|
MatButton, MatDialogActions, MatDialogContent, MatDialogTitle,
|
||||||
MatIcon, MatIconButton
|
MatIcon, MatIconButton, MatProgressSpinner
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PsProductDialogComponent implements OnInit, OnDestroy {
|
export class PsProductDialogComponent implements OnInit, OnDestroy {
|
||||||
@@ -48,7 +49,10 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
|
@Inject(MAT_DIALOG_DATA) public data: ProductDialogData,
|
||||||
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
|
private readonly dialogRef: MatDialogRef<PsProductDialogComponent>
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving = false;
|
||||||
|
|
||||||
mode!: 'create' | 'edit';
|
mode!: 'create' | 'edit';
|
||||||
categories: PsItem[] = [];
|
categories: PsItem[] = [];
|
||||||
@@ -167,11 +171,11 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
|
const qty$ = this.ps.getProductQuantity(r.id).pipe(catchError(() => of(0)));
|
||||||
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
|
const imgs$ = this.ps.getProductImageUrls(r.id).pipe(catchError(() => of<string[]>([])));
|
||||||
const flags$ = this.ps.getProductFlags(r.id).pipe(
|
const flags$ = this.ps.getProductFlags(r.id).pipe(
|
||||||
catchError(() => of({ complete: false, hasManual: false, conditionLabel: undefined }))
|
catchError(() => of({complete: false, hasManual: false, conditionLabel: undefined}))
|
||||||
);
|
);
|
||||||
|
|
||||||
forkJoin({ details: details$, qty: qty$, imgs: imgs$, flags: flags$ })
|
forkJoin({details: details$, qty: qty$, imgs: imgs$, flags: flags$})
|
||||||
.subscribe(({ details, qty, imgs, flags }) => {
|
.subscribe(({details, qty, imgs, flags}) => {
|
||||||
const ttc = this.toTtc(details.priceHt ?? 0);
|
const ttc = this.toTtc(details.priceHt ?? 0);
|
||||||
const baseDesc = this.cleanForTextarea(details.description ?? '');
|
const baseDesc = this.cleanForTextarea(details.description ?? '');
|
||||||
this.lastLoadedDescription = baseDesc;
|
this.lastLoadedDescription = baseDesc;
|
||||||
@@ -203,7 +207,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
const fl = (ev.target as HTMLInputElement).files;
|
const fl = (ev.target as HTMLInputElement).files;
|
||||||
|
|
||||||
// Nettoyage des anciens objectURL
|
// Nettoyage des anciens objectURL
|
||||||
for(let url of this.previewUrls) {
|
for (let url of this.previewUrls) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
this.previewUrls = [];
|
this.previewUrls = [];
|
||||||
@@ -224,12 +228,12 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private buildCarousel() {
|
private buildCarousel() {
|
||||||
const items: CarouselItem[] = [
|
const items: CarouselItem[] = [
|
||||||
...this.existingImageUrls.map(u => ({ src: u, isPlaceholder: false })),
|
...this.existingImageUrls.map(u => ({src: u, isPlaceholder: false})),
|
||||||
...this.previewUrls.map(u => ({ src: u, isPlaceholder: false }))
|
...this.previewUrls.map(u => ({src: u, isPlaceholder: false}))
|
||||||
];
|
];
|
||||||
|
|
||||||
// placeholder en dernier
|
// placeholder en dernier
|
||||||
items.push({ src: '', isPlaceholder: true });
|
items.push({src: '', isPlaceholder: true});
|
||||||
|
|
||||||
this.carouselItems = items;
|
this.carouselItems = items;
|
||||||
if (!this.carouselItems.length) {
|
if (!this.carouselItems.length) {
|
||||||
@@ -261,10 +265,13 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Save / close inchangés (à part dto.images) --------
|
// -------- Save / close --------
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid || this.isSaving) return;
|
||||||
|
|
||||||
|
this.isSaving = true;
|
||||||
|
this.dialogRef.disableClose = true;
|
||||||
|
|
||||||
const v = this.form.getRawValue();
|
const v = this.form.getRawValue();
|
||||||
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
|
const effectiveDescription = (v.description ?? '').trim() || this.lastLoadedDescription;
|
||||||
@@ -275,7 +282,7 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
categoryId: +v.categoryId!,
|
categoryId: +v.categoryId!,
|
||||||
manufacturerId: +v.manufacturerId!,
|
manufacturerId: +v.manufacturerId!,
|
||||||
supplierId: +v.supplierId!,
|
supplierId: +v.supplierId!,
|
||||||
images: this.images, // toujours les fichiers sélectionnés
|
images: this.images,
|
||||||
complete: !!v.complete,
|
complete: !!v.complete,
|
||||||
hasManual: !!v.hasManual,
|
hasManual: !!v.hasManual,
|
||||||
conditionLabel: v.conditionLabel || undefined,
|
conditionLabel: v.conditionLabel || undefined,
|
||||||
@@ -291,13 +298,99 @@ export class PsProductDialogComponent implements OnInit, OnDestroy {
|
|||||||
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
|
op$ = this.ps.updateProduct(this.productRow.id, dto) as Observable<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
op$.subscribe({
|
op$
|
||||||
next: () => this.dialogRef.close(true),
|
.pipe(
|
||||||
error: (e: unknown) => alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
finalize(() => {
|
||||||
});
|
// si la boîte de dialogue est encore ouverte, on réactive tout
|
||||||
|
this.isSaving = false;
|
||||||
|
this.dialogRef.disableClose = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: () => this.dialogRef.close(true),
|
||||||
|
error: (e: unknown) =>
|
||||||
|
alert('Erreur: ' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrait l'id_image depuis une URL FO Presta (.../img/p/.../<id>.jpg) */
|
||||||
|
private extractImageIdFromUrl(url: string): number | null {
|
||||||
|
const m = /\/(\d+)\.(?:jpg|jpeg|png|gif)$/i.exec(url);
|
||||||
|
if (!m) return null;
|
||||||
|
const id = Number(m[1]);
|
||||||
|
return Number.isFinite(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppression générique d'une image à l'index donné (carrousel + vignettes) */
|
||||||
|
private deleteImageAtIndex(idx: number) {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
|
||||||
|
const item = this.carouselItems[idx];
|
||||||
|
if (!item || item.isPlaceholder) return;
|
||||||
|
|
||||||
|
const existingCount = this.existingImageUrls.length;
|
||||||
|
|
||||||
|
// --- Cas 1 : image existante (déjà chez Presta) ---
|
||||||
|
if (idx < existingCount) {
|
||||||
|
if (!this.productRow) return; // sécurité
|
||||||
|
|
||||||
|
const url = this.existingImageUrls[idx];
|
||||||
|
const imageId = this.extractImageIdFromUrl(url);
|
||||||
|
if (!imageId) {
|
||||||
|
alert('Impossible de déterminer l’ID de l’image à supprimer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('Supprimer cette image du produit ?')) return;
|
||||||
|
|
||||||
|
this.ps.deleteProductImage(this.productRow.id, imageId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
// On la retire du tableau local et on reconstruit le carrousel
|
||||||
|
this.existingImageUrls.splice(idx, 1);
|
||||||
|
this.buildCarousel();
|
||||||
|
|
||||||
|
// Repositionnement de l’index si nécessaire
|
||||||
|
if (this.currentIndex >= this.carouselItems.length - 1) {
|
||||||
|
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e: unknown) => {
|
||||||
|
alert('Erreur lors de la suppression de l’image : ' + (e instanceof Error ? e.message : String(e)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cas 2 : image locale (nouvelle) ---
|
||||||
|
const localIdx = idx - existingCount;
|
||||||
|
if (localIdx >= 0 && localIdx < this.previewUrls.length) {
|
||||||
|
if (!confirm('Retirer cette image de la sélection ?')) return;
|
||||||
|
|
||||||
|
this.previewUrls.splice(localIdx, 1);
|
||||||
|
this.images.splice(localIdx, 1);
|
||||||
|
this.buildCarousel();
|
||||||
|
|
||||||
|
if (this.currentIndex >= this.carouselItems.length - 1) {
|
||||||
|
this.currentIndex = Math.max(0, this.carouselItems.length - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// utilisée par la grande image
|
||||||
|
onDeleteCurrentImage() {
|
||||||
|
if (!this.carouselItems.length) return;
|
||||||
|
this.deleteImageAtIndex(this.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// utilisée par la croix sur une vignette
|
||||||
|
onDeleteThumb(index: number, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.deleteImageAtIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
if (this.isSaving) return;
|
||||||
this.dialogRef.close(false);
|
this.dialogRef.close(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ export class RegisterComponent implements OnDestroy {
|
|||||||
isSubmitted = false;
|
isSubmitted = false;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
||||||
|
private readonly passwordPattern: RegExp = new RegExp(
|
||||||
|
'^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[\\p{P}\\p{S}]).{8,}$',
|
||||||
|
'u'
|
||||||
|
);
|
||||||
|
|
||||||
private readonly router: Router = inject(Router);
|
private readonly router: Router = inject(Router);
|
||||||
private readonly authService: AuthService = inject(AuthService);
|
private readonly authService: AuthService = inject(AuthService);
|
||||||
|
|
||||||
@@ -90,14 +95,14 @@ export class RegisterComponent implements OnDestroy {
|
|||||||
password: ['', [
|
password: ['', [
|
||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.minLength(8),
|
Validators.minLength(8),
|
||||||
Validators.maxLength(20),
|
Validators.maxLength(50),
|
||||||
Validators.pattern('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$')
|
Validators.pattern(this.passwordPattern)
|
||||||
]],
|
]],
|
||||||
confirmPassword: ['', [
|
confirmPassword: ['', [
|
||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.minLength(8),
|
Validators.minLength(8),
|
||||||
Validators.maxLength(20),
|
Validators.maxLength(50),
|
||||||
Validators.pattern('^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$')
|
Validators.pattern(this.passwordPattern)
|
||||||
]],
|
]],
|
||||||
termsAndConditions: [false, Validators.requiredTrue]
|
termsAndConditions: [false, Validators.requiredTrue]
|
||||||
}, {validators: this.passwordMatchValidator});
|
}, {validators: this.passwordMatchValidator});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import {inject, Injectable} from '@angular/core';
|
import {inject, Injectable} from '@angular/core';
|
||||||
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
|
import {HttpClient, HttpHeaders, HttpParams} from '@angular/common/http';
|
||||||
import {forkJoin, map, of, switchMap, Observable, catchError} from 'rxjs';
|
import {forkJoin, map, of, switchMap, Observable, catchError, from} from 'rxjs';
|
||||||
import {PsItem} from '../interfaces/ps-item';
|
import {PsItem} from '../interfaces/ps-item';
|
||||||
import {PsProduct} from '../interfaces/ps-product';
|
import {PsProduct} from '../interfaces/ps-product';
|
||||||
import {ProductListItem} from '../interfaces/product-list-item';
|
import {ProductListItem} from '../interfaces/product-list-item';
|
||||||
import {environment} from '../../environments/environment';
|
import {environment} from '../../environments/environment';
|
||||||
|
import {resizeImage} from '../utils/image-utils';
|
||||||
|
|
||||||
type Resource = 'categories' | 'manufacturers' | 'suppliers';
|
type Resource = 'categories' | 'manufacturers' | 'suppliers';
|
||||||
|
|
||||||
@@ -499,9 +500,30 @@ export class PrestashopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uploadProductImage(productId: number, file: File) {
|
uploadProductImage(productId: number, file: File) {
|
||||||
const fd = new FormData();
|
// 1) Compression AVANT upload
|
||||||
fd.append('image', file);
|
return from(resizeImage(file, 1600, 1600, 0.8)).pipe(
|
||||||
return this.http.post(`${this.base}/images/products/${productId}`, fd);
|
switchMap(compressedFile => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', compressedFile); // ← image compressée
|
||||||
|
|
||||||
|
// 2) Envoi vers ton backend (identique à avant)
|
||||||
|
return this.http.post(
|
||||||
|
`${this.base}/images/products/${productId}`,
|
||||||
|
fd,
|
||||||
|
{ reportProgress: true, observe: 'events' }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteProductImage(productId: number, imageId: number) {
|
||||||
|
// Presta : DELETE /images/products/{id_product}/{id_image}
|
||||||
|
return this.http.delete(
|
||||||
|
`${this.base}/images/products/${productId}/${imageId}`,
|
||||||
|
{ responseType: 'text' }
|
||||||
|
).pipe(
|
||||||
|
map(() => true)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Stock (quantité) — gestion fine via stock_availables
|
// -------- Stock (quantité) — gestion fine via stock_availables
|
||||||
|
|||||||
45
client/src/app/utils/image-utils.ts
Normal file
45
client/src/app/utils/image-utils.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export function resizeImage(
|
||||||
|
file: File,
|
||||||
|
maxWidth = 1600,
|
||||||
|
maxHeight = 1600,
|
||||||
|
quality = 0.8
|
||||||
|
): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = e => {
|
||||||
|
if (!e.target?.result) return reject('Cannot load file');
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
let { width, height } = img;
|
||||||
|
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||||
|
const newW = Math.round(width * ratio);
|
||||||
|
const newH = Math.round(height * ratio);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = newW;
|
||||||
|
canvas.height = newH;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return reject('Cannot get canvas context');
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, newW, newH);
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
blob => {
|
||||||
|
if (!blob) return reject('Compression failed');
|
||||||
|
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = e.target.result as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
apiUrl: 'https://dev.vincent-guillet.fr/gameovergne-api/api',
|
apiUrl: '/gameovergne-api/api',
|
||||||
psUrl: 'https://dev.vincent-guillet.fr/gameovergne-api/api/ps'
|
psUrl: '/gameovergne-api/api/ps',
|
||||||
|
hrefBase: '/gameovergne/',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: 'http://localhost:3000/api',
|
apiUrl: 'http://localhost:3000/api',
|
||||||
psUrl: '/ps'
|
psUrl: '/ps',
|
||||||
|
hrefBase: '/',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Game Over'gne App</title>
|
<title>Game Over'gne App</title>
|
||||||
<base href="./">
|
<base href="/gameovergne/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body class="mat-typography">
|
<body class="mat-typography">
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ body, html {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body { height: 100%; }
|
body {
|
||||||
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
|
margin: 0;
|
||||||
|
font-family: Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|||||||
86
docker-compose.dev.yml
Normal file
86
docker-compose.dev.yml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# docker-compose.dev.yml
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: gameovergne-mysql
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: gameovergne_app
|
||||||
|
MYSQL_USER: gameovergne
|
||||||
|
MYSQL_PASSWORD: gameovergne
|
||||||
|
volumes:
|
||||||
|
- ./mysql-data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3366:3306"
|
||||||
|
networks:
|
||||||
|
- gameovergne
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
spring:
|
||||||
|
image: registry.vincent-guillet.fr/gameovergne-api:dev-latest
|
||||||
|
container_name: gameovergne-api
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
SPRING_DATASOURCE_USERNAME: gameovergne
|
||||||
|
SPRING_DATASOURCE_PASSWORD: gameovergne
|
||||||
|
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- gameovergne
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
|
||||||
|
- traefik.http.routers.gameovergne-api.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-api.service=gameovergne-api
|
||||||
|
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
|
||||||
|
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
|
||||||
|
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
|
||||||
|
|
||||||
|
angular:
|
||||||
|
image: registry.vincent-guillet.fr/gameovergne-client:dev-latest
|
||||||
|
container_name: gameovergne-client
|
||||||
|
depends_on:
|
||||||
|
- spring
|
||||||
|
networks:
|
||||||
|
- gameovergne
|
||||||
|
- traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
|
||||||
|
- traefik.http.routers.gameovergne-client.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-client.service=gameovergne-client
|
||||||
|
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
|
||||||
|
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
|
||||||
|
|
||||||
|
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
||||||
|
|
||||||
|
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
|
||||||
|
- traefik.http.routers.gameovergne-ps.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
|
||||||
|
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
gameovergne:
|
||||||
|
external: true
|
||||||
86
docker-compose.prod.yml
Normal file
86
docker-compose.prod.yml
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# docker-compose.prod.yml
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: gameovergne-mysql-prod
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: gameovergne_app
|
||||||
|
MYSQL_USER: gameovergne
|
||||||
|
MYSQL_PASSWORD: gameovergne
|
||||||
|
volumes:
|
||||||
|
- ./mysql-data-prod:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3366:3306"
|
||||||
|
networks:
|
||||||
|
- gameovergne
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-proot"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
spring:
|
||||||
|
image: registry.vincent-guillet.fr/gameovergne-api:prod-latest
|
||||||
|
container_name: gameovergne-api-prod
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
SPRING_DATASOURCE_USERNAME: gameovergne
|
||||||
|
SPRING_DATASOURCE_PASSWORD: gameovergne
|
||||||
|
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
|
||||||
|
SERVER_PORT: 3000
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- gameovergne
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-api.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
|
||||||
|
- traefik.http.routers.gameovergne-api.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-api.service=gameovergne-api
|
||||||
|
- traefik.http.services.gameovergne-api.loadbalancer.server.port=3000
|
||||||
|
- traefik.http.routers.gameovergne-api.middlewares=gameovergne-api-stripprefix
|
||||||
|
- traefik.http.middlewares.gameovergne-api-stripprefix.stripprefix.prefixes=/gameovergne-api
|
||||||
|
|
||||||
|
angular:
|
||||||
|
image: registry.vincent-guillet.fr/gameovergne-client:prod-latest
|
||||||
|
container_name: gameovergne-client-prod
|
||||||
|
depends_on:
|
||||||
|
- spring
|
||||||
|
networks:
|
||||||
|
- gameovergne
|
||||||
|
- traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-client.rule=Host(`projets.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
|
||||||
|
- traefik.http.routers.gameovergne-client.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-client.service=gameovergne-client
|
||||||
|
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
|
||||||
|
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
|
||||||
|
|
||||||
|
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
||||||
|
|
||||||
|
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-ps.rule=Host(`projets.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
|
||||||
|
- traefik.http.routers.gameovergne-ps.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
|
||||||
|
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
gameovergne:
|
||||||
|
external: true
|
||||||
@@ -8,7 +8,9 @@ services:
|
|||||||
MYSQL_USER: gameovergne
|
MYSQL_USER: gameovergne
|
||||||
MYSQL_PASSWORD: gameovergne
|
MYSQL_PASSWORD: gameovergne
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- ./mysql-data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3366:3306"
|
||||||
networks:
|
networks:
|
||||||
- gameovergne
|
- gameovergne
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -28,15 +30,16 @@ services:
|
|||||||
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/gameovergne_app?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
SPRING_DATASOURCE_USERNAME: gameovergne
|
SPRING_DATASOURCE_USERNAME: gameovergne
|
||||||
SPRING_DATASOURCE_PASSWORD: gameovergne
|
SPRING_DATASOURCE_PASSWORD: gameovergne
|
||||||
|
PRESTASHOP_API_KEY: 2AQPG13MJ8X117U6FJ5NGHPS93HE34AB
|
||||||
|
SERVER_PORT: 3000
|
||||||
networks:
|
networks:
|
||||||
- gameovergne
|
|
||||||
- traefik
|
- traefik
|
||||||
|
- gameovergne
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
# API sous /gameovergne-api
|
|
||||||
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
|
- traefik.http.routers.gameovergne-api.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne-api`)
|
||||||
- traefik.http.routers.gameovergne-api.entrypoints=edge
|
- traefik.http.routers.gameovergne-api.entrypoints=edge
|
||||||
- traefik.http.routers.gameovergne-api.service=gameovergne-api
|
- traefik.http.routers.gameovergne-api.service=gameovergne-api
|
||||||
@@ -57,26 +60,26 @@ services:
|
|||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|
||||||
# FRONT sous /gameovergne (avec et sans slash final)
|
|
||||||
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
|
- traefik.http.routers.gameovergne-client.rule=Host(`dev.vincent-guillet.fr`) && (Path(`/gameovergne`) || PathPrefix(`/gameovergne/`))
|
||||||
- traefik.http.routers.gameovergne-client.entrypoints=edge
|
- traefik.http.routers.gameovergne-client.entrypoints=edge
|
||||||
- traefik.http.routers.gameovergne-client.service=gameovergne-client
|
- traefik.http.routers.gameovergne-client.service=gameovergne-client
|
||||||
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
# 1) Middleware : ajoute un "/" si on arrive sur /gameovergne sans slash
|
|
||||||
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
|
- traefik.http.routers.gameovergne-client.middlewares=gameovergne-slash,gameovergne-client-stripprefix
|
||||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^(.*/gameovergne)$$
|
|
||||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=$$1/
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.regex=^https?://([^/]+)/gameovergne$$
|
||||||
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.replacement=https://$${1}/gameovergne/
|
||||||
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
|
- traefik.http.middlewares.gameovergne-slash.redirectregex.permanent=true
|
||||||
|
|
||||||
# 2) Ensuite on enlève le préfixe /gameovergne avant d'envoyer à Nginx
|
|
||||||
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
- traefik.http.middlewares.gameovergne-client-stripprefix.stripprefix.prefixes=/gameovergne
|
||||||
|
|
||||||
|
- traefik.http.services.gameovergne-client.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
- traefik.http.routers.gameovergne-ps.rule=Host(`dev.vincent-guillet.fr`) && PathPrefix(`/gameovergne/ps`)
|
||||||
|
- traefik.http.routers.gameovergne-ps.entrypoints=edge
|
||||||
|
- traefik.http.routers.gameovergne-ps.service=gameovergne-client
|
||||||
|
- traefik.http.routers.gameovergne-ps.middlewares=gameovergne-client-stripprefix
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
external: true
|
external: true
|
||||||
gameovergne:
|
gameovergne:
|
||||||
driver: bridge
|
external: true
|
||||||
|
|
||||||
volumes:
|
|
||||||
mysql-data:
|
|
||||||
143
jenkinsfile
143
jenkinsfile
@@ -1,67 +1,120 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent none
|
||||||
|
|
||||||
tools {
|
|
||||||
maven 'mvn'
|
|
||||||
nodejs 'npm'
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
environment {
|
||||||
JAVA_HOME = '/opt/java/openjdk'
|
REGISTRY = 'registry.vincent-guillet.fr'
|
||||||
PATH = "${JAVA_HOME}/bin:${env.PATH}"
|
API_IMAGE_DEV = "${REGISTRY}/gameovergne-api:dev-latest"
|
||||||
SPRING_IMAGE_NAME = 'spring-jenkins'
|
CLIENT_IMAGE_DEV = "${REGISTRY}/gameovergne-client:dev-latest"
|
||||||
ANGULAR_IMAGE_NAME = 'angular-jenkins'
|
API_IMAGE_PROD = "${REGISTRY}/gameovergne-api:prod-latest"
|
||||||
IMAGE_TAG = 'latest'
|
CLIENT_IMAGE_PROD = "${REGISTRY}/gameovergne-client:prod-latest"
|
||||||
COMPOSE_PROJECT = 'gameovergne-app'
|
COMPOSE_PROJECT = 'gameovergne-app'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('Checkout sur la branche dev') {
|
|
||||||
steps {
|
|
||||||
git branch: 'dev', url: 'https://gitea.vincent-guillet.fr/vincentguillet/gameovergne-app.git'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Maven Build') {
|
// Build & push images (toujours sur ct-home-dev)
|
||||||
|
stage('Build & Push Docker Images') {
|
||||||
|
agent { label 'ct-home-dev' }
|
||||||
|
|
||||||
steps {
|
steps {
|
||||||
|
// Multi-branch friendly : Jenkins fait le checkout de la branche courante
|
||||||
|
checkout scm
|
||||||
|
|
||||||
|
script {
|
||||||
|
// Choix des tags selon la branche
|
||||||
|
if (env.BRANCH_NAME == 'main') {
|
||||||
|
env.API_IMAGE = API_IMAGE_PROD
|
||||||
|
env.CLIENT_IMAGE = CLIENT_IMAGE_PROD
|
||||||
|
} else {
|
||||||
|
env.API_IMAGE = API_IMAGE_DEV
|
||||||
|
env.CLIENT_IMAGE = CLIENT_IMAGE_DEV
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Build API -----
|
||||||
dir('api') {
|
dir('api') {
|
||||||
sh 'mvn clean package -DskipTests'
|
sh """
|
||||||
|
echo "=== Build image API ${API_IMAGE} ==="
|
||||||
|
docker build -t ${API_IMAGE} .
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Angular Build') {
|
// ----- Build Client -----
|
||||||
steps {
|
|
||||||
dir('client') {
|
dir('client') {
|
||||||
sh 'npm install'
|
sh """
|
||||||
sh 'npm run build'
|
echo "=== Build image CLIENT ${CLIENT_IMAGE} ==="
|
||||||
|
docker build -t ${CLIENT_IMAGE} .
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Push vers registry -----
|
||||||
|
sh """
|
||||||
|
echo "=== Push images vers ${REGISTRY} ==="
|
||||||
|
docker push ${API_IMAGE}
|
||||||
|
docker push ${CLIENT_IMAGE}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déploiement DEV (ct-home-dev, branche dev)
|
||||||
|
stage('Deploy DEV') {
|
||||||
|
when {
|
||||||
|
branch 'dev'
|
||||||
|
}
|
||||||
|
agent { label 'ct-home-dev' }
|
||||||
|
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
|
||||||
|
withEnv([
|
||||||
|
"DOCKER_HOST=unix:///var/run/docker.sock",
|
||||||
|
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
|
||||||
|
]) {
|
||||||
|
sh """
|
||||||
|
echo "=== [DEV] Nettoyage anciens conteneurs ==="
|
||||||
|
docker rm -f gameovergne-api gameovergne-client 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== [DEV] docker compose down ==="
|
||||||
|
docker compose -f docker-compose.dev.yml down -v || true
|
||||||
|
|
||||||
|
echo "=== [DEV] docker compose pull ==="
|
||||||
|
docker compose -f docker-compose.dev.yml pull
|
||||||
|
|
||||||
|
echo "=== [DEV] docker compose up (force recreate) ==="
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --force-recreate mysql spring angular
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Spring Docker Build') {
|
// Déploiement PROD (ct-home-projets, branche main)
|
||||||
steps {
|
stage('Deploy PROD') {
|
||||||
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-api:dev-latest ./api'
|
when {
|
||||||
|
branch 'main'
|
||||||
}
|
}
|
||||||
}
|
agent { label 'ct-home-projets' }
|
||||||
|
|
||||||
stage('Angular Docker Build') {
|
|
||||||
steps {
|
steps {
|
||||||
sh 'docker build -t registry.vincent-guillet.fr/gameovergne-client:dev-latest ./client'
|
checkout scm
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Deployment') {
|
withEnv([
|
||||||
steps {
|
"DOCKER_HOST=unix:///var/run/docker.sock",
|
||||||
sh '''
|
"COMPOSE_PROJECT_NAME=${env.COMPOSE_PROJECT}"
|
||||||
CONTAINERS=$(docker ps -a --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" -q || true)
|
]) {
|
||||||
if [ -n "$CONTAINERS" ]; then
|
sh """
|
||||||
docker rm -f $CONTAINERS
|
echo "=== [PROD] Nettoyage anciens conteneurs ==="
|
||||||
fi
|
docker rm -f gameovergne-api-prod gameovergne-client-prod 2>/dev/null || true
|
||||||
|
|
||||||
echo "=== (Re)création de la stack MySQL + Spring + Angular ==="
|
echo "=== [PROD] docker compose down ==="
|
||||||
docker-compose up -d mysql spring angular
|
docker compose -f docker-compose.prod.yml down || true
|
||||||
'''
|
|
||||||
|
echo "=== [PROD] docker compose pull ==="
|
||||||
|
docker compose -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
|
echo "=== [PROD] docker compose up (force recreate) ==="
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --force-recreate mysql spring angular
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user