Payments
This commit is contained in:
711
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
Normal file
711
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
Normal 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: ",")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user