This commit is contained in:
Daniel Arantes Loverde
2026-02-15 08:18:06 -03:00
parent d7b8b1864a
commit 0f5ad4c268
35 changed files with 4090 additions and 309 deletions

View File

@@ -0,0 +1,711 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
struct CheckoutView: View {
@Binding var appState: AppState
@State var storeInfo: StoreInfoResult? = nil
@State var errorMessage: String? = nil
@State var deliveryType: CheckoutDeliveryType = .delivery
@State var paymentMethod: CheckoutPaymentMethod = .pix
@State var useInAppPayment = true
@State var discountValue: Double = 0
@State var baseDeliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var addressValidationMessage: String? = nil
@State var addressValidationBlocked = false
@State var isValidatingAddress = false
@State var showAddressNotServedAlert = false
@State var isRestoringAddress = false
@State var lastAcceptedAddressState: AddressState? = nil
@State var isSubmittingOrder = false
@State var pixPaymentContext: PixPaymentContext? = nil
@State var openCardPayment = false
var paymentConfig: StorePaymentMethodsInfo? { storeInfo?.paymentMethods }
var availableDeliveryTypes: [CheckoutDeliveryType] {
let deliveryEnabled = paymentConfig?.paymentOnDelivery ?? true
let pickupEnabled = paymentConfig?.paymentOnPickup ?? true
var values: [CheckoutDeliveryType] = []
if deliveryEnabled { values.append(.delivery) }
if pickupEnabled { values.append(.pickup) }
return values.isEmpty ? [.delivery, .pickup] : values
}
var availableInAppPaymentMethods: [CheckoutPaymentMethod] {
[.pix, .creditCard]
}
var availableStoreMachineMethods: [CheckoutPaymentMethod] {
var methods: [CheckoutPaymentMethod] = []
if paymentConfig?.acceptCash == true { methods.append(.money) }
if paymentConfig?.hasAnyCreditCard == true { methods.append(.creditCard) }
if paymentConfig?.hasAnyDebitCard == true { methods.append(.debitCard) }
if paymentConfig?.hasAnyVoucher == true { methods.append(.voucher) }
return methods
}
var isDeliveryMode: Bool {
deliveryType == .delivery
}
private var deliveryToggle: Binding<Bool> {
Binding(
get: { isDeliveryMode },
set: { isOn in
let next: CheckoutDeliveryType = isOn ? .delivery : .pickup
if availableDeliveryTypes.contains(next) {
deliveryType = next
}
}
)
}
private var subtotalValue: Double {
appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
private var deliveryFeeValue: Double {
isDeliveryMode ? (baseDeliveryFee ?? 0) : 0
}
var totalValue: Double {
max(0, subtotalValue + deliveryFeeValue - discountValue)
}
private var sectionTitleColor: Color {
AppColors.textMuted
}
var canConfirmPayment: Bool {
if isDeliveryMode {
if addressValidationBlocked { return false }
if baseDeliveryFee == nil { return false }
if isValidatingAddress { return false }
}
if useInAppPayment == false && availableStoreMachineMethods.isEmpty { return false }
return true
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
deliveryTypeSection
addressSection
orderSummarySection
paymentSection
if let errorMessage, errorMessage.isEmpty == false {
Text(errorMessage)
.font(.caption)
.foregroundStyle(Color.red)
}
if let addressValidationMessage, addressValidationMessage.isEmpty == false {
Text(addressValidationMessage)
.font(.caption)
.foregroundStyle(addressValidationBlocked ? Color.red : AppColors.primary)
}
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 10)
}
.background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
bottomBar
}
.task {
await loadStoreInfoIfNeeded()
await refreshSelectedCustomerAddress()
normalizeSelectedOptions()
await validateDeliveryAddressIfNeeded()
}
.onChange(of: checkoutAddressWatchKey) { _, _ in
if isRestoringAddress { return }
Task {
await refreshSelectedCustomerAddress()
await validateDeliveryAddressIfNeeded()
}
}
.onChange(of: deliveryType) { _, _ in
Task {
await validateDeliveryAddressIfNeeded()
}
}
.alert("Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?", isPresented: $showAddressNotServedAlert) {
Button("Não", role: .cancel) {
restoreLastAcceptedAddress()
}
Button("Sim", role: .destructive) {
appState.cart.clear()
addressValidationBlocked = false
addressValidationMessage = nil
}
}
.navigationDestination(item: $pixPaymentContext) { context in
PaymentPixView(context: context)
}
.navigationDestination(isPresented: $openCardPayment) {
PaymentCardView(total: totalValue)
}
}
private var deliveryTypeSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("TIPO DE ENTREGA")
.font(AppTypography.overline)
.foregroundStyle(sectionTitleColor)
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(isDeliveryMode ? "Entrega" : "Retirada")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Spacer()
Toggle("", isOn: deliveryToggle)
.labelsHidden()
.tint(AppColors.primary)
.disabled(availableDeliveryTypes.count <= 1)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private var addressSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text(addressSectionTitle)
.font(AppTypography.overline)
.foregroundStyle(sectionTitleColor)
HStack(spacing: 12) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 44, height: 44)
.overlay(
Image(systemName: "mappin.and.ellipse")
.foregroundStyle(AppColors.primary)
)
VStack(alignment: .leading, spacing: 4) {
Text(isDeliveryMode ? "Casa" : (appState.cart.storeName ?? "Loja"))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(isDeliveryMode ? customerAddressLabel : storeAddressLabel)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
if isDeliveryMode {
Button("Alterar") {
appState.activeModal = .addressPicker
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.buttonStyle(.plain)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private var orderSummarySection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("RESUMO DO PEDIDO")
.font(AppTypography.overline)
.foregroundStyle(sectionTitleColor)
VStack(alignment: .leading, spacing: 12) {
ForEach(appState.cart.items) { item in
HStack(alignment: .center, spacing: 10) {
Text("\(item.quantity)x")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if let details = item.details, details.isEmpty == false {
Text(details)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
}
Spacer()
Text(formatCurrency(Double(item.quantity) * item.unitPrice))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
}
Divider()
.padding(.vertical, 4)
summaryRow("Subtotal", formatCurrency(subtotalValue))
summaryRow("Taxa de entrega", deliveryFeeLabel)
summaryRow("Desconto", "-\(formatCurrency(discountValue))", valueColor: Color.green)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
private var paymentSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("MÉTODO DE PAGAMENTO")
.font(AppTypography.overline)
.foregroundStyle(sectionTitleColor)
VStack(alignment: .leading, spacing: 12) {
paymentGroupCard(
title: "Pagar Pelo App",
subtitle: "Mais rápido e seguro",
isSelected: useInAppPayment
) {
VStack(spacing: 0) {
ForEach(availableInAppPaymentMethods, id: \.rawValue) { method in
paymentRow(method, isInAppGroup: true)
if method != availableInAppPaymentMethods.last {
Divider()
}
}
}
} onTap: {
useInAppPayment = true
if availableInAppPaymentMethods.contains(paymentMethod) == false {
paymentMethod = .pix
}
}
paymentGroupCard(
title: "Pagar Na Maquininha Da Loja",
subtitle: "Pague na entrega/retirada com os métodos aceitos pela loja",
isSelected: useInAppPayment == false
) {
if availableStoreMachineMethods.isEmpty {
Text("Loja não informou métodos presenciais.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 14)
.padding(.bottom, 14)
} else {
VStack(spacing: 0) {
ForEach(availableStoreMachineMethods, id: \.rawValue) { method in
paymentRow(method, isInAppGroup: false)
if method != availableStoreMachineMethods.last {
Divider()
}
}
}
}
} onTap: {
guard availableStoreMachineMethods.isEmpty == false else { return }
useInAppPayment = false
if availableStoreMachineMethods.contains(paymentMethod) == false,
let first = availableStoreMachineMethods.first {
paymentMethod = first
}
}
}
}
}
private func paymentRow(_ method: CheckoutPaymentMethod, isInAppGroup: Bool) -> some View {
let subtitle = paymentSubtitle(for: method, isInAppGroup: isInAppGroup)
return Button {
paymentMethod = method
} label: {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(AppColors.backgroundLight)
.frame(width: 54, height: 54)
.overlay(
Image(systemName: method.iconName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(method == .pix ? Color.green : AppColors.textPrimary)
)
VStack(alignment: .leading, spacing: 3) {
Text(method.label)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
Circle()
.stroke(paymentMethod == method ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2)
.frame(width: 24, height: 24)
.background(
Circle()
.fill(paymentMethod == method ? AppColors.tertiary : Color.clear)
)
}
.padding(14)
}
.buttonStyle(.plain)
}
private func paymentSubtitle(for method: CheckoutPaymentMethod, isInAppGroup: Bool) -> String? {
if isInAppGroup == false {
if method == .pix {
return "Pagamento presencial (QR da loja)"
}
return nil
}
return method.subtitle
}
private func paymentGroupCard<Content: View>(
title: String,
subtitle: String,
isSelected: Bool,
@ViewBuilder content: () -> Content,
onTap: @escaping () -> Void
) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(subtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
Circle()
.stroke(isSelected ? AppColors.tertiary : AppColors.textMuted.opacity(0.45), lineWidth: 2)
.frame(width: 22, height: 22)
.background(
Circle()
.fill(isSelected ? AppColors.tertiary : Color.clear)
)
}
.padding(.horizontal, 14)
.padding(.top, 14)
.contentShape(Rectangle())
.onTapGesture(perform: onTap)
content()
.allowsHitTesting(isSelected)
.opacity(isSelected ? 1 : 0.82)
}
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
private var bottomBar: some View {
VStack(spacing: 12) {
HStack {
Text("Total a pagar")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(formatCurrency(totalValue))
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
}
Button {
Task { await handleConfirmPaymentTap() }
} label: {
HStack(spacing: 10) {
Text("Confirmar e Pagar")
.font(AppTypography.heading2)
Image(systemName: "checkmark")
.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)
.disabled(canConfirmPayment == false || isValidatingAddress || isSubmittingOrder)
.opacity((canConfirmPayment && isValidatingAddress == false && isSubmittingOrder == false) ? 1 : 0.65)
}
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, UIDevice.bottomNotch + 40)
.background(AppColors.surface.opacity(0.98))
}
private func summaryRow(_ title: String, _ value: String, valueColor: Color? = nil) -> some View {
HStack {
Text(title)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(value)
.font(AppTypography.heading3)
.foregroundStyle(valueColor ?? AppColors.textMuted)
}
}
private var customerAddressLabel: String {
if let address = selectedCustomerAddress {
let street = (address.address ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ")
let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n")
if joined.isEmpty == false { return joined }
}
let value = appState.address.display.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "Defina seu endereço" : value
}
private var addressSectionTitle: String {
if isDeliveryMode {
return "ENDEREÇO DE ENTREGA"
}
let storeName = (appState.cart.storeName ?? "LOJA")
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
return "ENDEREÇO DE \(storeName)"
}
private var storeAddressLabel: String {
guard let address = storeInfo?.address else { return "Endereço da loja indisponível" }
let street = (address.street ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let number = (address.number ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let neighborhood = (address.neighborhood ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let city = (address.city ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let state = (address.state ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let firstLine = [street, number].filter { $0.isEmpty == false }.joined(separator: ", ")
let secondLine = [neighborhood, city, state].filter { $0.isEmpty == false }.joined(separator: " - ")
let joined = [firstLine, secondLine].filter { $0.isEmpty == false }.joined(separator: "\n")
return joined.isEmpty ? "Endereço da loja indisponível" : joined
}
private var deliveryFeeLabel: String {
if isDeliveryMode == false {
return formatCurrency(0)
}
if let baseDeliveryFee {
return formatCurrency(baseDeliveryFee)
}
return "Calculando..."
}
}
struct PixPaymentContext: Identifiable, Hashable {
let id: String
let orderId: String
let shortId: String?
let copyPaste: String
let qrCodeImageBase64: String?
let expirationDate: String?
}
struct PaymentPixView: View {
let context: PixPaymentContext
@Environment(\.dismiss) var dismiss
private var qrImageSource: String? {
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil }
if raw.lowercased().hasPrefix("data:image") { return raw }
return "data:image/png;base64,\(raw)"
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface)
.overlay(
VStack(spacing: 10) {
Text("Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
.padding(.top, 14)
AsyncStoreImage(imageURL: qrImageSource)
.frame(width: 220, height: 220)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(AppColors.tertiary.opacity(0.35), lineWidth: 2)
)
Text("AGUARDANDO PAGAMENTO")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
)
.frame(maxWidth: .infinity, minHeight: 360)
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface)
.overlay(
VStack(spacing: 8) {
Text("Código PIX")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text(context.copyPaste)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(AppColors.textMuted)
.lineLimit(3)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
Text("Expira em: \(expirationDate)")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
}
.padding(.vertical, 14)
)
PrimaryButton(title: "Copiar Código PIX") {
copyToClipboard(context.copyPaste)
SnackbarCenter.shared.show(title: "Código PIX copiado.", style: .success, icon: "doc.on.doc.fill", duration: 2.0)
}
Button("Já realizei o pagamento") {
dismiss()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 8)
}
.padding(20)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX")
.navigationBarTitleDisplayMode(.inline)
}
private func copyToClipboard(_ value: String) {
#if canImport(UIKit)
UIPasteboard.general.string = value
#elseif canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
#endif
}
}
struct PaymentCardView: View {
let total: Double
@Environment(\.dismiss) var dismiss
@State var cardHolderName = ""
@State var cardNumber = ""
@State var expiry = ""
@State var cvv = ""
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 14) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface)
.overlay(
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Total do Pedido")
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
Text(formatCurrency(total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
}
Spacer()
}
.padding(14)
)
.frame(height: 88)
Text("Dados do Cartão")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
VStack(spacing: 10) {
labeledField("Número do Cartão", placeholder: "0000 0000 0000 0000", text: $cardNumber)
labeledField("Nome no Cartão", placeholder: "Como impresso no cartão", text: $cardHolderName)
HStack(spacing: 10) {
labeledField("Validade", placeholder: "MM/AA", text: $expiry)
labeledField("CVV", placeholder: "•••", text: $cvv)
}
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
PrimaryButton(title: "Salvar e Pagar") {
SnackbarCenter.shared.show(title: "Fluxo de cartão em construção.", style: .info, icon: "creditcard.fill", duration: 2.0)
}
Button("Apenas Pagar") {
dismiss()
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.frame(maxWidth: .infinity, minHeight: 48)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.buttonStyle(.plain)
}
.padding(20)
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento")
.navigationBarTitleDisplayMode(.inline)
}
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.horizontal, 12)
.frame(height: 46)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}