From 93120f6bfe812194b1b594678844bd6620460b4f Mon Sep 17 00:00:00 2001 From: Xor290 Date: Tue, 19 May 2026 17:35:39 +0200 Subject: [PATCH] feat: update multiple tomtom keys and switch tomtom key and add comming soon button --- backend/gestion/db/db_init.go | 5 + backend/gestion/db/db_product.go | 12 +++ backend/gestion/handlers/product.go | 10 ++ backend/gestion/models/product.go | 19 ++-- backend/gestion/services/geo_services.go | 48 ++++------ backend/gestion/services/tomtom.go | 44 ++++----- backend/gestion/services/tomtom_keys.go | 96 +++++++++++++++++++ docker/docker-compose-prod.yml | 3 + .../src/screens/admin/ProductsScreen.tsx | 39 ++++++++ .../public/IMG_0209.MP4:Zone.Identifier | 3 - frontend-prep/src/api/api.ts | 1 + frontend-prep/src/components/ProductCard.css | 23 +++++ frontend-prep/src/components/ProductCard.tsx | 6 ++ frontend-prep/src/pages/User/Accueil.tsx | 1 + .../src/pages/User/ProductDetail.css | 34 +++++++ .../src/pages/User/ProductDetail.tsx | 2 + mobile/src/components/ProductCard.tsx | 32 +++++++ .../screens/client/ProductDetailScreen.tsx | 32 +++++++ 18 files changed, 341 insertions(+), 69 deletions(-) create mode 100644 backend/gestion/services/tomtom_keys.go delete mode 100644 frontend-prep/public/IMG_0209.MP4:Zone.Identifier diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 6b98ac48..09d14388 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -236,6 +236,11 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration clients.points_extra: %v", err) } + // Migration: flag "à venir" sur les produits + if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil { + log.Fatalf("❌ Erreur migration products.coming_soon: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go index 46902129..d085b16f 100644 --- a/backend/gestion/db/db_product.go +++ b/backend/gestion/db/db_product.go @@ -211,6 +211,18 @@ func (d *Database) SetProductStock(productID int, stock float64) error { return nil } +func (d *Database) SetProductComingSoon(productID int, comingSoon bool) error { + result := d.GDB.Exec(`UPDATE products SET coming_soon = ?, updated_at = ? WHERE id = ?`, + comingSoon, time.Now(), productID) + if result.Error != nil { + return fmt.Errorf("erreur mise à jour coming_soon: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("produit non trouvé") + } + return nil +} + // DeleteProduct supprime un produit func (d *Database) DeleteProduct(productID int) error { result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID) diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 40d4c75b..6ebcc7ee 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -278,6 +278,8 @@ func CreateProduct(c *gin.Context) { log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name) + comingSoon := c.PostForm("coming_soon") == "true" + // ✅ CRÉER LE PRODUIT product := models.Product{ Name: name, @@ -285,6 +287,7 @@ func CreateProduct(c *gin.Context) { Description: description, Stock: stock, Unit: unit, + ComingSoon: comingSoon, Prices: prices, } @@ -573,6 +576,7 @@ func UpdateProduct(c *gin.Context) { Unit string `json:"unit"` Prices []models.ProductPrice `json:"prices"` Stock *float64 `json:"stock"` + ComingSoon *bool `json:"coming_soon"` } if err := c.ShouldBindJSON(&updateData); err != nil { @@ -638,6 +642,12 @@ func UpdateProduct(c *gin.Context) { } } + if updateData.ComingSoon != nil { + if err := database.SetProductComingSoon(id, *updateData.ComingSoon); err != nil { + log.Printf("⚠️ [UpdateProduct] Erreur mise à jour coming_soon: %v", err) + } + } + // ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR updatedProduct, _ := database.GetProductByID(id) media, _ := database.GetMediaByProductID(id) diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index 0103c371..cb66b162 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -3,16 +3,17 @@ package models import "time" type Product struct { - ID int `json:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" gorm:"column:name" binding:"required"` - Category string `json:"category" gorm:"column:category" binding:"required"` - Description string `json:"description" gorm:"column:description"` - Stock float64 `json:"stock" gorm:"column:stock"` - Unit string `json:"unit" gorm:"column:unit"` - Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"` + ID int `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"column:name" binding:"required"` + Category string `json:"category" gorm:"column:category" binding:"required"` + Description string `json:"description" gorm:"column:description"` + Stock float64 `json:"stock" gorm:"column:stock"` + Unit string `json:"unit" gorm:"column:unit"` + ComingSoon bool `json:"coming_soon" gorm:"column:coming_soon;default:false"` + Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"` Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } func (Product) TableName() string { return "products" } diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index 4f5708d6..fc7e1092 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -10,7 +10,6 @@ import ( "math" "net/http" "net/url" - "os" "strings" "time" @@ -266,44 +265,37 @@ func CalculateETA(distanceKm float64) int { // CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel) // Retourne (etaMinutes, distanceKm, error) func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { - apiKey := os.Getenv("TOMTOM_API_KEY") - if apiKey == "" { - // Fallback sur calcul local si pas de clé API - distance := CalculateDistance(from, to) - return CalculateETA(distance), distance, nil - } - - // API TomTom Routing: Calculate Route avec trafic - u := &url.URL{ - Scheme: "https", - Host: "api.tomtom.com", - Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude), - } - q := url.Values{} - q.Set("key", apiKey) - q.Set("traffic", "true") - q.Set("travelMode", "car") - u.RawQuery = q.Encode() - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { + if len(tomTomKeys.keys) == 0 { distance := CalculateDistance(from, to) return CalculateETA(distance), distance, nil } client := &http.Client{Timeout: 8 * time.Second} - resp, err := client.Do(req) + + buildReq := func(key string) (*http.Request, error) { + u := &url.URL{ + Scheme: "https", + Host: "api.tomtom.com", + Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude), + } + q := url.Values{} + q.Set("key", key) + q.Set("traffic", "true") + q.Set("travelMode", "car") + u.RawQuery = q.Encode() + return http.NewRequest(http.MethodGet, u.String(), nil) + } + + resp, err := tomTomKeys.Do(client, buildReq) if err != nil { - // Fallback sur calcul local en cas d'erreur réseau distance := CalculateDistance(from, to) eta := CalculateETA(distance) - fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta) + fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err) return eta, distance, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - // Fallback sur calcul local en cas d'erreur API distance := CalculateDistance(from, to) eta := CalculateETA(distance) fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta) @@ -328,18 +320,14 @@ func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { } summary := routeResponse.Routes[0].Summary - - // Calculer ETA en minutes (arrondi supérieur) etaMinutes := (summary.TravelTimeInSeconds + 59) / 60 distanceKm := float64(summary.LengthInMeters) / 1000.0 - // Appliquer minimum if etaMinutes < MinETA { etaMinutes = MinETA } fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes) - return etaMinutes, distanceKm, nil } diff --git a/backend/gestion/services/tomtom.go b/backend/gestion/services/tomtom.go index 7b970cd2..01c7021a 100644 --- a/backend/gestion/services/tomtom.go +++ b/backend/gestion/services/tomtom.go @@ -12,36 +12,29 @@ import ( "log" "net/http" "net/url" - "os" "time" ) func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) { - apiKey := os.Getenv("TOMTOM_API_KEY") - if apiKey == "" { - return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée") - } - - u := &url.URL{ - Scheme: "https", - Host: "api.tomtom.com", - Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude), - } - q := url.Values{} - q.Set("key", apiKey) - q.Set("traffic", "true") - q.Set("travelMode", "car") - u.RawQuery = q.Encode() - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return 0, 0, fmt.Errorf("erreur construction requête TomTom: %w", err) - } - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + + buildReq := func(key string) (*http.Request, error) { + u := &url.URL{ + Scheme: "https", + Host: "api.tomtom.com", + Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude), + } + q := url.Values{} + q.Set("key", key) + q.Set("traffic", "true") + q.Set("travelMode", "car") + u.RawQuery = q.Encode() + return http.NewRequest(http.MethodGet, u.String(), nil) + } + + resp, err := tomTomKeys.Do(client, buildReq) if err != nil { - return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err) + return 0, 0, fmt.Errorf("erreur TomTom: %w", err) } defer resp.Body.Close() @@ -65,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64 } summary := routeResponse.Routes[0].Summary - - // Calculer ETA en minutes (arrondi supérieur) etaMinutes = (summary.TravelTimeInSeconds + 59) / 60 distanceKm = float64(summary.LengthInMeters) / 1000.0 log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes) - return etaMinutes, distanceKm, nil } diff --git a/backend/gestion/services/tomtom_keys.go b/backend/gestion/services/tomtom_keys.go new file mode 100644 index 00000000..ce1f483f --- /dev/null +++ b/backend/gestion/services/tomtom_keys.go @@ -0,0 +1,96 @@ +package services + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "sync/atomic" +) + +type tomTomKeyManager struct { + keys []string + current atomic.Int32 +} + +var tomTomKeys = initTomTomKeyManager() + +func initTomTomKeyManager() *tomTomKeyManager { + m := &tomTomKeyManager{} + seen := map[string]bool{} + + candidates := []string{ + os.Getenv("TOMTOM_API_KEY"), + os.Getenv("TOMTOM_API_KEY_1"), + os.Getenv("TOMTOM_API_KEY_2"), + os.Getenv("TOMTOM_API_KEY_3"), + } + for _, k := range candidates { + if k != "" && !seen[k] { + seen[k] = true + m.keys = append(m.keys, k) + } + } + + log.Printf("🔑 [TOMTOM] %d clé(s) API configurée(s)", len(m.keys)) + return m +} + +// currentKey retourne la clé active et son index. +func (m *tomTomKeyManager) currentKey() (string, int) { + n := len(m.keys) + if n == 0 { + return "", -1 + } + idx := int(m.current.Load()) % n + return m.keys[idx], idx +} + +// rotate passe à la clé suivante. +func (m *tomTomKeyManager) rotate(fromIdx int) { + n := len(m.keys) + if n <= 1 { + return + } + next := int32((fromIdx + 1) % n) + m.current.CompareAndSwap(int32(fromIdx), next) + log.Printf("🔄 [TOMTOM] Rotation clé %d → clé %d (quota atteint)", fromIdx+1, next+1) +} + +// Do exécute la requête en rotant automatiquement sur 403/429. +// buildReq doit construire une nouvelle *http.Request pour la clé donnée. +func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) { + n := len(m.keys) + if n == 0 { + return nil, fmt.Errorf("aucune clé TomTom configurée (TOMTOM_API_KEY / TOMTOM_API_KEY_1..3)") + } + + _, startIdx := m.currentKey() + + for attempt := 0; attempt < n; attempt++ { + idx := (startIdx + attempt) % n + key := m.keys[idx] + + req, err := buildReq(key) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + m.rotate(idx) + continue + } + + return resp, nil + } + + return nil, fmt.Errorf("toutes les clés TomTom ont atteint leur quota (%d clé(s) testée(s))", n) +} diff --git a/docker/docker-compose-prod.yml b/docker/docker-compose-prod.yml index 7269a18b..92007d63 100644 --- a/docker/docker-compose-prod.yml +++ b/docker/docker-compose-prod.yml @@ -22,6 +22,9 @@ services: - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PASSWORD=${REDIS_PASSWORD} - TOMTOM_API_KEY=${TOMTOM_API_KEY} + - TOMTOM_API_KEY_1=${TOMTOM_API_KEY_1} + - TOMTOM_API_KEY_2=${TOMTOM_API_KEY_2} + - TOMTOM_API_KEY_3=${TOMTOM_API_KEY_3} - API_PORT=${API_PORT:-8080} - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL} - TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET} diff --git a/frontend-admin/src/screens/admin/ProductsScreen.tsx b/frontend-admin/src/screens/admin/ProductsScreen.tsx index c4540afe..d88c1b24 100644 --- a/frontend-admin/src/screens/admin/ProductsScreen.tsx +++ b/frontend-admin/src/screens/admin/ProductsScreen.tsx @@ -78,6 +78,7 @@ interface FormState { stock: string; unit: string; prices: PriceRow[]; + comingSoon: boolean; } const emptyForm = (firstCategory = ""): FormState => ({ @@ -87,6 +88,7 @@ const emptyForm = (firstCategory = ""): FormState => ({ stock: "", unit: "u", prices: [{ quantity: "1", price: "", active: true }], + comingSoon: false, }); // ================================================== @@ -163,6 +165,7 @@ export default function ProductsScreen() { description: product.description || "", stock: product.stock.toString(), unit: product.unit || "u", + comingSoon: product.coming_soon ?? false, prices: product.prices && product.prices.length > 0 ? product.prices.map((p) => ({ @@ -339,6 +342,7 @@ export default function ProductsScreen() { stock: parseFloat(form.stock), unit: form.unit, prices, + coming_soon: form.comingSoon, }); productId = editingProduct.id; } else { @@ -349,6 +353,7 @@ export default function ProductsScreen() { fd.append("description", form.description.trim()); fd.append("stock", form.stock); fd.append("unit", form.unit); + fd.append("coming_soon", form.comingSoon ? "true" : "false"); prices.forEach((p, i) => { fd.append(`prices[${i}][quantity]`, String(p.quantity)); @@ -627,6 +632,22 @@ export default function ProductsScreen() { catBtnText: { color: colors.textMuted, fontSize: fontSize.sm }, catBtnTextActive: { color: colors.accent, fontWeight: "600" }, + comingSoonBtn: { + borderWidth: 1, + borderColor: colors.textMuted, + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 14, + marginBottom: 16, + alignItems: "center", + }, + comingSoonBtnActive: { + borderColor: "#f59e0b", + backgroundColor: "#f59e0b20", + }, + comingSoonBtnText: { color: colors.textMuted, fontSize: fontSize.sm }, + comingSoonBtnTextActive: { color: "#f59e0b", fontWeight: "700" }, + // Prices sectionHeader: { flexDirection: "row", @@ -999,6 +1020,24 @@ export default function ProductsScreen() { keyboardType="numeric" /> + {/* À venir */} + + setForm((f) => ({ ...f, comingSoon: !f.comingSoon })) + } + > + + {form.comingSoon ? "🔜 À venir (activé)" : "🔜 Marquer comme «À venir»"} + + + {/* Unité de mesure */} Unité de mesure * diff --git a/frontend-prep/public/IMG_0209.MP4:Zone.Identifier b/frontend-prep/public/IMG_0209.MP4:Zone.Identifier deleted file mode 100644 index 053d1127..00000000 --- a/frontend-prep/public/IMG_0209.MP4:Zone.Identifier +++ /dev/null @@ -1,3 +0,0 @@ -[ZoneTransfer] -ZoneId=3 -HostUrl=about:internet diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 11dc6c05..49e355d1 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -817,6 +817,7 @@ export interface Product { stock: number; prices?: Array<{ quantity: number; price: number; active_price?: boolean }>; media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[] + coming_soon?: boolean; } export interface Category { diff --git a/frontend-prep/src/components/ProductCard.css b/frontend-prep/src/components/ProductCard.css index 4dbe3ae0..ff17b978 100644 --- a/frontend-prep/src/components/ProductCard.css +++ b/frontend-prep/src/components/ProductCard.css @@ -62,6 +62,29 @@ 0 0 10px rgba(255, 0, 0, 0.5); } +.coming-soon-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-15deg); + text-align: center; + z-index: 5; + pointer-events: none; + background-color: rgba(245, 158, 11, 0.95); + color: white; + border: 6px solid white; + padding: clamp(0.5rem, 2vw, 0.8rem) clamp(1.5rem, 5vw, 2.5rem); + font-size: clamp(1.1rem, 4.5vw, 1.8rem); + font-weight: 900; + letter-spacing: 3px; + text-transform: uppercase; + white-space: nowrap; + box-shadow: 0 0 8px rgba(245, 158, 11, 0.6); + text-shadow: + 2px 2px 6px rgba(0, 0, 0, 0.6), + -2px -2px 6px rgba(0, 0, 0, 0.6); +} + .product-card.out-of-stock { opacity: 0.75; } diff --git a/frontend-prep/src/components/ProductCard.tsx b/frontend-prep/src/components/ProductCard.tsx index 871f4f8b..287d6ba6 100644 --- a/frontend-prep/src/components/ProductCard.tsx +++ b/frontend-prep/src/components/ProductCard.tsx @@ -26,6 +26,7 @@ interface ProductCardProps { hasVideo?: boolean; videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo categoryColor?: string; + coming_soon?: boolean; } function ProductCard({ @@ -40,6 +41,7 @@ function ProductCard({ hasVideo = false, videoUrl, categoryColor, + coming_soon, }: ProductCardProps) { const navigate = useNavigate(); const { addToCart } = useCart(); @@ -52,6 +54,7 @@ function ProductCard({ const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo const isOutOfStock = stock === 0; + const isComingSoon = coming_soon === true; const normalizedCategory = (category || "autre").toLowerCase().trim(); const handleDetailsClick = (e: React.MouseEvent) => { @@ -156,6 +159,9 @@ function ProductCard({ {isOutOfStock && (
SOLD OUT
)} + {isComingSoon && !isOutOfStock && ( +
À VENIR
+ )}
diff --git a/frontend-prep/src/pages/User/Accueil.tsx b/frontend-prep/src/pages/User/Accueil.tsx index c79ce427..d3ac7734 100644 --- a/frontend-prep/src/pages/User/Accueil.tsx +++ b/frontend-prep/src/pages/User/Accueil.tsx @@ -219,6 +219,7 @@ function UserAccueil() { product.category?.toLowerCase(), )?.color } + coming_soon={product.coming_soon} />
))} diff --git a/frontend-prep/src/pages/User/ProductDetail.css b/frontend-prep/src/pages/User/ProductDetail.css index 3a4c81ed..dfc40b8f 100644 --- a/frontend-prep/src/pages/User/ProductDetail.css +++ b/frontend-prep/src/pages/User/ProductDetail.css @@ -133,6 +133,40 @@ } } +/* Coming Soon Badge */ +.coming-soon-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-15deg); + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border: 4px solid white; + padding: clamp(1rem, 4vw, 1.5rem) clamp(2.5rem, 8vw, 4rem); + font-size: clamp(2rem, 8vw, 3.5rem); + font-weight: 900; + letter-spacing: 6px; + text-transform: uppercase; + white-space: nowrap; + box-shadow: + 0 0 40px rgba(245, 158, 11, 0.8), + 0 8px 32px rgba(0, 0, 0, 0.6); + text-shadow: + 2px 2px 12px rgba(0, 0, 0, 0.9), + 0 0 20px rgba(255, 255, 255, 0.3); + z-index: 10; + animation: pulseAmber 2s infinite; +} + +@keyframes pulseAmber { + 0%, 100% { + transform: translate(-50%, -50%) rotate(-15deg) scale(1); + } + 50% { + transform: translate(-50%, -50%) rotate(-15deg) scale(1.05); + } +} + /* Info Section */ .product-info-section { display: flex; diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx index 9f095e08..686ceb89 100644 --- a/frontend-prep/src/pages/User/ProductDetail.tsx +++ b/frontend-prep/src/pages/User/ProductDetail.tsx @@ -200,6 +200,7 @@ function ProductDetail() { } const isOutOfStock = product.stock === 0; + const isComingSoon = product.coming_soon === true; const hasValidPrices = product.prices && product.prices.length > 0; // Convertir la couleur hex en valeurs RGB pour les CSS rgba() @@ -248,6 +249,7 @@ function ProductDetail() { className="product-detail-image" /> {isOutOfStock &&
SOLD OUT
} + {isComingSoon && !isOutOfStock &&
À VENIR
}
diff --git a/mobile/src/components/ProductCard.tsx b/mobile/src/components/ProductCard.tsx index 599176ec..f58fe9ca 100644 --- a/mobile/src/components/ProductCard.tsx +++ b/mobile/src/components/ProductCard.tsx @@ -36,6 +36,7 @@ interface ProductCardProps { category: string; stock: number; unit?: string; + coming_soon?: boolean; prices?: Array<{ quantity: number; price: number; active_price?: boolean }>; media?: Array<{ url: string; type: string }>; }; @@ -48,6 +49,7 @@ export default function ProductCard({ product, onPress, categoryColor }: Product const { addToCart } = useCart(); const catColor = categoryColor ?? colors.accent; const isSoldOut = product.stock <= 0; + const isComingSoon = product.coming_soon === true; const activePrices = product.prices?.filter((p) => p.active_price !== false) ?? []; const firstPrice = activePrices[0]?.price ?? null; @@ -157,6 +159,11 @@ export default function ProductCard({ product, onPress, categoryColor }: Product SOLD OUT )} + {isComingSoon && ( + + À VENIR + + )} 0; const imageMedia = product.media?.find((m) => m.type === "image"); const videoMedia = product.media?.find((m) => m.type === "video"); @@ -517,6 +544,11 @@ export default function ProductDetailScreen() { SOLD OUT )} + {isComingSoon && !isOutOfStock && ( + + À VENIR + + )} {videoUri && !isOutOfStock && (