feat: update multiple tomtom keys and switch tomtom key and add comming soon button
This commit is contained in:
@@ -236,6 +236,11 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration clients.points_extra: %v", err)
|
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
|
// Lancer le nettoyage périodique des tokens expirés
|
||||||
go database.cleanExpiredTokensPeriodically()
|
go database.cleanExpiredTokensPeriodically()
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,18 @@ func (d *Database) SetProductStock(productID int, stock float64) error {
|
|||||||
return nil
|
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
|
// DeleteProduct supprime un produit
|
||||||
func (d *Database) DeleteProduct(productID int) error {
|
func (d *Database) DeleteProduct(productID int) error {
|
||||||
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
result := d.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||||
|
|||||||
@@ -278,6 +278,8 @@ func CreateProduct(c *gin.Context) {
|
|||||||
|
|
||||||
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
log.Printf("✅ [CreateProduct] %s crée produit: %s", username, name)
|
||||||
|
|
||||||
|
comingSoon := c.PostForm("coming_soon") == "true"
|
||||||
|
|
||||||
// ✅ CRÉER LE PRODUIT
|
// ✅ CRÉER LE PRODUIT
|
||||||
product := models.Product{
|
product := models.Product{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -285,6 +287,7 @@ func CreateProduct(c *gin.Context) {
|
|||||||
Description: description,
|
Description: description,
|
||||||
Stock: stock,
|
Stock: stock,
|
||||||
Unit: unit,
|
Unit: unit,
|
||||||
|
ComingSoon: comingSoon,
|
||||||
Prices: prices,
|
Prices: prices,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +576,7 @@ func UpdateProduct(c *gin.Context) {
|
|||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
Prices []models.ProductPrice `json:"prices"`
|
Prices []models.ProductPrice `json:"prices"`
|
||||||
Stock *float64 `json:"stock"`
|
Stock *float64 `json:"stock"`
|
||||||
|
ComingSoon *bool `json:"coming_soon"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&updateData); err != nil {
|
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
|
// ✅ RÉCUPÉRER LE PRODUIT MIS À JOUR
|
||||||
updatedProduct, _ := database.GetProductByID(id)
|
updatedProduct, _ := database.GetProductByID(id)
|
||||||
media, _ := database.GetMediaByProductID(id)
|
media, _ := database.GetMediaByProductID(id)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Product struct {
|
|||||||
Description string `json:"description" gorm:"column:description"`
|
Description string `json:"description" gorm:"column:description"`
|
||||||
Stock float64 `json:"stock" gorm:"column:stock"`
|
Stock float64 `json:"stock" gorm:"column:stock"`
|
||||||
Unit string `json:"unit" gorm:"column:unit"`
|
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"`
|
Prices []ProductPrice `json:"prices" gorm:"foreignKey:ProductID"`
|
||||||
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
Media []Media `json:"media,omitempty" gorm:"foreignKey:ProductID"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -266,44 +265,37 @@ func CalculateETA(distanceKm float64) int {
|
|||||||
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
||||||
// Retourne (etaMinutes, distanceKm, error)
|
// Retourne (etaMinutes, distanceKm, error)
|
||||||
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
||||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
if len(tomTomKeys.keys) == 0 {
|
||||||
if apiKey == "" {
|
|
||||||
// Fallback sur calcul local si pas de clé API
|
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
return CalculateETA(distance), distance, nil
|
return CalculateETA(distance), distance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// API TomTom Routing: Calculate Route avec trafic
|
client := &http.Client{Timeout: 8 * time.Second}
|
||||||
|
|
||||||
|
buildReq := func(key string) (*http.Request, error) {
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Scheme: "https",
|
Scheme: "https",
|
||||||
Host: "api.tomtom.com",
|
Host: "api.tomtom.com",
|
||||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("key", apiKey)
|
q.Set("key", key)
|
||||||
q.Set("traffic", "true")
|
q.Set("traffic", "true")
|
||||||
q.Set("travelMode", "car")
|
q.Set("travelMode", "car")
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
distance := CalculateDistance(from, to)
|
|
||||||
return CalculateETA(distance), distance, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: 8 * time.Second}
|
resp, err := tomTomKeys.Do(client, buildReq)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback sur calcul local en cas d'erreur réseau
|
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
eta := CalculateETA(distance)
|
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
|
return eta, distance, nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
// Fallback sur calcul local en cas d'erreur API
|
|
||||||
distance := CalculateDistance(from, to)
|
distance := CalculateDistance(from, to)
|
||||||
eta := CalculateETA(distance)
|
eta := CalculateETA(distance)
|
||||||
fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta)
|
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
|
summary := routeResponse.Routes[0].Summary
|
||||||
|
|
||||||
// Calculer ETA en minutes (arrondi supérieur)
|
|
||||||
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
||||||
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
||||||
|
|
||||||
// Appliquer minimum
|
|
||||||
if etaMinutes < MinETA {
|
if etaMinutes < MinETA {
|
||||||
etaMinutes = MinETA
|
etaMinutes = MinETA
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes)
|
||||||
|
|
||||||
return etaMinutes, distanceKm, nil
|
return etaMinutes, distanceKm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,36 +12,29 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
if apiKey == "" {
|
|
||||||
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
buildReq := func(key string) (*http.Request, error) {
|
||||||
u := &url.URL{
|
u := &url.URL{
|
||||||
Scheme: "https",
|
Scheme: "https",
|
||||||
Host: "api.tomtom.com",
|
Host: "api.tomtom.com",
|
||||||
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude),
|
||||||
}
|
}
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("key", apiKey)
|
q.Set("key", key)
|
||||||
q.Set("traffic", "true")
|
q.Set("traffic", "true")
|
||||||
q.Set("travelMode", "car")
|
q.Set("travelMode", "car")
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
return http.NewRequest(http.MethodGet, u.String(), nil)
|
||||||
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 := tomTomKeys.Do(client, buildReq)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -65,12 +58,9 @@ func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64
|
|||||||
}
|
}
|
||||||
|
|
||||||
summary := routeResponse.Routes[0].Summary
|
summary := routeResponse.Routes[0].Summary
|
||||||
|
|
||||||
// Calculer ETA en minutes (arrondi supérieur)
|
|
||||||
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
||||||
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
||||||
|
|
||||||
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
||||||
|
|
||||||
return etaMinutes, distanceKm, nil
|
return etaMinutes, distanceKm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ services:
|
|||||||
- REDIS_PORT=${REDIS_PORT:-6379}
|
- REDIS_PORT=${REDIS_PORT:-6379}
|
||||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||||
- TOMTOM_API_KEY=${TOMTOM_API_KEY}
|
- 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}
|
- API_PORT=${API_PORT:-8080}
|
||||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL}
|
||||||
- TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET}
|
- TELEGRAM_WEBHOOK_SECRET=${TELEGRAM_WEBHOOK_SECRET}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ interface FormState {
|
|||||||
stock: string;
|
stock: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
prices: PriceRow[];
|
prices: PriceRow[];
|
||||||
|
comingSoon: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm = (firstCategory = ""): FormState => ({
|
const emptyForm = (firstCategory = ""): FormState => ({
|
||||||
@@ -87,6 +88,7 @@ const emptyForm = (firstCategory = ""): FormState => ({
|
|||||||
stock: "",
|
stock: "",
|
||||||
unit: "u",
|
unit: "u",
|
||||||
prices: [{ quantity: "1", price: "", active: true }],
|
prices: [{ quantity: "1", price: "", active: true }],
|
||||||
|
comingSoon: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================================================
|
// ==================================================
|
||||||
@@ -163,6 +165,7 @@ export default function ProductsScreen() {
|
|||||||
description: product.description || "",
|
description: product.description || "",
|
||||||
stock: product.stock.toString(),
|
stock: product.stock.toString(),
|
||||||
unit: product.unit || "u",
|
unit: product.unit || "u",
|
||||||
|
comingSoon: product.coming_soon ?? false,
|
||||||
prices:
|
prices:
|
||||||
product.prices && product.prices.length > 0
|
product.prices && product.prices.length > 0
|
||||||
? product.prices.map((p) => ({
|
? product.prices.map((p) => ({
|
||||||
@@ -339,6 +342,7 @@ export default function ProductsScreen() {
|
|||||||
stock: parseFloat(form.stock),
|
stock: parseFloat(form.stock),
|
||||||
unit: form.unit,
|
unit: form.unit,
|
||||||
prices,
|
prices,
|
||||||
|
coming_soon: form.comingSoon,
|
||||||
});
|
});
|
||||||
productId = editingProduct.id;
|
productId = editingProduct.id;
|
||||||
} else {
|
} else {
|
||||||
@@ -349,6 +353,7 @@ export default function ProductsScreen() {
|
|||||||
fd.append("description", form.description.trim());
|
fd.append("description", form.description.trim());
|
||||||
fd.append("stock", form.stock);
|
fd.append("stock", form.stock);
|
||||||
fd.append("unit", form.unit);
|
fd.append("unit", form.unit);
|
||||||
|
fd.append("coming_soon", form.comingSoon ? "true" : "false");
|
||||||
|
|
||||||
prices.forEach((p, i) => {
|
prices.forEach((p, i) => {
|
||||||
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
fd.append(`prices[${i}][quantity]`, String(p.quantity));
|
||||||
@@ -627,6 +632,22 @@ export default function ProductsScreen() {
|
|||||||
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
|
||||||
catBtnTextActive: { color: colors.accent, fontWeight: "600" },
|
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
|
// Prices
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -999,6 +1020,24 @@ export default function ProductsScreen() {
|
|||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* À venir */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.comingSoonBtn,
|
||||||
|
form.comingSoon && styles.comingSoonBtnActive,
|
||||||
|
]}
|
||||||
|
onPress={() =>
|
||||||
|
setForm((f) => ({ ...f, comingSoon: !f.comingSoon }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.comingSoonBtnText,
|
||||||
|
form.comingSoon && styles.comingSoonBtnTextActive,
|
||||||
|
]}>
|
||||||
|
{form.comingSoon ? "🔜 À venir (activé)" : "🔜 Marquer comme «À venir»"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Unité de mesure */}
|
{/* Unité de mesure */}
|
||||||
<Text style={styles.label}>Unité de mesure *</Text>
|
<Text style={styles.label}>Unité de mesure *</Text>
|
||||||
<View style={styles.catRow}>
|
<View style={styles.catRow}>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[ZoneTransfer]
|
|
||||||
ZoneId=3
|
|
||||||
HostUrl=about:internet
|
|
||||||
@@ -817,6 +817,7 @@ export interface Product {
|
|||||||
stock: number;
|
stock: number;
|
||||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||||
|
coming_soon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
|
|||||||
@@ -62,6 +62,29 @@
|
|||||||
0 0 10px rgba(255, 0, 0, 0.5);
|
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 {
|
.product-card.out-of-stock {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface ProductCardProps {
|
|||||||
hasVideo?: boolean;
|
hasVideo?: boolean;
|
||||||
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
||||||
categoryColor?: string;
|
categoryColor?: string;
|
||||||
|
coming_soon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProductCard({
|
function ProductCard({
|
||||||
@@ -40,6 +41,7 @@ function ProductCard({
|
|||||||
hasVideo = false,
|
hasVideo = false,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
categoryColor,
|
categoryColor,
|
||||||
|
coming_soon,
|
||||||
}: ProductCardProps) {
|
}: ProductCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addToCart } = useCart();
|
const { addToCart } = useCart();
|
||||||
@@ -52,6 +54,7 @@ function ProductCard({
|
|||||||
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
|
const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo
|
||||||
|
|
||||||
const isOutOfStock = stock === 0;
|
const isOutOfStock = stock === 0;
|
||||||
|
const isComingSoon = coming_soon === true;
|
||||||
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
||||||
|
|
||||||
const handleDetailsClick = (e: React.MouseEvent) => {
|
const handleDetailsClick = (e: React.MouseEvent) => {
|
||||||
@@ -156,6 +159,9 @@ function ProductCard({
|
|||||||
{isOutOfStock && (
|
{isOutOfStock && (
|
||||||
<div className="sold-out-overlay">SOLD OUT</div>
|
<div className="sold-out-overlay">SOLD OUT</div>
|
||||||
)}
|
)}
|
||||||
|
{isComingSoon && !isOutOfStock && (
|
||||||
|
<div className="coming-soon-overlay">À VENIR</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="product-info">
|
<div className="product-info">
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ function UserAccueil() {
|
|||||||
product.category?.toLowerCase(),
|
product.category?.toLowerCase(),
|
||||||
)?.color
|
)?.color
|
||||||
}
|
}
|
||||||
|
coming_soon={product.coming_soon}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 */
|
/* Info Section */
|
||||||
.product-info-section {
|
.product-info-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ function ProductDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOutOfStock = product.stock === 0;
|
const isOutOfStock = product.stock === 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||||
|
|
||||||
// Convertir la couleur hex en valeurs RGB pour les CSS rgba()
|
// Convertir la couleur hex en valeurs RGB pour les CSS rgba()
|
||||||
@@ -248,6 +249,7 @@ function ProductDetail() {
|
|||||||
className="product-detail-image"
|
className="product-detail-image"
|
||||||
/>
|
/>
|
||||||
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
|
{isOutOfStock && <div className="sold-out-badge">SOLD OUT</div>}
|
||||||
|
{isComingSoon && !isOutOfStock && <div className="coming-soon-badge">À VENIR</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="product-info-section">
|
<div className="product-info-section">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface ProductCardProps {
|
|||||||
category: string;
|
category: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
coming_soon?: boolean;
|
||||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||||
media?: Array<{ url: string; type: string }>;
|
media?: Array<{ url: string; type: string }>;
|
||||||
};
|
};
|
||||||
@@ -48,6 +49,7 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
const { addToCart } = useCart();
|
const { addToCart } = useCart();
|
||||||
const catColor = categoryColor ?? colors.accent;
|
const catColor = categoryColor ?? colors.accent;
|
||||||
const isSoldOut = product.stock <= 0;
|
const isSoldOut = product.stock <= 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
const activePrices = product.prices?.filter((p) => p.active_price !== false) ?? [];
|
const activePrices = product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||||
const firstPrice = activePrices[0]?.price ?? null;
|
const firstPrice = activePrices[0]?.price ?? null;
|
||||||
|
|
||||||
@@ -157,6 +159,11 @@ export default function ProductCard({ product, onPress, categoryColor }: Product
|
|||||||
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{isComingSoon && (
|
||||||
|
<View style={styles.comingSoonOverlay}>
|
||||||
|
<Text style={styles.comingSoonText}>À VENIR</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -450,6 +457,31 @@ const styles = StyleSheet.create({
|
|||||||
textShadowOffset: { width: 2, height: 2 },
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
textShadowRadius: 6,
|
textShadowRadius: 6,
|
||||||
},
|
},
|
||||||
|
comingSoonOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: -25 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "rgba(0,0,0,0.75)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: "rgba(245,158,11,0.95)",
|
||||||
|
paddingHorizontal: 22,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: "rgba(245,158,11,0.95)",
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 6,
|
||||||
|
},
|
||||||
info: { padding: spacing.m, borderTopWidth: 1 },
|
info: { padding: spacing.m, borderTopWidth: 1 },
|
||||||
name: {
|
name: {
|
||||||
fontSize: fontSize.lg,
|
fontSize: fontSize.lg,
|
||||||
|
|||||||
@@ -209,6 +209,32 @@ export default function ProductDetailScreen() {
|
|||||||
textShadowOffset: { width: 2, height: 2 },
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
textShadowRadius: 12,
|
textShadowRadius: 12,
|
||||||
},
|
},
|
||||||
|
comingSoonBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: [
|
||||||
|
{ translateX: -80 },
|
||||||
|
{ translateY: -30 },
|
||||||
|
{ rotate: "-15deg" },
|
||||||
|
],
|
||||||
|
backgroundColor: "rgba(245,158,11,0.95)",
|
||||||
|
borderWidth: 4,
|
||||||
|
borderColor: colors.white,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
paddingVertical: 16,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
comingSoonText: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "900",
|
||||||
|
letterSpacing: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.9)",
|
||||||
|
textShadowOffset: { width: 2, height: 2 },
|
||||||
|
textShadowRadius: 12,
|
||||||
|
},
|
||||||
videoBtn: {
|
videoBtn: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 16,
|
top: 16,
|
||||||
@@ -470,6 +496,7 @@ export default function ProductDetailScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOutOfStock = product.stock === 0;
|
const isOutOfStock = product.stock === 0;
|
||||||
|
const isComingSoon = product.coming_soon === true;
|
||||||
const hasValidPrices = product.prices && product.prices.length > 0;
|
const hasValidPrices = product.prices && product.prices.length > 0;
|
||||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||||
@@ -517,6 +544,11 @@ export default function ProductDetailScreen() {
|
|||||||
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
<Text style={styles.soldOutText}>SOLD OUT</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{isComingSoon && !isOutOfStock && (
|
||||||
|
<View style={styles.comingSoonBadge}>
|
||||||
|
<Text style={styles.comingSoonText}>À VENIR</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{videoUri && !isOutOfStock && (
|
{videoUri && !isOutOfStock && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
Reference in New Issue
Block a user