migration
This commit is contained in:
407
PediFoods/Views/Main/CartView.swift
Normal file
407
PediFoods/Views/Main/CartView.swift
Normal file
@@ -0,0 +1,407 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CartView: View {
|
||||
@Binding var appState: AppState
|
||||
@Binding var selectedTab: MainTab
|
||||
let enterAuth: () -> Void
|
||||
@State var openCheckout = false
|
||||
@State var couponCode = ""
|
||||
@State var appliedCouponCode: String? = nil
|
||||
@State var deliveryFee: Double? = nil
|
||||
@State var selectedCustomerAddress: CustomerAddress? = nil
|
||||
@State var isLoadingDeliveryFee = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 16) {
|
||||
Text("Meu Carrinho")
|
||||
.font(AppTypography.heading1)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 20)
|
||||
|
||||
if appState.cart.items.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Text("Seu carrinho está vazio")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
Text("Adicione produtos para continuar.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 280, alignment: .center)
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(appState.cart.items) { item in
|
||||
cartItemRow(item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// couponSection
|
||||
// .padding(.horizontal, 20)
|
||||
|
||||
summarySection
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationDestination(isPresented: $openCheckout) {
|
||||
CheckoutView(appState: $appState, selectedTab: $selectedTab)
|
||||
}
|
||||
.task(id: deliveryFeeWatchKey) {
|
||||
await refreshDeliveryFee()
|
||||
}
|
||||
}
|
||||
|
||||
private var subtotalValue: Double {
|
||||
appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
|
||||
}
|
||||
|
||||
private var totalValue: Double {
|
||||
max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue)
|
||||
}
|
||||
|
||||
private var effectiveDiscountValue: Double {
|
||||
let normalizedCoupon = (appliedCouponCode ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.uppercased()
|
||||
|
||||
if normalizedCoupon == "DESCONTO10" {
|
||||
return min(subtotalValue, subtotalValue * 0.1)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private var discountLabelValue: String {
|
||||
if effectiveDiscountValue <= 0.0001 {
|
||||
return formatCurrency(0)
|
||||
}
|
||||
return "-\(formatCurrency(effectiveDiscountValue))"
|
||||
}
|
||||
|
||||
private var deliveryFeeWatchKey: String {
|
||||
let storeId = appState.cart.storeId ?? "nil"
|
||||
let selectedId = appState.address.selectedId ?? "nil"
|
||||
let display = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
|
||||
return "\(storeId)|\(selectedId)|\(display)|\(lat)|\(lng)"
|
||||
}
|
||||
|
||||
private var couponSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Cupom de Desconto")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "ticket")
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
TextField("Inserir cupom", text: $couponCode)
|
||||
.appNoAutoCap()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 50)
|
||||
.background(AppColors.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
|
||||
.stroke(AppColors.brandSoft, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
Button("Aplicar") {
|
||||
applyCoupon()
|
||||
}
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(width: 120, height: 50)
|
||||
.background(AppColors.brandDark)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let appliedCouponCode {
|
||||
Text("Cupom aplicado: \(appliedCouponCode)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Resumo de Valores")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
|
||||
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
|
||||
summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted)
|
||||
|
||||
Divider()
|
||||
|
||||
summaryRow(title: "Total", value: formatCurrency(totalValue), highlighted: true)
|
||||
|
||||
Button {
|
||||
guard appState.session.isAuthenticated else {
|
||||
enterAuth()
|
||||
return
|
||||
}
|
||||
openCheckout = true
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Text("Ir para o Pagamento")
|
||||
.font(AppTypography.heading2)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity, minHeight: 54)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(16)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
}
|
||||
|
||||
private var deliveryFeeLabel: String {
|
||||
if isLoadingDeliveryFee {
|
||||
return "Calculando..."
|
||||
}
|
||||
if let deliveryFee {
|
||||
return formatCurrency(deliveryFee)
|
||||
}
|
||||
return "Indisponível"
|
||||
}
|
||||
|
||||
private func summaryRow(title: String, value: String, valueColor: Color? = nil, highlighted: Bool = false) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(highlighted ? AppTypography.heading2 : AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(highlighted ? AppTypography.heading1 : AppTypography.heading3)
|
||||
.foregroundStyle(valueColor ?? AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
private func cartItemRow(_ item: CartItemState) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
AsyncStoreImage(imageURL: item.imageURL)
|
||||
.frame(width: 78, height: 78)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.name)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
if let details = item.details, details.isEmpty == false {
|
||||
Text(details)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Text(formatCurrency(item.unitPrice))
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { appState.cart.decrement(itemId: item.id) }) {
|
||||
Image(systemName: "minus")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("\(item.quantity)")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(minWidth: 18)
|
||||
|
||||
Button(action: { appState.cart.increment(itemId: item.id) }) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AppColors.tertiary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(AppColors.backgroundLight)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func applyCoupon() {
|
||||
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
guard normalized.isEmpty == false else {
|
||||
appliedCouponCode = nil
|
||||
return
|
||||
}
|
||||
|
||||
if normalized == "DESCONTO10" {
|
||||
appliedCouponCode = normalized
|
||||
return
|
||||
}
|
||||
|
||||
appliedCouponCode = nil
|
||||
}
|
||||
|
||||
private func formatCurrency(_ value: Double) -> String {
|
||||
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshDeliveryFee() async {
|
||||
guard appState.cart.items.isEmpty == false else {
|
||||
deliveryFee = nil
|
||||
selectedCustomerAddress = nil
|
||||
return
|
||||
}
|
||||
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
|
||||
deliveryFee = nil
|
||||
selectedCustomerAddress = nil
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingDeliveryFee = true
|
||||
defer { isLoadingDeliveryFee = false }
|
||||
|
||||
do {
|
||||
let profileResponse = try await ApiService().profile(forceRefresh: true)
|
||||
let addresses = profileResponse.result?.addressBook ?? []
|
||||
|
||||
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
|
||||
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
|
||||
} else {
|
||||
selectedCustomerAddress = nil
|
||||
}
|
||||
|
||||
// id can be nil for some address book entries — lat/lng is set
|
||||
// immediately and reliably at selection time (AddressesView.
|
||||
// selectAddress), so it's a stronger signal than the label match
|
||||
// below, which silently collides whenever two addresses share an
|
||||
// empty/duplicate label. Without this, an id-less address falls
|
||||
// through to addresses.first and never actually "changes".
|
||||
if selectedCustomerAddress == nil,
|
||||
let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||
selectedCustomerAddress = addresses.first { address in
|
||||
guard let addrLat = address.latLong?.first,
|
||||
let addrLng = address.latLong?.dropFirst().first else { return false }
|
||||
return abs(addrLat - lat) < 0.00001 && abs(addrLng - lng) < 0.00001
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil {
|
||||
let display = appState.address.display
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
if display.isEmpty == false, display != "defina seu endereco" {
|
||||
selectedCustomerAddress = addresses.first {
|
||||
($0.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == display
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCustomerAddress == nil {
|
||||
selectedCustomerAddress = addresses.first
|
||||
}
|
||||
|
||||
if let selected = selectedCustomerAddress {
|
||||
// appState.address reflects the address the user just picked —
|
||||
// authoritative. Only fill in gaps from the address book here,
|
||||
// never overwrite a live selection with a (possibly stale)
|
||||
// cached record, or the fee/validation payload below can end
|
||||
// up built against the wrong coordinates.
|
||||
if appState.address.selectedId == nil || appState.address.selectedId?.isEmpty == true {
|
||||
appState.address.selectedId = selected.id
|
||||
}
|
||||
if appState.address.display.isEmpty || appState.address.display == "Defina seu endereco" {
|
||||
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if label.isEmpty == false {
|
||||
appState.address.display = label
|
||||
}
|
||||
}
|
||||
if appState.address.latitude == nil || appState.address.longitude == nil,
|
||||
let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
|
||||
appState.address.latitude = lat
|
||||
appState.address.longitude = lng
|
||||
}
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
}
|
||||
|
||||
var payloadLat = appState.address.latitude ?? selectedCustomerAddress?.latLong?.first
|
||||
var payloadLng = appState.address.longitude ?? selectedCustomerAddress?.latLong?.dropFirst().first
|
||||
|
||||
// Some saved addresses have no lat/long (CEP lookup at creation
|
||||
// time didn't return coordinates). Without coordinates the
|
||||
// backend can't tell this address apart from any other, so the
|
||||
// fee silently never changes. Geocode locally as a fallback.
|
||||
if payloadLat == nil || payloadLng == nil {
|
||||
if let coordinate = await LocationService.geocodeAddress(
|
||||
street: selectedCustomerAddress?.address,
|
||||
number: selectedCustomerAddress?.number,
|
||||
neighborhood: selectedCustomerAddress?.neighborhood,
|
||||
city: selectedCustomerAddress?.city,
|
||||
state: selectedCustomerAddress?.state,
|
||||
zip: selectedCustomerAddress?.zipCode
|
||||
) {
|
||||
payloadLat = coordinate.0
|
||||
payloadLng = coordinate.1
|
||||
appState.address.latitude = coordinate.0
|
||||
appState.address.longitude = coordinate.1
|
||||
SessionStateStore.saveAddress(appState.address)
|
||||
}
|
||||
}
|
||||
|
||||
let payload = ValidateDeliveryAddressPayload(
|
||||
address: ValidateDeliveryAddressDataPayload(
|
||||
street: selectedCustomerAddress?.address,
|
||||
number: selectedCustomerAddress?.number,
|
||||
neighborhood: selectedCustomerAddress?.neighborhood,
|
||||
city: selectedCustomerAddress?.city,
|
||||
state: selectedCustomerAddress?.state,
|
||||
zip: selectedCustomerAddress?.zipCode,
|
||||
lat: payloadLat,
|
||||
lng: payloadLng
|
||||
)
|
||||
)
|
||||
|
||||
let validationResponse = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
|
||||
guard validationResponse.error == false,
|
||||
validationResponse.result?.deliveryAllowed == true,
|
||||
let fee = validationResponse.result?.deliveryFee else {
|
||||
deliveryFee = nil
|
||||
return
|
||||
}
|
||||
|
||||
deliveryFee = fee
|
||||
} catch {
|
||||
deliveryFee = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user