This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,356 @@
import Foundation
import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@Binding var selectedTab: MainTab
@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 {
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()
let addresses = profileResponse.result?.addressBook ?? []
if let selectedId = appState.address.selectedId, selectedId.isEmpty == false {
selectedCustomerAddress = addresses.first(where: { $0.id == selectedId })
} else {
selectedCustomerAddress = nil
}
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.selectedId = selected.id
let label = (selected.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if label.isEmpty == false {
appState.address.display = label
}
if let lat = selected.latLong?.first, let lng = selected.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
SessionStateStore.saveAddress(appState.address)
}
let payloadLat = selectedCustomerAddress?.latLong?.first ?? appState.address.latitude
let payloadLng = selectedCustomerAddress?.latLong?.dropFirst().first ?? appState.address.longitude
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
}
}
}