feat: overhaul shipping module, add seeder, and improve order UI
This commit is contained in:
parent
8a5ec57e9c
commit
baa60c0d9b
20 changed files with 1222 additions and 270 deletions
276
backend/cmd/seeder/main.go
Normal file
276
backend/cmd/seeder/main.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/saveinmed/backend-go/internal/config"
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := sqlx.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to DB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping DB: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
log.Println("🧹 Cleaning database...")
|
||||
cleanDB(ctx, db)
|
||||
|
||||
log.Println("🌱 Seeding data...")
|
||||
seedData(ctx, db, cfg)
|
||||
|
||||
log.Println("✅ Seeding complete!")
|
||||
}
|
||||
|
||||
func cleanDB(ctx context.Context, db *sqlx.DB) {
|
||||
tables := []string{
|
||||
"reviews", "shipments", "payment_preferences", "orders", "order_items",
|
||||
"cart_items", "products", "companies", "users", "shipping_settings",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
_, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||
if err != nil {
|
||||
// Ignore error if table doesn't exist or is empty
|
||||
log.Printf("Warning cleaning %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
|
||||
// 1. Seed Admin
|
||||
adminCompanyID := uuid.Must(uuid.NewV7())
|
||||
createCompany(ctx, db, &domain.Company{
|
||||
ID: adminCompanyID,
|
||||
CNPJ: "00000000000000",
|
||||
CorporateName: "SaveInMed Admin",
|
||||
Category: "admin",
|
||||
LicenseNumber: "ADMIN",
|
||||
IsVerified: true,
|
||||
})
|
||||
createUser(ctx, db, &domain.User{
|
||||
CompanyID: adminCompanyID,
|
||||
Role: "Admin",
|
||||
Name: cfg.AdminName,
|
||||
Username: cfg.AdminUsername,
|
||||
Email: cfg.AdminEmail,
|
||||
}, cfg.AdminPassword, cfg.PasswordPepper)
|
||||
|
||||
// 2. Distributors (Sellers)
|
||||
distributor1ID := uuid.Must(uuid.NewV7())
|
||||
createCompany(ctx, db, &domain.Company{
|
||||
ID: distributor1ID,
|
||||
CNPJ: "11111111000111",
|
||||
CorporateName: "Distribuidora Nacional",
|
||||
Category: "distribuidora",
|
||||
LicenseNumber: "DIST-001",
|
||||
IsVerified: true,
|
||||
Latitude: -23.55052,
|
||||
Longitude: -46.633308,
|
||||
})
|
||||
createUser(ctx, db, &domain.User{
|
||||
CompanyID: distributor1ID,
|
||||
Role: "Owner",
|
||||
Name: "Dono da Distribuidora",
|
||||
Username: "distribuidora",
|
||||
Email: "distribuidora@saveinmed.com",
|
||||
}, "123456", cfg.PasswordPepper)
|
||||
createShippingSettings(ctx, db, distributor1ID)
|
||||
|
||||
// 3. Pharmacies (Buyers)
|
||||
pharmacy1ID := uuid.Must(uuid.NewV7())
|
||||
createCompany(ctx, db, &domain.Company{
|
||||
ID: pharmacy1ID,
|
||||
CNPJ: "22222222000122",
|
||||
CorporateName: "Farmácia Central",
|
||||
Category: "farmacia",
|
||||
LicenseNumber: "FARM-001",
|
||||
IsVerified: true,
|
||||
Latitude: -23.56052,
|
||||
Longitude: -46.643308,
|
||||
})
|
||||
createUser(ctx, db, &domain.User{
|
||||
CompanyID: pharmacy1ID,
|
||||
Role: "Owner",
|
||||
Name: "Dono da Farmácia",
|
||||
Username: "farmacia",
|
||||
Email: "farmacia@saveinmed.com",
|
||||
}, "123456", cfg.PasswordPepper)
|
||||
|
||||
// 4. Products
|
||||
products := []struct {
|
||||
Name string
|
||||
Price int64
|
||||
Stock int64
|
||||
}{
|
||||
{"Dipirona 500mg", 500, 1000},
|
||||
{"Paracetamol 750mg", 750, 1000},
|
||||
{"Ibuprofeno 600mg", 1200, 500},
|
||||
{"Amoxicilina 500mg", 2500, 300},
|
||||
{"Omeprazol 20mg", 1500, 800},
|
||||
}
|
||||
|
||||
var productIDs []uuid.UUID
|
||||
|
||||
for _, p := range products {
|
||||
id := uuid.Must(uuid.NewV7())
|
||||
expiry := time.Now().AddDate(1, 0, 0)
|
||||
createProduct(ctx, db, &domain.Product{
|
||||
ID: id,
|
||||
SellerID: distributor1ID,
|
||||
Name: p.Name,
|
||||
Description: "Medicamento genérico de alta qualidade",
|
||||
Batch: "BATCH-" + id.String()[:8],
|
||||
ExpiresAt: expiry,
|
||||
PriceCents: p.Price,
|
||||
Stock: p.Stock,
|
||||
})
|
||||
productIDs = append(productIDs, id)
|
||||
}
|
||||
|
||||
// 5. Orders
|
||||
// Create an order from Pharmacy to Distributor
|
||||
orderID := uuid.Must(uuid.NewV7())
|
||||
totalCents := int64(0)
|
||||
|
||||
// Items
|
||||
qty := int64(10)
|
||||
price := products[0].Price
|
||||
itemTotal := price * qty
|
||||
totalCents += itemTotal
|
||||
|
||||
createOrder(ctx, db, &domain.Order{
|
||||
ID: orderID,
|
||||
BuyerID: pharmacy1ID,
|
||||
SellerID: distributor1ID,
|
||||
Status: "Faturado", // Ready for "Confirmar Entrega" test
|
||||
TotalCents: totalCents,
|
||||
CreatedAt: time.Now().AddDate(0, 0, -2),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
|
||||
createOrderItem(ctx, db, &domain.OrderItem{
|
||||
ID: uuid.Must(uuid.NewV7()),
|
||||
OrderID: orderID,
|
||||
ProductID: productIDs[0],
|
||||
Quantity: qty,
|
||||
UnitCents: price,
|
||||
Batch: "BATCH-" + productIDs[0].String()[:8],
|
||||
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func createCompany(ctx context.Context, db *sqlx.DB, c *domain.Company) {
|
||||
now := time.Now().UTC()
|
||||
c.CreatedAt = now
|
||||
c.UpdatedAt = now
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, created_at, updated_at)
|
||||
VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :created_at, :updated_at)
|
||||
`, c)
|
||||
if err != nil {
|
||||
log.Printf("Error creating company %s: %v", c.CorporateName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, db *sqlx.DB, u *domain.User, password, pepper string) {
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte(password+pepper), bcrypt.DefaultCost)
|
||||
u.ID = uuid.Must(uuid.NewV7())
|
||||
u.PasswordHash = string(hashed)
|
||||
u.CreatedAt = time.Now().UTC()
|
||||
u.UpdatedAt = time.Now().UTC()
|
||||
|
||||
// Ensure email/username uniqueness is handled by DB constraint, usually we just insert
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO users (id, company_id, role, name, username, email, password_hash, email_verified, created_at, updated_at)
|
||||
VALUES (:id, :company_id, :role, :name, :username, :email, :password_hash, :email_verified, :created_at, :updated_at)
|
||||
`, u)
|
||||
if err != nil {
|
||||
log.Printf("Error creating user %s: %v", u.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createProduct(ctx context.Context, db *sqlx.DB, p *domain.Product) {
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at)
|
||||
VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)
|
||||
`, p)
|
||||
if err != nil {
|
||||
log.Printf("Error creating product %s: %v", p.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createShippingSettings(ctx context.Context, db *sqlx.DB, vendorID uuid.UUID) {
|
||||
now := time.Now().UTC()
|
||||
settings := &domain.ShippingSettings{
|
||||
VendorID: vendorID,
|
||||
Active: true,
|
||||
MaxRadiusKm: 50,
|
||||
PricePerKmCents: 150, // R$ 1.50
|
||||
MinFeeCents: 1000, // R$ 10.00
|
||||
FreeShippingThresholdCents: nil,
|
||||
PickupActive: true,
|
||||
PickupAddress: "Rua da Distribuidora, 1000",
|
||||
PickupHours: "Seg-Sex 08-18h",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Latitude: -23.55052,
|
||||
Longitude: -46.633308,
|
||||
}
|
||||
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO shipping_settings (
|
||||
vendor_id, active, max_radius_km, price_per_km_cents, min_fee_cents,
|
||||
free_shipping_threshold_cents, pickup_active, pickup_address, pickup_hours,
|
||||
latitude, longitude, created_at, updated_at
|
||||
) VALUES (
|
||||
:vendor_id, :active, :max_radius_km, :price_per_km_cents, :min_fee_cents,
|
||||
:free_shipping_threshold_cents, :pickup_active, :pickup_address, :pickup_hours,
|
||||
:latitude, :longitude, :created_at, :updated_at
|
||||
)
|
||||
`, settings)
|
||||
if err != nil {
|
||||
log.Printf("Error creating shipping settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrder(ctx context.Context, db *sqlx.DB, o *domain.Order) {
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, created_at, updated_at)
|
||||
VALUES (:id, :buyer_id, :seller_id, :status, :total_cents, :created_at, :updated_at)
|
||||
`, o)
|
||||
if err != nil {
|
||||
log.Printf("Error creating order: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createOrderItem(ctx context.Context, db *sqlx.DB, item *domain.OrderItem) {
|
||||
_, err := db.NamedExecContext(ctx, `
|
||||
INSERT INTO order_items (id, order_id, product_id, quantity, unit_cents, batch, expires_at)
|
||||
VALUES (:id, :order_id, :product_id, :quantity, :unit_cents, :batch, :expires_at)
|
||||
`, item)
|
||||
if err != nil {
|
||||
log.Printf("Error creating order item: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package domain
|
|||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
const earthRadiusKm = 6371.0
|
||||
|
|
@ -43,17 +45,18 @@ type ProductWithDistance struct {
|
|||
|
||||
// ProductSearchFilter captures search constraints.
|
||||
type ProductSearchFilter struct {
|
||||
Search string
|
||||
Category string
|
||||
MinPriceCents *int64
|
||||
MaxPriceCents *int64
|
||||
ExpiresAfter *time.Time
|
||||
ExpiresBefore *time.Time
|
||||
MaxDistanceKm *float64
|
||||
BuyerLat float64
|
||||
BuyerLng float64
|
||||
Limit int
|
||||
Offset int
|
||||
Search string
|
||||
Category string
|
||||
MinPriceCents *int64
|
||||
MaxPriceCents *int64
|
||||
ExpiresAfter *time.Time
|
||||
ExpiresBefore *time.Time
|
||||
MaxDistanceKm *float64
|
||||
BuyerLat float64
|
||||
BuyerLng float64
|
||||
ExcludeSellerID *uuid.UUID // Exclude products from buyer's own company
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ProductSearchPage wraps search results with pagination.
|
||||
|
|
|
|||
|
|
@ -249,6 +249,23 @@ type ShippingAddress struct {
|
|||
Country string `json:"country" db:"shipping_country"`
|
||||
}
|
||||
|
||||
// ShippingSettings stores configuration for calculating delivery fees.
|
||||
type ShippingSettings struct {
|
||||
VendorID uuid.UUID `db:"vendor_id" json:"vendor_id"`
|
||||
Active bool `db:"active" json:"active"`
|
||||
MaxRadiusKm float64 `db:"max_radius_km" json:"max_radius_km"`
|
||||
PricePerKmCents int64 `db:"price_per_km_cents" json:"price_per_km_cents"`
|
||||
MinFeeCents int64 `db:"min_fee_cents" json:"min_fee_cents"`
|
||||
FreeShippingThresholdCents *int64 `db:"free_shipping_threshold_cents" json:"free_shipping_threshold_cents"`
|
||||
PickupActive bool `db:"pickup_active" json:"pickup_active"`
|
||||
PickupAddress string `db:"pickup_address" json:"pickup_address"`
|
||||
PickupHours string `db:"pickup_hours" json:"pickup_hours"`
|
||||
Latitude float64 `db:"latitude" json:"latitude"`
|
||||
Longitude float64 `db:"longitude" json:"longitude"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Shipment stores freight label data and tracking linkage.
|
||||
type Shipment struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
|
|
|
|||
|
|
@ -171,20 +171,17 @@ type updateStatusRequest struct {
|
|||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type shippingMethodRequest struct {
|
||||
Type string `json:"type"`
|
||||
type shippingSettingsRequest struct {
|
||||
Active bool `json:"active"`
|
||||
PreparationMinutes int `json:"preparation_minutes"`
|
||||
MaxRadiusKm float64 `json:"max_radius_km"`
|
||||
MinFeeCents int64 `json:"min_fee_cents"`
|
||||
PricePerKmCents int64 `json:"price_per_km_cents"`
|
||||
MinFeeCents int64 `json:"min_fee_cents"`
|
||||
FreeShippingThresholdCents *int64 `json:"free_shipping_threshold_cents,omitempty"`
|
||||
PickupActive bool `json:"pickup_active"`
|
||||
PickupAddress string `json:"pickup_address,omitempty"`
|
||||
PickupHours string `json:"pickup_hours,omitempty"`
|
||||
}
|
||||
|
||||
type shippingSettingsRequest struct {
|
||||
Methods []shippingMethodRequest `json:"methods"`
|
||||
Latitude float64 `json:"latitude"` // Store location for radius calc
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type shippingCalculateRequest struct {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
"github.com/saveinmed/backend-go/internal/http/middleware"
|
||||
)
|
||||
|
||||
// CreateProduct godoc
|
||||
|
|
@ -131,6 +132,11 @@ func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Exclude products from the buyer's own company
|
||||
if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil {
|
||||
filter.ExcludeSellerID = claims.CompanyID
|
||||
}
|
||||
|
||||
result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
// @Tags Shipping
|
||||
// @Produce json
|
||||
// @Param vendor_id path string true "Vendor ID"
|
||||
// @Success 200 {array} domain.ShippingMethod
|
||||
// @Success 200 {object} domain.ShippingSettings
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
|
|
@ -40,12 +40,17 @@ func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
methods, err := h.svc.GetShippingMethods(r.Context(), vendorID)
|
||||
settings, err := h.svc.GetShippingSettings(r.Context(), vendorID)
|
||||
if err != nil {
|
||||
// Log error if needed, but for 404/not found we might return empty object
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, methods)
|
||||
if settings == nil {
|
||||
// Return defaults
|
||||
settings = &domain.ShippingSettings{VendorID: vendorID, Active: false}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// UpsertShippingSettings godoc
|
||||
|
|
@ -56,7 +61,7 @@ func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
|
|||
// @Produce json
|
||||
// @Param vendor_id path string true "Vendor ID"
|
||||
// @Param payload body shippingSettingsRequest true "Shipping settings"
|
||||
// @Success 200 {array} domain.ShippingMethod
|
||||
// @Success 200 {object} domain.ShippingSettings
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
|
|
@ -85,61 +90,43 @@ func (h *Handler) UpsertShippingSettings(w http.ResponseWriter, r *http.Request)
|
|||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if len(req.Methods) == 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("methods are required"))
|
||||
return
|
||||
}
|
||||
|
||||
methods := make([]domain.ShippingMethod, 0, len(req.Methods))
|
||||
for _, method := range req.Methods {
|
||||
methodType, err := parseShippingMethodType(method.Type)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
if req.Active {
|
||||
if req.MaxRadiusKm < 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be >= 0"))
|
||||
return
|
||||
}
|
||||
if method.PreparationMinutes < 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("preparation_minutes must be >= 0"))
|
||||
if req.PricePerKmCents < 0 || req.MinFeeCents < 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("pricing fields must be >= 0"))
|
||||
return
|
||||
}
|
||||
if methodType != domain.ShippingMethodPickup {
|
||||
if method.Active && method.MaxRadiusKm <= 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be > 0 for active delivery methods"))
|
||||
return
|
||||
}
|
||||
if method.MinFeeCents < 0 || method.PricePerKmCents < 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("delivery pricing must be >= 0"))
|
||||
return
|
||||
}
|
||||
if method.Active && method.FreeShippingThresholdCents != nil && *method.FreeShippingThresholdCents <= 0 {
|
||||
writeError(w, http.StatusBadRequest, errors.New("free_shipping_threshold_cents must be > 0"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PickupActive {
|
||||
if strings.TrimSpace(req.PickupAddress) == "" || strings.TrimSpace(req.PickupHours) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
|
||||
return
|
||||
}
|
||||
if methodType == domain.ShippingMethodPickup && method.Active {
|
||||
if strings.TrimSpace(method.PickupAddress) == "" || strings.TrimSpace(method.PickupHours) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
|
||||
return
|
||||
}
|
||||
}
|
||||
methods = append(methods, domain.ShippingMethod{
|
||||
Type: methodType,
|
||||
Active: method.Active,
|
||||
PreparationMinutes: method.PreparationMinutes,
|
||||
MaxRadiusKm: method.MaxRadiusKm,
|
||||
MinFeeCents: method.MinFeeCents,
|
||||
PricePerKmCents: method.PricePerKmCents,
|
||||
FreeShippingThresholdCents: method.FreeShippingThresholdCents,
|
||||
PickupAddress: strings.TrimSpace(method.PickupAddress),
|
||||
PickupHours: strings.TrimSpace(method.PickupHours),
|
||||
})
|
||||
}
|
||||
|
||||
updated, err := h.svc.UpsertShippingMethods(r.Context(), vendorID, methods)
|
||||
if err != nil {
|
||||
settings := &domain.ShippingSettings{
|
||||
VendorID: vendorID,
|
||||
Active: req.Active,
|
||||
MaxRadiusKm: req.MaxRadiusKm,
|
||||
PricePerKmCents: req.PricePerKmCents,
|
||||
MinFeeCents: req.MinFeeCents,
|
||||
FreeShippingThresholdCents: req.FreeShippingThresholdCents,
|
||||
PickupActive: req.PickupActive,
|
||||
PickupAddress: req.PickupAddress,
|
||||
PickupHours: req.PickupHours,
|
||||
Latitude: req.Latitude,
|
||||
Longitude: req.Longitude,
|
||||
}
|
||||
|
||||
if err := h.svc.UpsertShippingSettings(r.Context(), settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
// CalculateShipping godoc
|
||||
|
|
|
|||
|
|
@ -25,51 +25,74 @@ type Claims struct {
|
|||
func RequireAuth(secret []byte, allowedRoles ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimSpace(authHeader[7:])
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
role, _ := claims["role"].(string)
|
||||
if len(allowedRoles) > 0 && !isRoleAllowed(role, allowedRoles) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sub, _ := claims["sub"].(string)
|
||||
userID, err := uuid.FromString(sub)
|
||||
claims, err := parseToken(r, secret)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, ok := claims["company_id"].(string); ok && cid != "" {
|
||||
if parsed, err := uuid.FromString(cid); err == nil {
|
||||
companyID = &parsed
|
||||
}
|
||||
if len(allowedRoles) > 0 && !isRoleAllowed(claims.Role, allowedRoles) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), claimsKey, Claims{UserID: userID, Role: role, CompanyID: companyID})
|
||||
ctx := context.WithValue(r.Context(), claimsKey, *claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth attempts to validate a JWT token if present, but proceeds without context if missing or invalid.
|
||||
func OptionalAuth(secret []byte) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := parseToken(r, secret)
|
||||
if err == nil && claims != nil {
|
||||
ctx := context.WithValue(r.Context(), claimsKey, *claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
} else {
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseToken(r *http.Request, secret []byte) (*Claims, error) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
return nil, errors.New("missing bearer header")
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimSpace(authHeader[7:])
|
||||
jwtClaims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, jwtClaims, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sub, _ := jwtClaims["sub"].(string)
|
||||
userID, err := uuid.FromString(sub)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid sub")
|
||||
}
|
||||
|
||||
role, _ := jwtClaims["role"].(string)
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, ok := jwtClaims["company_id"].(string); ok && cid != "" {
|
||||
if parsed, err := uuid.FromString(cid); err == nil {
|
||||
companyID = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
return &Claims{UserID: userID, Role: role, CompanyID: companyID}, nil
|
||||
}
|
||||
|
||||
// GetClaims extracts JWT claims from the request context.
|
||||
func GetClaims(ctx context.Context) (Claims, bool) {
|
||||
claims, ok := ctx.Value(claimsKey).(Claims)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
CREATE TABLE IF NOT EXISTS shipping_settings (
|
||||
vendor_id UUID PRIMARY KEY,
|
||||
active BOOLEAN DEFAULT true,
|
||||
|
||||
-- Configuração de Entrega
|
||||
max_radius_km DOUBLE PRECISION DEFAULT 0,
|
||||
price_per_km_cents BIGINT DEFAULT 0,
|
||||
min_fee_cents BIGINT DEFAULT 0,
|
||||
free_shipping_threshold_cents BIGINT, -- Nova opção de frete grátis
|
||||
|
||||
-- Configuração de Retirada
|
||||
pickup_active BOOLEAN DEFAULT false,
|
||||
pickup_address TEXT, -- JSON ou texto formatado
|
||||
pickup_hours TEXT,
|
||||
|
||||
-- Geolocalização da loja (para cálculo do raio)
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index para busca rápida por vendedor
|
||||
CREATE INDEX IF NOT EXISTS idx_shipping_settings_vendor_id ON shipping_settings(vendor_id);
|
||||
|
|
@ -308,6 +308,10 @@ func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSe
|
|||
clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1))
|
||||
args = append(args, *filter.ExpiresBefore)
|
||||
}
|
||||
if filter.ExcludeSellerID != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("p.seller_id != $%d", len(args)+1))
|
||||
args = append(args, *filter.ExcludeSellerID)
|
||||
}
|
||||
|
||||
// Always filter only available products
|
||||
clauses = append(clauses, "p.stock > 0")
|
||||
|
|
@ -471,6 +475,22 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`
|
|||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Reduce stock
|
||||
res, err := tx.ExecContext(ctx, `UPDATE products SET stock = stock - $1, updated_at = $2 WHERE id = $3 AND stock >= $1`, item.Quantity, now, item.ProductID)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("insufficient stock for product %s", item.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
|
|
@ -1141,3 +1161,49 @@ func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFi
|
|||
}
|
||||
return shipments, total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) {
|
||||
var settings domain.ShippingSettings
|
||||
err := r.db.GetContext(ctx, &settings, `SELECT * FROM shipping_settings WHERE vendor_id = $1`, vendorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
|
||||
now := time.Now().UTC()
|
||||
settings.UpdatedAt = now
|
||||
|
||||
// Create if new
|
||||
if settings.CreatedAt.IsZero() {
|
||||
settings.CreatedAt = now
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO shipping_settings (
|
||||
vendor_id, active, max_radius_km, price_per_km_cents, min_fee_cents,
|
||||
free_shipping_threshold_cents, pickup_active, pickup_address, pickup_hours,
|
||||
latitude, longitude, created_at, updated_at
|
||||
) VALUES (
|
||||
:vendor_id, :active, :max_radius_km, :price_per_km_cents, :min_fee_cents,
|
||||
:free_shipping_threshold_cents, :pickup_active, :pickup_address, :pickup_hours,
|
||||
:latitude, :longitude, :created_at, :updated_at
|
||||
)
|
||||
ON CONFLICT (vendor_id) DO UPDATE SET
|
||||
active = :active,
|
||||
max_radius_km = :max_radius_km,
|
||||
price_per_km_cents = :price_per_km_cents,
|
||||
min_fee_cents = :min_fee_cents,
|
||||
free_shipping_threshold_cents = :free_shipping_threshold_cents,
|
||||
pickup_active = :pickup_active,
|
||||
pickup_address = :pickup_address,
|
||||
pickup_hours = :pickup_hours,
|
||||
latitude = :latitude,
|
||||
longitude = :longitude,
|
||||
updated_at = :updated_at
|
||||
`
|
||||
|
||||
_, err := r.db.NamedExecContext(ctx, query, settings)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func New(cfg config.Config) (*Server, error) {
|
|||
|
||||
mux.Handle("POST /api/v1/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("GET /api/v1/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("GET /api/v1/products/search", chain(http.HandlerFunc(h.SearchProducts), middleware.Logger, middleware.Gzip, middleware.OptionalAuth([]byte(cfg.JWTSecret))))
|
||||
mux.Handle("GET /api/v1/products/{id}", chain(http.HandlerFunc(h.GetProduct), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("GET /api/v1/marketplace/records", chain(http.HandlerFunc(h.ListMarketplaceRecords), middleware.Logger, middleware.Gzip))
|
||||
|
||||
|
|
|
|||
|
|
@ -58,9 +58,8 @@ type Repository interface {
|
|||
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
|
||||
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
||||
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
||||
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
|
||||
|
||||
UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error
|
||||
GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error)
|
||||
UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error
|
||||
ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error)
|
||||
ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error)
|
||||
}
|
||||
|
|
@ -120,29 +119,6 @@ func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company
|
|||
return s.repo.GetCompany(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetShippingMethods(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
|
||||
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertShippingMethods(ctx context.Context, vendorID uuid.UUID, methods []domain.ShippingMethod) ([]domain.ShippingMethod, error) {
|
||||
if len(methods) == 0 {
|
||||
return nil, errors.New("shipping methods are required")
|
||||
}
|
||||
for i := range methods {
|
||||
if methods[i].Type == "" {
|
||||
return nil, errors.New("shipping method type is required")
|
||||
}
|
||||
if methods[i].ID == uuid.Nil {
|
||||
methods[i].ID = uuid.Must(uuid.NewV7())
|
||||
}
|
||||
methods[i].VendorID = vendorID
|
||||
}
|
||||
if err := s.repo.UpsertShippingMethods(ctx, methods); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
|
||||
}
|
||||
|
||||
func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UUID, buyerLat, buyerLng float64, cartTotalCents int64) ([]domain.ShippingOption, error) {
|
||||
company, err := s.repo.GetCompany(ctx, vendorID)
|
||||
if err != nil {
|
||||
|
|
@ -152,61 +128,62 @@ func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UU
|
|||
return nil, errors.New("vendor not found")
|
||||
}
|
||||
|
||||
methods, err := s.repo.GetShippingMethodsByVendor(ctx, vendorID)
|
||||
settings, err := s.repo.GetShippingSettings(ctx, vendorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Just return empty options if settings not found
|
||||
return []domain.ShippingOption{}, nil
|
||||
}
|
||||
if settings == nil {
|
||||
return []domain.ShippingOption{}, nil
|
||||
}
|
||||
|
||||
distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng)
|
||||
|
||||
var options []domain.ShippingOption
|
||||
for _, method := range methods {
|
||||
if !method.Active {
|
||||
continue
|
||||
}
|
||||
switch method.Type {
|
||||
case domain.ShippingMethodPickup:
|
||||
description := "Pickup at seller location"
|
||||
if method.PickupAddress != "" {
|
||||
description = "Pickup at " + method.PickupAddress
|
||||
}
|
||||
if method.PickupHours != "" {
|
||||
description += " (" + method.PickupHours + ")"
|
||||
}
|
||||
options = append(options, domain.ShippingOption{
|
||||
Type: domain.ShippingOptionTypePickup,
|
||||
ValueCents: 0,
|
||||
EstimatedMinutes: method.PreparationMinutes,
|
||||
Description: description,
|
||||
DistanceKm: distance,
|
||||
})
|
||||
case domain.ShippingMethodOwnDelivery, domain.ShippingMethodThirdParty:
|
||||
if method.MaxRadiusKm > 0 && distance > method.MaxRadiusKm {
|
||||
continue
|
||||
}
|
||||
variableCost := int64(math.Round(distance * float64(method.PricePerKmCents)))
|
||||
price := method.MinFeeCents
|
||||
|
||||
// 1. Delivery
|
||||
if settings.Active {
|
||||
if settings.MaxRadiusKm > 0 && distance <= settings.MaxRadiusKm {
|
||||
variableCost := int64(math.Round(distance * float64(settings.PricePerKmCents)))
|
||||
price := settings.MinFeeCents
|
||||
if variableCost > price {
|
||||
price = variableCost
|
||||
}
|
||||
if method.FreeShippingThresholdCents != nil && cartTotalCents >= *method.FreeShippingThresholdCents {
|
||||
if settings.FreeShippingThresholdCents != nil && *settings.FreeShippingThresholdCents > 0 && cartTotalCents >= *settings.FreeShippingThresholdCents {
|
||||
price = 0
|
||||
}
|
||||
estimatedMinutes := method.PreparationMinutes + int(math.Round(distance*5))
|
||||
description := "Delivery via own fleet"
|
||||
if method.Type == domain.ShippingMethodThirdParty {
|
||||
description = "Delivery via third-party courier"
|
||||
}
|
||||
|
||||
// Estimate: 30 mins base + 5 mins/km default
|
||||
estMins := 30 + int(math.Round(distance*5))
|
||||
|
||||
options = append(options, domain.ShippingOption{
|
||||
Type: domain.ShippingOptionTypeDelivery,
|
||||
ValueCents: price,
|
||||
EstimatedMinutes: estimatedMinutes,
|
||||
Description: description,
|
||||
EstimatedMinutes: estMins,
|
||||
Description: "Entrega Própria",
|
||||
DistanceKm: distance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Pickup
|
||||
if settings.PickupActive {
|
||||
desc := "Retirada na loja"
|
||||
if settings.PickupAddress != "" {
|
||||
desc = "Retirada em: " + settings.PickupAddress
|
||||
}
|
||||
if settings.PickupHours != "" {
|
||||
desc += " (" + settings.PickupHours + ")"
|
||||
}
|
||||
|
||||
options = append(options, domain.ShippingOption{
|
||||
Type: domain.ShippingOptionTypePickup,
|
||||
ValueCents: 0,
|
||||
EstimatedMinutes: 60, // Default 1 hour readily available
|
||||
Description: desc,
|
||||
DistanceKm: distance,
|
||||
})
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
|
|
@ -843,3 +820,11 @@ func (s *Service) ListShipments(ctx context.Context, filter domain.ShipmentFilte
|
|||
}
|
||||
return &domain.ShipmentPage{Shipments: shipments, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) {
|
||||
return s.repo.GetShippingSettings(ctx, vendorID)
|
||||
}
|
||||
|
||||
func (s *Service) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error {
|
||||
return s.repo.UpsertShippingSettings(ctx, settings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SellerDashboardPage } from './pages/SellerDashboard'
|
|||
import { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||
import { MyProfilePage } from './pages/MyProfile'
|
||||
import { CheckoutPage } from './pages/Checkout'
|
||||
import ProductSearch from './pages/ProductSearch'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { DashboardLayout } from './layouts/DashboardLayout'
|
||||
|
|
@ -20,7 +21,8 @@ import {
|
|||
OrdersPage,
|
||||
ReviewsPage,
|
||||
LogisticsPage,
|
||||
ProfilePage
|
||||
ProfilePage,
|
||||
ShippingSettingsPage
|
||||
} from './pages/admin'
|
||||
|
||||
function App() {
|
||||
|
|
@ -144,6 +146,14 @@ function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CheckoutPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Product Search - Buy from other pharmacies */}
|
||||
<Route
|
||||
path="/search"
|
||||
|
|
@ -153,6 +163,15 @@ function App() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Shipping Settings - for Sellers */}
|
||||
<Route
|
||||
path="/shipping-settings"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||
<ShippingSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ShoppingBasket } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Shell } from '../layouts/Shell'
|
||||
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
||||
import { formatCurrency } from '../utils/format'
|
||||
|
|
@ -119,12 +120,12 @@ export function CartPage() {
|
|||
<button className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold" onClick={clearAll}>
|
||||
Limpar carrinho
|
||||
</button>
|
||||
<a
|
||||
href="/checkout"
|
||||
<Link
|
||||
to="/checkout"
|
||||
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
Seguir para checkout unificado
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,78 +1,241 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { initMercadoPago, Payment } from '@mercadopago/sdk-react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shell } from '../layouts/Shell'
|
||||
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
||||
import { useCartStore, selectGroupedCart, selectCartSummary } from '../stores/cartStore'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const MP_PUBLIC_KEY = import.meta.env.VITE_MP_PUBLIC_KEY || 'TEST-PUBLIC-KEY'
|
||||
|
||||
// Helper function to safely format currency values
|
||||
const formatCurrency = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null || isNaN(value)) {
|
||||
return '0,00'
|
||||
}
|
||||
return value.toFixed(2).replace('.', ',')
|
||||
}
|
||||
import { ordersService, CreateOrderRequest } from '../services/ordersService'
|
||||
import { formatCurrency } from '../utils/format'
|
||||
import { ArrowLeft, CheckCircle2, Truck } from 'lucide-react'
|
||||
|
||||
export function CheckoutPage() {
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const summary = useCartStore(selectCartSummary)
|
||||
const groups = useCartStore(selectGroupedCart)
|
||||
const [status, setStatus] = useState<'pendente' | 'pago' | null>(null)
|
||||
const summary = useCartStore(selectCartSummary)
|
||||
const clearAll = useCartStore((state) => state.clearAll)
|
||||
|
||||
useEffect(() => {
|
||||
initMercadoPago(MP_PUBLIC_KEY, { locale: 'pt-BR' })
|
||||
}, [])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [shipping, setShipping] = useState({
|
||||
recipient_name: user?.name || '',
|
||||
street: '',
|
||||
number: '',
|
||||
complement: '',
|
||||
district: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip_code: '',
|
||||
country: 'Brasil'
|
||||
})
|
||||
|
||||
const preferenceId = useMemo(() => `pref-${Date.now()}`, [])
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setShipping(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handlePlaceOrder = async () => {
|
||||
if (!user) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// Create an order for each vendor group
|
||||
const promises = Object.entries(groups).map(([sellerId, group]) => {
|
||||
const orderData: CreateOrderRequest = {
|
||||
buyer_id: user.id,
|
||||
seller_id: sellerId,
|
||||
items: group.items.map(item => ({
|
||||
product_id: item.id,
|
||||
quantity: item.quantity,
|
||||
unit_cents: item.unitPrice,
|
||||
batch: item.batch,
|
||||
expires_at: item.expiry // Ensure format matches backend expectation? Backend expects ISO. Cart stores string?
|
||||
})),
|
||||
shipping: {
|
||||
recipient_name: shipping.recipient_name,
|
||||
street: shipping.street,
|
||||
number: shipping.number,
|
||||
complement: shipping.complement,
|
||||
district: shipping.district,
|
||||
city: shipping.city,
|
||||
state: shipping.state,
|
||||
zip_code: shipping.zip_code,
|
||||
country: shipping.country
|
||||
}
|
||||
}
|
||||
return ordersService.createOrder(orderData)
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
clearAll()
|
||||
navigate('/orders')
|
||||
} catch (error) {
|
||||
console.error('Failed to create order', error)
|
||||
alert('Erro ao criar pedido. Verifique os dados e tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.totalItems === 0) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<h2 className="text-xl font-semibold">Seu carrinho está vazio</h2>
|
||||
<button onClick={() => navigate('/inventory')} className="mt-4 text-medicalBlue hover:underline">
|
||||
Voltar para o catálogo
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-3 rounded-lg bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-medicalBlue">Checkout e Pagamento</h1>
|
||||
<p className="text-sm text-gray-600">Split automático por distribuidora, status pendente/pago.</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">Comprador</p>
|
||||
<p className="font-semibold text-gray-800">{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(groups).map(([vendorId, group]) => (
|
||||
<div key={vendorId} className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-gray-800">{group.vendorName}</p>
|
||||
<p className="text-sm font-bold text-medicalBlue">R$ {formatCurrency(group.total)}</p>
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<button onClick={() => navigate('/cart')} className="flex items-center text-sm text-gray-500 hover:text-gray-900">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Voltar para o carrinho
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-900">Finalizar Compra</h1>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
{/* Left Column: Form */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-medicalBlue" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Endereço de Entrega</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Destinatário</label>
|
||||
<input
|
||||
type="text"
|
||||
name="recipient_name"
|
||||
value={shipping.recipient_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">CEP</label>
|
||||
<input
|
||||
type="text"
|
||||
name="zip_code"
|
||||
value={shipping.zip_code}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Rua</label>
|
||||
<input
|
||||
type="text"
|
||||
name="street"
|
||||
value={shipping.street}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Número</label>
|
||||
<input
|
||||
type="text"
|
||||
name="number"
|
||||
value={shipping.number}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Complemento</label>
|
||||
<input
|
||||
type="text"
|
||||
name="complement"
|
||||
value={shipping.complement}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Bairro</label>
|
||||
<input
|
||||
type="text"
|
||||
name="district"
|
||||
value={shipping.district}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Cidade</label>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={shipping.city}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Estado</label>
|
||||
<input
|
||||
type="text"
|
||||
name="state"
|
||||
value={shipping.state}
|
||||
onChange={handleInputChange}
|
||||
maxLength={2}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-medicalBlue focus:outline-none focus:ring-1 focus:ring-medicalBlue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method Stub */}
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-800">Pagamento</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">Este é um ambiente de demonstração. O pagamento será processado como "Confirmado" para fins de teste.</p>
|
||||
<div className="mt-4 flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">Método de Teste (Aprovação Automática)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Itens enviados no split de pagamento.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-lg bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-700">Resumo</p>
|
||||
<p className="text-2xl font-bold text-medicalBlue">R$ {formatCurrency(summary.totalValue)}</p>
|
||||
</div>
|
||||
<div className="rounded bg-gray-100 p-3 text-sm">
|
||||
<p className="font-semibold text-gray-700">Status</p>
|
||||
<p className="text-gray-600">{status ? status.toUpperCase() : 'Aguardando pagamento'}</p>
|
||||
</div>
|
||||
<div className="rounded bg-gray-50 p-3">
|
||||
<p className="text-sm font-semibold text-gray-700">Pagamento Mercado Pago</p>
|
||||
<Payment
|
||||
initialization={{ preferenceId, amount: summary.totalValue || 0.01 }}
|
||||
customization={{ paymentMethods: { creditCard: 'all', bankTransfer: 'all' } }}
|
||||
onSubmit={async () => {
|
||||
setStatus('pendente')
|
||||
}}
|
||||
onReady={() => console.log('Payment brick pronto')}
|
||||
onError={(error) => {
|
||||
console.error(error)
|
||||
setStatus(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Right Column: Summary */}
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-800">Resumo do Pedido</h2>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groups).map(([vendorId, group]) => (
|
||||
<div key={vendorId} className="border-b border-gray-100 pb-4 last:border-0 last:pb-0">
|
||||
<p className="mb-2 text-sm font-medium text-gray-600">{group.vendorName}</p>
|
||||
{group.items.map(item => (
|
||||
<div key={item.id} className="flex justify-between text-sm">
|
||||
<span className="text-gray-800">{item.quantity}x {item.name}</span>
|
||||
<span className="text-gray-600">R$ {formatCurrency(item.quantity * item.unitPrice)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
||||
<div className="flex justify-between font-semibold text-gray-900">
|
||||
<span>Total</span>
|
||||
<span className="text-xl text-medicalBlue">R$ {formatCurrency(summary.totalValue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePlaceOrder}
|
||||
disabled={loading}
|
||||
className="mt-6 w-full rounded-lg bg-medicalBlue px-4 py-3 font-semibold text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Processando...' : 'Confirmar Pedido'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ export function OrdersPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab('compras')}
|
||||
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'compras'
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
|
@ -116,8 +116,8 @@ export function OrdersPage() {
|
|||
<button
|
||||
onClick={() => setActiveTab('vendas')}
|
||||
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'vendas'
|
||||
? 'text-green-600 bg-green-50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
? 'text-green-600 bg-green-50'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
|
@ -224,36 +224,69 @@ export function OrdersPage() {
|
|||
|
||||
{/* Actions - only for sales */}
|
||||
{activeTab === 'vendas' && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex gap-2">
|
||||
{order.status === 'Pendente' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Pago')}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
💳 Confirmar Pagamento
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Pago' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Faturado')}
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
📄 Faturar Pedido
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Faturado' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Entregue')}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
✅ Confirmar Entrega
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Entregue' && (
|
||||
<span className="text-green-600 text-sm font-medium flex items-center gap-2">
|
||||
✅ Pedido concluído com sucesso
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
{/* Timeline for Sales */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{['Pendente', 'Pago', 'Faturado', 'Entregue'].map((status, idx) => {
|
||||
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
|
||||
return (
|
||||
<div key={status} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : idx + 1}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
||||
<span>Pendente</span>
|
||||
<span>Pago</span>
|
||||
<span>Faturado</span>
|
||||
<span>Entregue</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
{order.status === 'Pendente' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Pago')}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
💳 Confirmar Pagamento
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Pago' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Faturado')}
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
📄 Faturar Pedido
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Faturado' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Entregue')}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
✅ Confirmar Entrega
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'Entregue' && (
|
||||
<span className="text-green-600 text-sm font-medium flex items-center gap-2">
|
||||
✅ Pedido concluído com sucesso
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -266,15 +299,15 @@ export function OrdersPage() {
|
|||
return (
|
||||
<div key={status} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : idx + 1}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -287,6 +320,18 @@ export function OrdersPage() {
|
|||
<span>Faturado</span>
|
||||
<span>Entregue</span>
|
||||
</div>
|
||||
|
||||
{/* Confirm Receipt Action */}
|
||||
{order.status === 'Faturado' && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'Entregue')}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
✅ Confirmar Recebimento
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
268
marketplace/src/pages/admin/ShippingSettings.tsx
Normal file
268
marketplace/src/pages/admin/ShippingSettings.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Circle, useMapEvents, Marker, Popup } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { adminService, ShippingSettings } from '../../services/adminService'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
|
||||
import L from 'leaflet'
|
||||
|
||||
// Fix Leaflet marker icon issue in React
|
||||
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
let DefaultIcon = L.icon({
|
||||
iconUrl: icon,
|
||||
shadowUrl: iconShadow,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41]
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
function RadiusController({ center, setRadius }: { center: [number, number], setRadius: (r: number) => void }) {
|
||||
const map = useMapEvents({
|
||||
click(e) {
|
||||
const distanceMeters = e.latlng.distanceTo(center);
|
||||
setRadius(parseFloat((distanceMeters / 1000).toFixed(2)));
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
export function ShippingSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const [settings, setSettings] = useState<ShippingSettings>({
|
||||
active: false,
|
||||
max_radius_km: 5,
|
||||
price_per_km_cents: 0,
|
||||
min_fee_cents: 0,
|
||||
free_shipping_threshold_cents: 0,
|
||||
pickup_active: false,
|
||||
pickup_address: '',
|
||||
pickup_hours: '',
|
||||
latitude: -23.55052, // Default SP
|
||||
longitude: -46.633308
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.companyId) {
|
||||
loadSettings()
|
||||
}
|
||||
}, [user?.companyId])
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!user?.companyId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
// Fetch company first to get canonical location if needed
|
||||
const company = await adminService.getCompany(user.companyId)
|
||||
let initialLat = company.latitude || -23.55052
|
||||
let initialLng = company.longitude || -46.633308
|
||||
|
||||
const data = await adminService.getShippingSettings(user.companyId)
|
||||
|
||||
// If settings exist and have valid lat/long, use them. Otherwise use company's.
|
||||
if (data && (data.latitude !== 0 || data.longitude !== 0)) {
|
||||
initialLat = data.latitude
|
||||
initialLng = data.longitude
|
||||
}
|
||||
|
||||
setSettings({
|
||||
...data,
|
||||
// Ensure defaults if null/undefined
|
||||
max_radius_km: data?.max_radius_km ?? 5,
|
||||
price_per_km_cents: data?.price_per_km_cents ?? 0,
|
||||
min_fee_cents: data?.min_fee_cents ?? 0,
|
||||
free_shipping_threshold_cents: data?.free_shipping_threshold_cents ?? 0,
|
||||
latitude: initialLat,
|
||||
longitude: initialLng
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Error loading settings", err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!user?.companyId) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminService.upsertShippingSettings(user.companyId, settings)
|
||||
alert('Configurações salvas com sucesso!')
|
||||
} catch (err) {
|
||||
console.error("Error saving settings", err)
|
||||
alert('Erro ao salvar configurações.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const center: [number, number] = [settings.latitude, settings.longitude]
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Carregando configurações de frete...</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<span className="text-2xl">🚚</span>
|
||||
Configurações de Entrega e Retirada
|
||||
</h1>
|
||||
|
||||
<form onSubmit={handleSave} className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Delivery Settings */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="text-xl">📍</span>
|
||||
Entrega Própria
|
||||
</h2>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" checked={settings.active} onChange={e => setSettings({ ...settings, active: e.target.checked })} />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">{settings.active ? 'Ativado' : 'Desativado'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 ${!settings.active && 'opacity-50 pointer-events-none'}`}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Raio de Entrega (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="w-full p-2 border rounded"
|
||||
value={settings.max_radius_km}
|
||||
onChange={e => setSettings({ ...settings, max_radius_km: parseFloat(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Clique no mapa para definir o raio visualmente.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Preço por Km (Centavos)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full pl-8 p-2 border rounded"
|
||||
value={(settings.price_per_km_cents / 100).toFixed(2)}
|
||||
onChange={e => setSettings({ ...settings, price_per_km_cents: Math.round(parseFloat(e.target.value) * 100) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Taxa Mínima</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full pl-8 p-2 border rounded"
|
||||
value={(settings.min_fee_cents / 100).toFixed(2)}
|
||||
onChange={e => setSettings({ ...settings, min_fee_cents: Math.round(parseFloat(e.target.value) * 100) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Frete Grátis acima de</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">R$</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full pl-8 p-2 border rounded"
|
||||
value={((settings.free_shipping_threshold_cents || 0) / 100).toFixed(2)}
|
||||
onChange={e => setSettings({ ...settings, free_shipping_threshold_cents: Math.round(parseFloat(e.target.value) * 100) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MAP AREA */}
|
||||
<div className="mt-6 h-64 w-full rounded-lg overflow-hidden border border-gray-300 relative z-0">
|
||||
{settings.latitude !== 0 && (
|
||||
<MapContainer center={center} zoom={13} style={{ height: '100%', width: '100%' }}>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
/>
|
||||
<Marker position={center}>
|
||||
<Popup>Sua Loja</Popup>
|
||||
</Marker>
|
||||
{settings.active && (
|
||||
<Circle
|
||||
center={center}
|
||||
radius={settings.max_radius_km * 1000}
|
||||
pathOptions={{ fillColor: 'blue', color: 'blue', opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{settings.active && (
|
||||
<RadiusController
|
||||
center={center}
|
||||
setRadius={(r) => setSettings(s => ({ ...s, max_radius_km: r }))}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pickup Settings */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 h-fit">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="text-xl">🏪</span>
|
||||
Retirada na Loja
|
||||
</h2>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" checked={settings.pickup_active} onChange={e => setSettings({ ...settings, pickup_active: e.target.checked })} />
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-emerald-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-600"></div>
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">{settings.pickup_active ? 'Ativado' : 'Desativado'}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 ${!settings.pickup_active && 'opacity-50 pointer-events-none'}`}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Endereço de Retirada</label>
|
||||
<textarea
|
||||
className="w-full p-2 border rounded h-24"
|
||||
placeholder="Rua Exemplo, 123 - Bairro..."
|
||||
value={settings.pickup_address}
|
||||
onChange={e => setSettings({ ...settings, pickup_address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Horário de Retirada</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Seg-Sex 08:00 às 18:00..."
|
||||
value={settings.pickup_hours}
|
||||
onChange={e => setSettings({ ...settings, pickup_hours: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg flex gap-3 text-blue-700 text-sm">
|
||||
<span className="text-xl">ℹ️</span>
|
||||
<p>Ao ativar a retirada, os clientes poderão escolher buscar o pedido na loja sem custo de frete.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Salvando...' : 'Salvar Configurações'}
|
||||
<span className="text-sm">💾</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ export function UsersPage() {
|
|||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
const data = await adminService.listCompanies(1, 100)
|
||||
const data = await adminService.listCompanies(1, 1000)
|
||||
setCompanies(data.tenants || [])
|
||||
} catch (err) {
|
||||
console.error('Error loading companies:', err)
|
||||
|
|
@ -58,9 +58,10 @@ export function UsersPage() {
|
|||
setShowModal(false)
|
||||
resetForm()
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
loadUsers()
|
||||
} catch (err: any) {
|
||||
console.error('Error saving user:', err)
|
||||
alert('Erro ao salvar usuário')
|
||||
alert(`Erro ao salvar usuário: ${err.response?.data?.error || err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ export { DashboardHome } from './DashboardHome'
|
|||
export { ReviewsPage } from './ReviewsPage'
|
||||
export { LogisticsPage } from './LogisticsPage'
|
||||
export { ProfilePage } from './ProfilePage'
|
||||
export { ShippingSettingsPage } from './ShippingSettings'
|
||||
|
||||
|
|
|
|||
|
|
@ -318,6 +318,21 @@ export const adminService = {
|
|||
log('listShipments result', result)
|
||||
return result
|
||||
},
|
||||
|
||||
// ================== SHIPPING SETTINGS ==================
|
||||
getShippingSettings: async (vendorID: string) => {
|
||||
log('getShippingSettings', { vendorID })
|
||||
const result = await apiClient.get<ShippingSettings>(`/v1/shipping/settings/${vendorID}`)
|
||||
log('getShippingSettings result', result)
|
||||
return result
|
||||
},
|
||||
|
||||
upsertShippingSettings: async (vendorID: string, data: ShippingSettings) => {
|
||||
log('upsertShippingSettings', { vendorID, data })
|
||||
const result = await apiClient.put<ShippingSettings>(`/v1/shipping/settings/${vendorID}`, data)
|
||||
log('upsertShippingSettings result', result)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
// ================== REVIEWS & SHIPMENTS TYPES ==================
|
||||
|
|
@ -355,3 +370,18 @@ export interface ShipmentPage {
|
|||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
// ================== SHIPPING SETTINGS ==================
|
||||
export interface ShippingSettings {
|
||||
vendor_id?: string
|
||||
active: boolean
|
||||
max_radius_km: number
|
||||
price_per_km_cents: number
|
||||
min_fee_cents: number
|
||||
free_shipping_threshold_cents?: number
|
||||
pickup_active: boolean
|
||||
pickup_address?: string
|
||||
pickup_hours?: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
|
|
|||
38
marketplace/src/services/ordersService.ts
Normal file
38
marketplace/src/services/ordersService.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { apiClient } from './apiClient'
|
||||
|
||||
export interface OrderItem {
|
||||
product_id: string
|
||||
quantity: number
|
||||
unit_cents: number
|
||||
batch: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface ShippingAddress {
|
||||
recipient_name: string
|
||||
street: string
|
||||
number: string
|
||||
complement?: string
|
||||
district: string
|
||||
city: string
|
||||
state: string
|
||||
zip_code: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
buyer_id: string
|
||||
seller_id: string
|
||||
items: OrderItem[]
|
||||
shipping: ShippingAddress
|
||||
}
|
||||
|
||||
export const ordersService = {
|
||||
createOrder: (data: CreateOrderRequest) => {
|
||||
return apiClient.post('/v1/orders', data)
|
||||
},
|
||||
|
||||
listOrders: () => {
|
||||
return apiClient.get('/v1/orders')
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue