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 (
|
import (
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const earthRadiusKm = 6371.0
|
const earthRadiusKm = 6371.0
|
||||||
|
|
@ -43,17 +45,18 @@ type ProductWithDistance struct {
|
||||||
|
|
||||||
// ProductSearchFilter captures search constraints.
|
// ProductSearchFilter captures search constraints.
|
||||||
type ProductSearchFilter struct {
|
type ProductSearchFilter struct {
|
||||||
Search string
|
Search string
|
||||||
Category string
|
Category string
|
||||||
MinPriceCents *int64
|
MinPriceCents *int64
|
||||||
MaxPriceCents *int64
|
MaxPriceCents *int64
|
||||||
ExpiresAfter *time.Time
|
ExpiresAfter *time.Time
|
||||||
ExpiresBefore *time.Time
|
ExpiresBefore *time.Time
|
||||||
MaxDistanceKm *float64
|
MaxDistanceKm *float64
|
||||||
BuyerLat float64
|
BuyerLat float64
|
||||||
BuyerLng float64
|
BuyerLng float64
|
||||||
Limit int
|
ExcludeSellerID *uuid.UUID // Exclude products from buyer's own company
|
||||||
Offset int
|
Limit int
|
||||||
|
Offset int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductSearchPage wraps search results with pagination.
|
// ProductSearchPage wraps search results with pagination.
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,23 @@ type ShippingAddress struct {
|
||||||
Country string `json:"country" db:"shipping_country"`
|
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.
|
// Shipment stores freight label data and tracking linkage.
|
||||||
type Shipment struct {
|
type Shipment struct {
|
||||||
ID uuid.UUID `db:"id" json:"id"`
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
|
|
||||||
|
|
@ -171,20 +171,17 @@ type updateStatusRequest struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type shippingMethodRequest struct {
|
type shippingSettingsRequest struct {
|
||||||
Type string `json:"type"`
|
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
PreparationMinutes int `json:"preparation_minutes"`
|
|
||||||
MaxRadiusKm float64 `json:"max_radius_km"`
|
MaxRadiusKm float64 `json:"max_radius_km"`
|
||||||
MinFeeCents int64 `json:"min_fee_cents"`
|
|
||||||
PricePerKmCents int64 `json:"price_per_km_cents"`
|
PricePerKmCents int64 `json:"price_per_km_cents"`
|
||||||
|
MinFeeCents int64 `json:"min_fee_cents"`
|
||||||
FreeShippingThresholdCents *int64 `json:"free_shipping_threshold_cents,omitempty"`
|
FreeShippingThresholdCents *int64 `json:"free_shipping_threshold_cents,omitempty"`
|
||||||
|
PickupActive bool `json:"pickup_active"`
|
||||||
PickupAddress string `json:"pickup_address,omitempty"`
|
PickupAddress string `json:"pickup_address,omitempty"`
|
||||||
PickupHours string `json:"pickup_hours,omitempty"`
|
PickupHours string `json:"pickup_hours,omitempty"`
|
||||||
}
|
Latitude float64 `json:"latitude"` // Store location for radius calc
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
type shippingSettingsRequest struct {
|
|
||||||
Methods []shippingMethodRequest `json:"methods"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type shippingCalculateRequest struct {
|
type shippingCalculateRequest struct {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/saveinmed/backend-go/internal/domain"
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
|
"github.com/saveinmed/backend-go/internal/http/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateProduct godoc
|
// 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)
|
result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err)
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
// @Tags Shipping
|
// @Tags Shipping
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param vendor_id path string true "Vendor ID"
|
// @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 400 {object} map[string]string
|
||||||
// @Failure 403 {object} map[string]string
|
// @Failure 403 {object} map[string]string
|
||||||
// @Failure 500 {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 {
|
if err != nil {
|
||||||
|
// Log error if needed, but for 404/not found we might return empty object
|
||||||
writeError(w, http.StatusInternalServerError, err)
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
return
|
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
|
// UpsertShippingSettings godoc
|
||||||
|
|
@ -56,7 +61,7 @@ func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param vendor_id path string true "Vendor ID"
|
// @Param vendor_id path string true "Vendor ID"
|
||||||
// @Param payload body shippingSettingsRequest true "Shipping settings"
|
// @Param payload body shippingSettingsRequest true "Shipping settings"
|
||||||
// @Success 200 {array} domain.ShippingMethod
|
// @Success 200 {object} domain.ShippingSettings
|
||||||
// @Failure 400 {object} map[string]string
|
// @Failure 400 {object} map[string]string
|
||||||
// @Failure 403 {object} map[string]string
|
// @Failure 403 {object} map[string]string
|
||||||
// @Failure 500 {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)
|
writeError(w, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Methods) == 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, errors.New("methods are required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
methods := make([]domain.ShippingMethod, 0, len(req.Methods))
|
if req.Active {
|
||||||
for _, method := range req.Methods {
|
if req.MaxRadiusKm < 0 {
|
||||||
methodType, err := parseShippingMethodType(method.Type)
|
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be >= 0"))
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if method.PreparationMinutes < 0 {
|
if req.PricePerKmCents < 0 || req.MinFeeCents < 0 {
|
||||||
writeError(w, http.StatusBadRequest, errors.New("preparation_minutes must be >= 0"))
|
writeError(w, http.StatusBadRequest, errors.New("pricing fields must be >= 0"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if methodType != domain.ShippingMethodPickup {
|
}
|
||||||
if method.Active && method.MaxRadiusKm <= 0 {
|
if req.PickupActive {
|
||||||
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be > 0 for active delivery methods"))
|
if strings.TrimSpace(req.PickupAddress) == "" || strings.TrimSpace(req.PickupHours) == "" {
|
||||||
return
|
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
|
||||||
}
|
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 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)
|
settings := &domain.ShippingSettings{
|
||||||
if err != nil {
|
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)
|
writeError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, updated)
|
writeJSON(w, http.StatusOK, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalculateShipping godoc
|
// CalculateShipping godoc
|
||||||
|
|
|
||||||
|
|
@ -25,51 +25,74 @@ type Claims struct {
|
||||||
func RequireAuth(secret []byte, allowedRoles ...string) func(http.Handler) http.Handler {
|
func RequireAuth(secret []byte, allowedRoles ...string) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
claims, err := parseToken(r, secret)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var companyID *uuid.UUID
|
if len(allowedRoles) > 0 && !isRoleAllowed(claims.Role, allowedRoles) {
|
||||||
if cid, ok := claims["company_id"].(string); ok && cid != "" {
|
w.WriteHeader(http.StatusForbidden)
|
||||||
if parsed, err := uuid.FromString(cid); err == nil {
|
return
|
||||||
companyID = &parsed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
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.
|
// GetClaims extracts JWT claims from the request context.
|
||||||
func GetClaims(ctx context.Context) (Claims, bool) {
|
func GetClaims(ctx context.Context) (Claims, bool) {
|
||||||
claims, ok := ctx.Value(claimsKey).(Claims)
|
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))
|
clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1))
|
||||||
args = append(args, *filter.ExpiresBefore)
|
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
|
// Always filter only available products
|
||||||
clauses = append(clauses, "p.stock > 0")
|
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()
|
_ = tx.Rollback()
|
||||||
return err
|
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()
|
return tx.Commit()
|
||||||
|
|
@ -1141,3 +1161,49 @@ func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFi
|
||||||
}
|
}
|
||||||
return shipments, total, nil
|
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("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", 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/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))
|
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)
|
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
|
||||||
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
|
||||||
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
|
||||||
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
|
GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error)
|
||||||
|
UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error
|
||||||
UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error
|
|
||||||
ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error)
|
ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error)
|
||||||
ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, 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)
|
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) {
|
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)
|
company, err := s.repo.GetCompany(ctx, vendorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -152,61 +128,62 @@ func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UU
|
||||||
return nil, errors.New("vendor not found")
|
return nil, errors.New("vendor not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
methods, err := s.repo.GetShippingMethodsByVendor(ctx, vendorID)
|
settings, err := s.repo.GetShippingSettings(ctx, vendorID)
|
||||||
if err != nil {
|
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)
|
distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng)
|
||||||
|
|
||||||
var options []domain.ShippingOption
|
var options []domain.ShippingOption
|
||||||
for _, method := range methods {
|
|
||||||
if !method.Active {
|
// 1. Delivery
|
||||||
continue
|
if settings.Active {
|
||||||
}
|
if settings.MaxRadiusKm > 0 && distance <= settings.MaxRadiusKm {
|
||||||
switch method.Type {
|
variableCost := int64(math.Round(distance * float64(settings.PricePerKmCents)))
|
||||||
case domain.ShippingMethodPickup:
|
price := settings.MinFeeCents
|
||||||
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
|
|
||||||
if variableCost > price {
|
if variableCost > price {
|
||||||
price = variableCost
|
price = variableCost
|
||||||
}
|
}
|
||||||
if method.FreeShippingThresholdCents != nil && cartTotalCents >= *method.FreeShippingThresholdCents {
|
if settings.FreeShippingThresholdCents != nil && *settings.FreeShippingThresholdCents > 0 && cartTotalCents >= *settings.FreeShippingThresholdCents {
|
||||||
price = 0
|
price = 0
|
||||||
}
|
}
|
||||||
estimatedMinutes := method.PreparationMinutes + int(math.Round(distance*5))
|
|
||||||
description := "Delivery via own fleet"
|
// Estimate: 30 mins base + 5 mins/km default
|
||||||
if method.Type == domain.ShippingMethodThirdParty {
|
estMins := 30 + int(math.Round(distance*5))
|
||||||
description = "Delivery via third-party courier"
|
|
||||||
}
|
|
||||||
options = append(options, domain.ShippingOption{
|
options = append(options, domain.ShippingOption{
|
||||||
Type: domain.ShippingOptionTypeDelivery,
|
Type: domain.ShippingOptionTypeDelivery,
|
||||||
ValueCents: price,
|
ValueCents: price,
|
||||||
EstimatedMinutes: estimatedMinutes,
|
EstimatedMinutes: estMins,
|
||||||
Description: description,
|
Description: "Entrega Própria",
|
||||||
DistanceKm: distance,
|
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
|
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
|
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 { EmployeeDashboardPage } from './pages/EmployeeDashboard'
|
||||||
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
import { DeliveryDashboardPage } from './pages/DeliveryDashboard'
|
||||||
import { MyProfilePage } from './pages/MyProfile'
|
import { MyProfilePage } from './pages/MyProfile'
|
||||||
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
import ProductSearch from './pages/ProductSearch'
|
import ProductSearch from './pages/ProductSearch'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
import { DashboardLayout } from './layouts/DashboardLayout'
|
import { DashboardLayout } from './layouts/DashboardLayout'
|
||||||
|
|
@ -20,7 +21,8 @@ import {
|
||||||
OrdersPage,
|
OrdersPage,
|
||||||
ReviewsPage,
|
ReviewsPage,
|
||||||
LogisticsPage,
|
LogisticsPage,
|
||||||
ProfilePage
|
ProfilePage,
|
||||||
|
ShippingSettingsPage
|
||||||
} from './pages/admin'
|
} from './pages/admin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -144,6 +146,14 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/checkout"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CheckoutPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* Product Search - Buy from other pharmacies */}
|
{/* Product Search - Buy from other pharmacies */}
|
||||||
<Route
|
<Route
|
||||||
path="/search"
|
path="/search"
|
||||||
|
|
@ -153,6 +163,15 @@ function App() {
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Shipping Settings - for Sellers */}
|
||||||
|
<Route
|
||||||
|
path="/shipping-settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['owner', 'seller']}>
|
||||||
|
<ShippingSettingsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ShoppingBasket } from 'lucide-react'
|
import { ShoppingBasket } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { Shell } from '../layouts/Shell'
|
import { Shell } from '../layouts/Shell'
|
||||||
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
||||||
import { formatCurrency } from '../utils/format'
|
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}>
|
<button className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold" onClick={clearAll}>
|
||||||
Limpar carrinho
|
Limpar carrinho
|
||||||
</button>
|
</button>
|
||||||
<a
|
<Link
|
||||||
href="/checkout"
|
to="/checkout"
|
||||||
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||||
>
|
>
|
||||||
Seguir para checkout unificado
|
Seguir para checkout unificado
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,241 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { initMercadoPago, Payment } from '@mercadopago/sdk-react'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Shell } from '../layouts/Shell'
|
import { Shell } from '../layouts/Shell'
|
||||||
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
import { useCartStore, selectGroupedCart, selectCartSummary } from '../stores/cartStore'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { ordersService, CreateOrderRequest } from '../services/ordersService'
|
||||||
const MP_PUBLIC_KEY = import.meta.env.VITE_MP_PUBLIC_KEY || 'TEST-PUBLIC-KEY'
|
import { formatCurrency } from '../utils/format'
|
||||||
|
import { ArrowLeft, CheckCircle2, Truck } from 'lucide-react'
|
||||||
// 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('.', ',')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckoutPage() {
|
export function CheckoutPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const summary = useCartStore(selectCartSummary)
|
|
||||||
const groups = useCartStore(selectGroupedCart)
|
const groups = useCartStore(selectGroupedCart)
|
||||||
const [status, setStatus] = useState<'pendente' | 'pago' | null>(null)
|
const summary = useCartStore(selectCartSummary)
|
||||||
|
const clearAll = useCartStore((state) => state.clearAll)
|
||||||
|
|
||||||
useEffect(() => {
|
const [loading, setLoading] = useState(false)
|
||||||
initMercadoPago(MP_PUBLIC_KEY, { locale: 'pt-BR' })
|
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 (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
<div className="lg:col-span-2 space-y-3 rounded-lg bg-white p-4 shadow-sm">
|
<button onClick={() => navigate('/cart')} className="flex items-center text-sm text-gray-500 hover:text-gray-900">
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
<ArrowLeft className="mr-1 h-4 w-4" /> Voltar para o carrinho
|
||||||
<div>
|
</button>
|
||||||
<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>
|
<h1 className="text-2xl font-bold text-gray-900">Finalizar Compra</h1>
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
<p className="text-xs text-gray-500">Comprador</p>
|
{/* Left Column: Form */}
|
||||||
<p className="font-semibold text-gray-800">{user?.name}</p>
|
<div className="space-y-6 md:col-span-2">
|
||||||
</div>
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
</div>
|
<div className="mb-4 flex items-center gap-2">
|
||||||
{Object.entries(groups).map(([vendorId, group]) => (
|
<Truck className="h-5 w-5 text-medicalBlue" />
|
||||||
<div key={vendorId} className="rounded border border-gray-200 bg-gray-50 p-3">
|
<h2 className="text-lg font-semibold text-gray-800">Endereço de Entrega</h2>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<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="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>
|
</div>
|
||||||
<p className="text-xs text-gray-600">Itens enviados no split de pagamento.</p>
|
|
||||||
</div>
|
</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>
|
||||||
<div className="rounded bg-gray-100 p-3 text-sm">
|
|
||||||
<p className="font-semibold text-gray-700">Status</p>
|
{/* Right Column: Summary */}
|
||||||
<p className="text-gray-600">{status ? status.toUpperCase() : 'Aguardando pagamento'}</p>
|
<div className="space-y-6">
|
||||||
</div>
|
<div className="rounded-lg bg-white p-6 shadow-sm">
|
||||||
<div className="rounded bg-gray-50 p-3">
|
<h2 className="mb-4 text-lg font-semibold text-gray-800">Resumo do Pedido</h2>
|
||||||
<p className="text-sm font-semibold text-gray-700">Pagamento Mercado Pago</p>
|
<div className="space-y-4">
|
||||||
<Payment
|
{Object.entries(groups).map(([vendorId, group]) => (
|
||||||
initialization={{ preferenceId, amount: summary.totalValue || 0.01 }}
|
<div key={vendorId} className="border-b border-gray-100 pb-4 last:border-0 last:pb-0">
|
||||||
customization={{ paymentMethods: { creditCard: 'all', bankTransfer: 'all' } }}
|
<p className="mb-2 text-sm font-medium text-gray-600">{group.vendorName}</p>
|
||||||
onSubmit={async () => {
|
{group.items.map(item => (
|
||||||
setStatus('pendente')
|
<div key={item.id} className="flex justify-between text-sm">
|
||||||
}}
|
<span className="text-gray-800">{item.quantity}x {item.name}</span>
|
||||||
onReady={() => console.log('Payment brick pronto')}
|
<span className="text-gray-600">R$ {formatCurrency(item.quantity * item.unitPrice)}</span>
|
||||||
onError={(error) => {
|
</div>
|
||||||
console.error(error)
|
))}
|
||||||
setStatus(null)
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,8 @@ export function OrdersPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('compras')}
|
onClick={() => setActiveTab('compras')}
|
||||||
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'compras'
|
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'compras'
|
||||||
? 'text-blue-600 bg-blue-50'
|
? 'text-blue-600 bg-blue-50'
|
||||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
|
@ -116,8 +116,8 @@ export function OrdersPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('vendas')}
|
onClick={() => setActiveTab('vendas')}
|
||||||
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'vendas'
|
className={`flex-1 py-4 px-6 text-center font-medium transition-all relative ${activeTab === 'vendas'
|
||||||
? 'text-green-600 bg-green-50'
|
? 'text-green-600 bg-green-50'
|
||||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
|
@ -224,36 +224,69 @@ export function OrdersPage() {
|
||||||
|
|
||||||
{/* Actions - only for sales */}
|
{/* Actions - only for sales */}
|
||||||
{activeTab === 'vendas' && (
|
{activeTab === 'vendas' && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex gap-2">
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
{order.status === 'Pendente' && (
|
{/* Timeline for Sales */}
|
||||||
<button
|
<div className="mb-4">
|
||||||
onClick={() => updateStatus(order.id, 'Pago')}
|
<div className="flex items-center justify-between">
|
||||||
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"
|
{['Pendente', 'Pago', 'Faturado', 'Entregue'].map((status, idx) => {
|
||||||
>
|
const isCompleted = ['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) >= idx
|
||||||
💳 Confirmar Pagamento
|
return (
|
||||||
</button>
|
<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
|
||||||
{order.status === 'Pago' && (
|
? 'bg-blue-600 text-white'
|
||||||
<button
|
: 'bg-gray-200 text-gray-500'
|
||||||
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"
|
{isCompleted ? '✓' : idx + 1}
|
||||||
>
|
</div>
|
||||||
📄 Faturar Pedido
|
{idx < 3 && (
|
||||||
</button>
|
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
|
||||||
)}
|
? 'bg-blue-600'
|
||||||
{order.status === 'Faturado' && (
|
: 'bg-gray-200'
|
||||||
<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"
|
</div>
|
||||||
>
|
)
|
||||||
✅ Confirmar Entrega
|
})}
|
||||||
</button>
|
</div>
|
||||||
)}
|
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
||||||
{order.status === 'Entregue' && (
|
<span>Pendente</span>
|
||||||
<span className="text-green-600 text-sm font-medium flex items-center gap-2">
|
<span>Pago</span>
|
||||||
✅ Pedido concluído com sucesso
|
<span>Faturado</span>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -266,15 +299,15 @@ export function OrdersPage() {
|
||||||
return (
|
return (
|
||||||
<div key={status} className="flex items-center">
|
<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
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${isCompleted
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 text-gray-500'
|
: 'bg-gray-200 text-gray-500'
|
||||||
}`}>
|
}`}>
|
||||||
{isCompleted ? '✓' : idx + 1}
|
{isCompleted ? '✓' : idx + 1}
|
||||||
</div>
|
</div>
|
||||||
{idx < 3 && (
|
{idx < 3 && (
|
||||||
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
|
<div className={`w-16 h-1 mx-1 ${['Pendente', 'Pago', 'Faturado', 'Entregue'].indexOf(order.status) > idx
|
||||||
? 'bg-blue-600'
|
? 'bg-blue-600'
|
||||||
: 'bg-gray-200'
|
: 'bg-gray-200'
|
||||||
}`} />
|
}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,6 +320,18 @@ export function OrdersPage() {
|
||||||
<span>Faturado</span>
|
<span>Faturado</span>
|
||||||
<span>Entregue</span>
|
<span>Entregue</span>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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 () => {
|
const loadCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await adminService.listCompanies(1, 100)
|
const data = await adminService.listCompanies(1, 1000)
|
||||||
setCompanies(data.tenants || [])
|
setCompanies(data.tenants || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading companies:', err)
|
console.error('Error loading companies:', err)
|
||||||
|
|
@ -58,9 +58,10 @@ export function UsersPage() {
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
resetForm()
|
resetForm()
|
||||||
loadUsers()
|
loadUsers()
|
||||||
} catch (err) {
|
loadUsers()
|
||||||
|
} catch (err: any) {
|
||||||
console.error('Error saving user:', err)
|
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 { ReviewsPage } from './ReviewsPage'
|
||||||
export { LogisticsPage } from './LogisticsPage'
|
export { LogisticsPage } from './LogisticsPage'
|
||||||
export { ProfilePage } from './ProfilePage'
|
export { ProfilePage } from './ProfilePage'
|
||||||
|
export { ShippingSettingsPage } from './ShippingSettings'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,21 @@ export const adminService = {
|
||||||
log('listShipments result', result)
|
log('listShipments result', result)
|
||||||
return 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 ==================
|
// ================== REVIEWS & SHIPMENTS TYPES ==================
|
||||||
|
|
@ -355,3 +370,18 @@ export interface ShipmentPage {
|
||||||
page: number
|
page: number
|
||||||
page_size: 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