payment and list

This commit is contained in:
Daniel Arantes Loverde
2026-02-24 10:19:47 -03:00
parent 3a3dc7217b
commit ca83a275ac
51 changed files with 1645 additions and 256 deletions

View File

@@ -152,12 +152,28 @@ struct CheckoutView: View {
}
}
.navigationDestination(item: $pixPaymentContext) { context in
PaymentPixView(context: context) {
PaymentPixView(
context: context,
onPaymentConfirmed: {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
}
) {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
}
}
.navigationDestination(item: $cardPaymentContext) { context in
PaymentCardView(context: context) {
PaymentCardView(
context: context,
onPaymentConfirmed: {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
}
) {
appState.cart.clear()
SessionStateStore.clearPendingCartOrder()
orderTrackingContext = OrderTrackingContext(orderId: context.orderId, shortId: context.shortId)
}
}
@@ -552,11 +568,14 @@ struct CardPaymentContext: Identifiable, Hashable {
struct PaymentPixView: View {
let context: PixPaymentContext
var onPaymentConfirmed: (() -> Void)? = nil
var onOpenTracking: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@State var tracker = OrderRealtimeTracker()
@State var latestOrder: PublicOrderResult? = nil
@State var hasOpenedTracking = false
@State var hasShownPixExpiredSnackbar = false
@State var currentTime = Date()
private var qrImageSource: String? {
guard let raw = context.qrCodeImageBase64?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -590,51 +609,51 @@ struct PaymentPixView: View {
Text("AGUARDANDO PAGAMENTO")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 12)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(AppColors.brandSoft)
.clipShape(Capsule())
}
)
.frame(maxWidth: .infinity, minHeight: 360)
.frame(maxWidth: .infinity, minHeight: 380)
Text("Código PIX")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.surface)
.frame(height: 20)
.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)
.lineLimit(0)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
Text("Expira em: \(expirationDate)")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 10)
}
.padding(.vertical, 14)
)
if let expirationDate = context.expirationDate, expirationDate.isEmpty == false {
Text(expirationLabel)
.font(.caption)
.foregroundStyle(isPixExpired ? Color.red : AppColors.textMuted)
}
PrimaryButton(title: "Copiar Código PIX") {
if isPixExpired {
showPixExpiredSnackbar()
return
}
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") {
if latestOrder?.isPaymentConfirmed == true {
openTrackingOnce()
} else {
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
}
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 8)
.disabled(isPixExpired)
.opacity(isPixExpired ? 0.5 : 1.0)
.padding(.top, 10)
}
.padding(20)
}
@@ -645,11 +664,22 @@ struct PaymentPixView: View {
tracker.onOrderUpdated = { updated in
latestOrder = updated
if updated.isPaymentConfirmed {
onPaymentConfirmed?()
openTrackingOnce()
}
}
tracker.start(orderId: context.orderId, jwt: DefaultTokenStore().jwt)
}
.task {
while Task.isCancelled == false {
currentTime = Date()
if isPixExpired {
showPixExpiredSnackbar()
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
.onDisappear {
tracker.stop()
}
@@ -659,7 +689,6 @@ struct PaymentPixView: View {
guard hasOpenedTracking == false else { return }
hasOpenedTracking = true
onOpenTracking?()
dismiss()
}
private func copyToClipboard(_ value: String) {
@@ -670,10 +699,83 @@ struct PaymentPixView: View {
NSPasteboard.general.setString(value, forType: .string)
#endif
}
private var parsedExpirationDate: Date? {
let raw = (context.expirationDate ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard raw.isEmpty == false else { return nil }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = iso.date(from: raw) { return date }
iso.formatOptions = [.withInternetDateTime]
if let date = iso.date(from: raw) { return date }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "pt_BR")
let formats = [
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"dd/MM/yyyy HH:mm:ss",
"dd/MM/yyyy HH:mm"
]
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: raw) {
return date
}
}
return nil
}
private var isPixExpired: Bool {
guard let parsedExpirationDate else { return false }
return currentTime >= parsedExpirationDate
}
private var expirationLabel: String {
guard let parsedExpirationDate else {
return "Expira em: --"
}
if isPixExpired {
return "Expirado"
}
let remaining = max(0, Int(parsedExpirationDate.timeIntervalSince(currentTime)))
let day = 24 * 60 * 60
let hour = 60 * 60
if remaining >= day {
let days = remaining / day
return "Expira em: \(days) dia(s)"
}
if remaining >= hour {
let hours = remaining / hour
return "Expira em: \(hours) hora(s)"
}
if remaining >= 60 {
let minutes = remaining / 60
return "Expira em: \(minutes) min"
}
return "Vai expirar em \(remaining) segundos"
}
private func showPixExpiredSnackbar() {
guard hasShownPixExpiredSnackbar == false else { return }
hasShownPixExpiredSnackbar = true
SnackbarCenter.shared.show(
title: "PIX expirou. Gere um novo pedido para continuar.",
style: .warning,
icon: "clock.badge.xmark.fill",
duration: 4.0
)
}
}
struct PaymentCardView: View {
let context: CardPaymentContext
var onPaymentConfirmed: (() -> Void)? = nil
var onOpenTracking: (() -> Void)? = nil
@Environment(\.dismiss) var dismiss
@State var cardHolderName = ""
@@ -723,6 +825,7 @@ struct PaymentCardView: View {
PrimaryButton(title: "Salvar e Pagar") {
if latestOrder?.isPaymentConfirmed == true {
onPaymentConfirmed?()
openTrackingOnce()
} else {
SnackbarCenter.shared.show(title: "Pagamento ainda não confirmado.", style: .info, icon: "clock.fill", duration: 2.0)
@@ -731,6 +834,7 @@ struct PaymentCardView: View {
Button("Apenas Pagar") {
if latestOrder?.isPaymentConfirmed == true {
onPaymentConfirmed?()
openTrackingOnce()
} else {
SnackbarCenter.shared.show(title: "Aguardando confirmação do pagamento.", style: .info, icon: "clock.fill", duration: 2.0)
@@ -752,6 +856,7 @@ struct PaymentCardView: View {
tracker.onOrderUpdated = { updated in
latestOrder = updated
if updated.isPaymentConfirmed {
onPaymentConfirmed?()
openTrackingOnce()
}
}
@@ -766,7 +871,6 @@ struct PaymentCardView: View {
guard hasOpenedTracking == false else { return }
hasOpenedTracking = true
onOpenTracking?()
dismiss()
}
private func labeledField(_ label: String, placeholder: String, text: Binding<String>) -> some View {