feat: add quantity selector, fix offer display, swap filter/location layout
- ProductOffersModal: Add quantity input for each offer when purchasing - ProductOffersModal: Display offer info in single line with flex-wrap - GroupedProductCard: Add whitespace-nowrap to prevent 'oferta' badge wrapping - ProductSearch: Swap Filters and Location components (Filters now first) - Backend: Refactored admin routes to use role-based access control - review_handler: New handler with role-based filtering - shipping_handler: Added ListShipments with role-based filtering - domain/models: Added SellerID to ReviewFilter and ShipmentFilter - postgres.go: Updated ListReviews and ListShipments for SellerID filtering - server.go: Removed /api/v1/admin routes, updated handlers
This commit is contained in:
parent
41862b3d5c
commit
240ce9a7e5
9 changed files with 200 additions and 108 deletions
|
|
@ -281,8 +281,9 @@ type Shipment struct {
|
|||
|
||||
// ReviewFilter captures review listing constraints.
|
||||
type ReviewFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
SellerID *uuid.UUID
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ReviewPage wraps paginated review results.
|
||||
|
|
@ -295,8 +296,9 @@ type ReviewPage struct {
|
|||
|
||||
// ShipmentFilter captures shipment listing constraints.
|
||||
type ShipmentFilter struct {
|
||||
Limit int
|
||||
Offset int
|
||||
SellerID *uuid.UUID
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ShipmentPage wraps paginated shipment results.
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
// ListAllReviews godoc
|
||||
// @Summary Lista todas as avaliações (Admin)
|
||||
// @Tags Admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Página"
|
||||
// @Param page_size query int false "Tamanho da página"
|
||||
// @Success 200 {object} domain.ReviewPage
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/reviews [get]
|
||||
// @Router /api/v1/reviews [get]
|
||||
func (h *Handler) ListAllReviews(w http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := parsePagination(r)
|
||||
|
||||
result, err := h.svc.ListReviews(r.Context(), domain.ReviewFilter{}, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ListAllShipments godoc
|
||||
// @Summary Lista todos os envios (Admin)
|
||||
// @Tags Admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Página"
|
||||
// @Param page_size query int false "Tamanho da página"
|
||||
// @Success 200 {object} domain.ShipmentPage
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/shipments [get]
|
||||
// @Router /api/v1/shipments [get]
|
||||
func (h *Handler) ListAllShipments(w http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := parsePagination(r)
|
||||
|
||||
result, err := h.svc.ListShipments(r.Context(), domain.ShipmentFilter{}, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
51
backend/internal/http/handler/review_handler.go
Normal file
51
backend/internal/http/handler/review_handler.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
)
|
||||
|
||||
// ListReviews godoc
|
||||
// @Summary List reviews
|
||||
// @Description Returns reviews. Admins see all, Tenants see only their own.
|
||||
// @Tags Reviews
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Página"
|
||||
// @Param page_size query int false "Tamanho da página"
|
||||
// @Success 200 {object} domain.ReviewPage
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/reviews [get]
|
||||
func (h *Handler) ListReviews(w http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := parsePagination(r)
|
||||
|
||||
requester, err := getRequester(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter := domain.ReviewFilter{}
|
||||
if !strings.EqualFold(requester.Role, "Admin") {
|
||||
if requester.CompanyID == nil {
|
||||
writeError(w, http.StatusForbidden, errors.New("user has no company associated"))
|
||||
return
|
||||
}
|
||||
// Assuming SellerID logic:
|
||||
// Reviews are usually linked to a Seller (Vendor/Pharmacy).
|
||||
// If the user is a Tenant/Seller, they should only see reviews where they are the seller.
|
||||
filter.SellerID = requester.CompanyID
|
||||
}
|
||||
|
||||
result, err := h.svc.ListReviews(r.Context(), filter, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
|
@ -180,3 +180,44 @@ func parseShippingMethodType(value string) (domain.ShippingMethodType, error) {
|
|||
return "", errors.New("invalid shipping method type")
|
||||
}
|
||||
}
|
||||
|
||||
// ListShipments godoc
|
||||
// @Summary List shipments
|
||||
// @Description Returns shipments. Admins see all, Tenants see only their own.
|
||||
// @Tags Shipments
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Página"
|
||||
// @Param page_size query int false "Tamanho da página"
|
||||
// @Success 200 {object} domain.ShipmentPage
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shipments [get]
|
||||
func (h *Handler) ListShipments(w http.ResponseWriter, r *http.Request) {
|
||||
page, pageSize := parsePagination(r)
|
||||
|
||||
requester, err := getRequester(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
|
||||
filter := domain.ShipmentFilter{}
|
||||
if !strings.EqualFold(requester.Role, "Admin") {
|
||||
if requester.CompanyID == nil {
|
||||
writeError(w, http.StatusForbidden, errors.New("user has no company associated"))
|
||||
return
|
||||
}
|
||||
// Shipments logic:
|
||||
// Shipments are linked to orders, and orders belong to sellers.
|
||||
filter.SellerID = requester.CompanyID
|
||||
}
|
||||
|
||||
result, err := h.svc.ListShipments(r.Context(), filter, page, pageSize)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1119,11 +1119,20 @@ SET active = EXCLUDED.active,
|
|||
func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error) {
|
||||
baseQuery := `FROM reviews`
|
||||
var args []any
|
||||
var clauses []string
|
||||
|
||||
// Add where clauses if needed in future
|
||||
if filter.SellerID != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1))
|
||||
args = append(args, *filter.SellerID)
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(clauses) > 0 {
|
||||
where = " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil {
|
||||
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
|
@ -1132,7 +1141,7 @@ func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter
|
|||
}
|
||||
args = append(args, filter.Limit, filter.Offset)
|
||||
|
||||
listQuery := fmt.Sprintf("SELECT id, order_id, buyer_id, seller_id, rating, comment, created_at %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, len(args)-1, len(args))
|
||||
listQuery := fmt.Sprintf("SELECT id, order_id, buyer_id, seller_id, rating, comment, created_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
||||
|
||||
var reviews []domain.Review
|
||||
if err := r.db.SelectContext(ctx, &reviews, listQuery, args...); err != nil {
|
||||
|
|
@ -1142,13 +1151,23 @@ func (r *Repository) ListReviews(ctx context.Context, filter domain.ReviewFilter
|
|||
}
|
||||
|
||||
func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error) {
|
||||
baseQuery := `FROM shipments`
|
||||
baseQuery := `FROM shipments s`
|
||||
var args []any
|
||||
var clauses []string
|
||||
|
||||
// Add where clauses if needed in future
|
||||
if filter.SellerID != nil {
|
||||
baseQuery += ` JOIN orders o ON s.order_id = o.id`
|
||||
clauses = append(clauses, fmt.Sprintf("o.seller_id = $%d", len(args)+1))
|
||||
args = append(args, *filter.SellerID)
|
||||
}
|
||||
|
||||
where := ""
|
||||
if len(clauses) > 0 {
|
||||
where = " WHERE " + strings.Join(clauses, " AND ")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery, args...); err != nil {
|
||||
if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
|
@ -1157,7 +1176,7 @@ func (r *Repository) ListShipments(ctx context.Context, filter domain.ShipmentFi
|
|||
}
|
||||
args = append(args, filter.Limit, filter.Offset)
|
||||
|
||||
listQuery := fmt.Sprintf("SELECT id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, len(args)-1, len(args))
|
||||
listQuery := fmt.Sprintf("SELECT s.id, s.order_id, s.carrier, s.tracking_code, s.external_tracking, s.status, s.created_at, s.updated_at %s%s ORDER BY s.created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
||||
|
||||
var shipments []domain.Shipment
|
||||
if err := r.db.SelectContext(ctx, &shipments, listQuery, args...); err != nil {
|
||||
|
|
|
|||
|
|
@ -90,19 +90,16 @@ func New(cfg config.Config) (*Server, error) {
|
|||
mux.Handle("POST /api/v1/orders/{id}/payment", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip, auth))
|
||||
|
||||
mux.Handle("POST /api/v1/shipments", chain(http.HandlerFunc(h.CreateShipment), middleware.Logger, middleware.Gzip, auth))
|
||||
mux.Handle("GET /api/v1/shipments", chain(http.HandlerFunc(h.ListAllShipments), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
mux.Handle("GET /api/v1/shipments", chain(http.HandlerFunc(h.ListShipments), middleware.Logger, middleware.Gzip, auth))
|
||||
mux.Handle("GET /api/v1/shipments/", chain(http.HandlerFunc(h.GetShipmentByOrderID), middleware.Logger, middleware.Gzip, auth))
|
||||
|
||||
mux.Handle("POST /api/v1/payments/webhook", chain(http.HandlerFunc(h.HandlePaymentWebhook), middleware.Logger, middleware.Gzip))
|
||||
|
||||
mux.Handle("POST /api/v1/reviews", chain(http.HandlerFunc(h.CreateReview), middleware.Logger, middleware.Gzip, auth))
|
||||
mux.Handle("GET /api/v1/reviews", chain(http.HandlerFunc(h.ListAllReviews), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
mux.Handle("GET /api/v1/reviews", chain(http.HandlerFunc(h.ListReviews), middleware.Logger, middleware.Gzip, auth))
|
||||
mux.Handle("GET /api/v1/dashboard/seller", chain(http.HandlerFunc(h.GetSellerDashboard), middleware.Logger, middleware.Gzip, auth))
|
||||
mux.Handle("GET /api/v1/dashboard/admin", chain(http.HandlerFunc(h.GetAdminDashboard), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
|
||||
mux.Handle("GET /api/v1/admin/reviews", chain(http.HandlerFunc(h.ListAllReviews), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
mux.Handle("GET /api/v1/admin/shipments", chain(http.HandlerFunc(h.ListAllShipments), middleware.Logger, middleware.Gzip, adminOnly))
|
||||
|
||||
mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("POST /api/v1/auth/register/customer", chain(http.HandlerFunc(h.RegisterCustomer), middleware.Logger, middleware.Gzip))
|
||||
mux.Handle("POST /api/v1/auth/register/tenant", chain(http.HandlerFunc(h.RegisterTenant), middleware.Logger, middleware.Gzip))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const GroupedProductCard = ({ group, onClick }: GroupedProductCardProps)
|
|||
Vence em breve
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium">
|
||||
<span className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full font-medium whitespace-nowrap">
|
||||
{group.offerCount} {group.offerCount === 1 ? 'oferta' : 'ofertas'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { GroupedProduct, ProductWithDistance } from '../types/product'
|
||||
import { formatCents } from '../utils/format'
|
||||
import { useCartStore } from '../stores/cartStore'
|
||||
|
|
@ -9,8 +10,16 @@ interface ProductOffersModalProps {
|
|||
|
||||
export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps) => {
|
||||
const addItem = useCartStore((state) => state.addItem)
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({})
|
||||
|
||||
const getQuantity = (productId: string) => quantities[productId] || 1
|
||||
|
||||
const setQuantity = (productId: string, qty: number) => {
|
||||
setQuantities(prev => ({ ...prev, [productId]: Math.max(1, qty) }))
|
||||
}
|
||||
|
||||
const handleAddToCart = (product: ProductWithDistance) => {
|
||||
const qty = getQuantity(product.id)
|
||||
addItem({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
|
|
@ -21,7 +30,7 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
vendorId: product.seller_id,
|
||||
vendorName: `${product.tenant_city}/${product.tenant_state}`,
|
||||
unitPrice: product.price_cents
|
||||
})
|
||||
}, qty)
|
||||
}
|
||||
|
||||
// Sort by price (cheapest first)
|
||||
|
|
@ -50,10 +59,10 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-4 text-sm">
|
||||
<span className="bg-white/20 px-3 py-1 rounded-full">
|
||||
<span className="bg-white/20 px-3 py-1 rounded-full whitespace-nowrap">
|
||||
{group.offerCount} {group.offerCount === 1 ? 'oferta disponível' : 'ofertas disponíveis'}
|
||||
</span>
|
||||
<span className="bg-green-500 px-3 py-1 rounded-full">
|
||||
<span className="bg-green-500 px-3 py-1 rounded-full whitespace-nowrap">
|
||||
A partir de {formatCents(group.minPriceCents)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -65,6 +74,8 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
{sortedOffers.map((offer, index) => {
|
||||
const isExpiringSoon = new Date(offer.expires_at).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000
|
||||
const isCheapest = index === 0
|
||||
const qty = getQuantity(offer.id)
|
||||
const maxQty = offer.stock || 999
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -74,40 +85,40 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
{isCheapest && (
|
||||
<span className="bg-green-600 text-white text-xs px-2 py-0.5 rounded font-medium">
|
||||
<span className="bg-green-600 text-white text-xs px-2 py-0.5 rounded font-medium whitespace-nowrap">
|
||||
Menor preço
|
||||
</span>
|
||||
)}
|
||||
{isExpiringSoon && (
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded font-medium">
|
||||
<span className="bg-amber-100 text-amber-800 text-xs px-2 py-0.5 rounded font-medium whitespace-nowrap">
|
||||
Vence em breve
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="text-gray-500">Lote:</span>
|
||||
<span className="ml-2 font-mono text-gray-700">{offer.batch}</span>
|
||||
<span className="ml-1 font-mono text-gray-700">{offer.batch}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="text-gray-500">Validade:</span>
|
||||
<span className={`ml-2 font-medium ${isExpiringSoon ? 'text-amber-600' : 'text-gray-700'}`}>
|
||||
<span className={`ml-1 font-medium ${isExpiringSoon ? 'text-amber-600' : 'text-gray-700'}`}>
|
||||
{new Date(offer.expires_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center text-blue-600">
|
||||
<div className="flex items-center text-blue-600 whitespace-nowrap">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{offer.distance_km} km • {offer.tenant_city}/{offer.tenant_state}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="whitespace-nowrap">
|
||||
<span className="text-gray-500">Estoque:</span>
|
||||
<span className="ml-2 font-medium text-gray-700">{offer.stock} un.</span>
|
||||
<span className="ml-1 font-medium text-gray-700">{offer.stock} un.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -116,15 +127,41 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCents(offer.price_cents)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddToCart(offer)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
Adicionar
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setQuantity(offer.id, qty - 1)}
|
||||
disabled={qty <= 1}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed text-gray-600 font-bold"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQty}
|
||||
value={qty}
|
||||
onChange={(e) => setQuantity(offer.id, Math.min(maxQty, parseInt(e.target.value) || 1))}
|
||||
className="w-12 text-center border-x py-1 text-sm font-medium"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(offer.id, Math.min(maxQty, qty + 1))}
|
||||
disabled={qty >= maxQty}
|
||||
className="px-2 py-1 bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed text-gray-600 font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddToCart(offer)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -146,3 +183,4 @@ export const ProductOffersModal = ({ group, onClose }: ProductOffersModalProps)
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,20 +143,7 @@ const ProductSearch = () => {
|
|||
<h1 className="text-3xl font-bold mb-6 text-gray-800">Encontre Medicamentos Próximos</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Filters & Map */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
|
||||
<h2 className="text-lg font-semibold mb-3">Sua Localização</h2>
|
||||
<LocationPicker
|
||||
initialLat={lat}
|
||||
initialLng={lng}
|
||||
onLocationSelect={handleLocationSelect}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Clique no mapa para ajustar sua localização.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border border-gray-200 space-y-4">
|
||||
<h2 className="text-lg font-semibold">Filtros</h2>
|
||||
|
||||
|
|
@ -260,6 +247,18 @@ const ProductSearch = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow border border-gray-200">
|
||||
<h2 className="text-lg font-semibold mb-3">Sua Localização</h2>
|
||||
<LocationPicker
|
||||
initialLat={lat}
|
||||
initialLng={lng}
|
||||
onLocationSelect={handleLocationSelect}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Clique no mapa para ajustar sua localização.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue