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

@@ -3,73 +3,158 @@ import SwiftUI
struct CartView: View {
@Binding var appState: AppState
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
@State var discountValue: Double = 0
@State var deliveryFee: Double = 5
var body: some View {
VStack(spacing: 14) {
Text("Carrinho")
.font(AppTypography.heading1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
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, maxHeight: .infinity, alignment: .center)
} else {
ScrollView(showsIndicators: false) {
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) {
if let storeName = appState.cart.storeName, storeName.isEmpty == false {
Text(storeName)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
}
ForEach(appState.cart.items) { item in
cartItemRow(item)
}
}
.padding(.bottom, 120)
.padding(.horizontal, 20)
couponSection
.padding(.horizontal, 20)
summarySection
.padding(.horizontal, 20)
}
}
.padding(.bottom, 120)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.safeAreaInset(edge: .bottom) {
if appState.cart.items.isEmpty == false {
VStack(spacing: 10) {
HStack {
Text("Total")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Spacer()
Text(formatCurrency(appState.cart.total))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
.navigationDestination(isPresented: $openCheckout) {
CheckoutView(appState: $appState)
}
}
PrimaryButton(title: "Finalizar pedido", action: {})
.padding(.horizontal, 20)
private var subtotalValue: Double {
appState.cart.items.reduce(0) { $0 + (Double($1.quantity) * $1.unitPrice) }
}
private var totalValue: Double {
max(0, subtotalValue + deliveryFee - discountValue)
}
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(.top, 10)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
.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: formatCurrency(deliveryFee))
summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
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 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: 12) {
VStack(alignment: .leading, spacing: 4) {
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)
@@ -87,13 +172,13 @@ struct CartView: View {
Spacer()
HStack(spacing: 8) {
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: 28, height: 28)
.background(AppColors.brandSoft)
.frame(width: 32, height: 32)
.background(AppColors.backgroundLight)
.clipShape(Circle())
}
.buttonStyle(.plain)
@@ -107,18 +192,39 @@ struct CartView: View {
Image(systemName: "plus")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 28, height: 28)
.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, 14)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.padding(.horizontal, 20)
}
private func applyCoupon() {
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard normalized.isEmpty == false else {
appliedCouponCode = nil
discountValue = 0
return
}
if normalized == "DESCONTO10" {
appliedCouponCode = normalized
discountValue = min(subtotalValue, subtotalValue * 0.1)
return
}
appliedCouponCode = nil
discountValue = 0
}
private func formatCurrency(_ value: Double) -> String {

View File

@@ -0,0 +1,41 @@
import Foundation
enum CheckoutDeliveryType: String {
case delivery = "DELIVERY"
case pickup = "PICKUP"
}
enum CheckoutPaymentMethod: String {
case pix = "PIX"
case creditCard = "CREDIT_CARD"
case debitCard = "DEBIT_CARD"
case money = "MONEY"
case voucher = "VOUCHER"
var label: String {
switch self {
case .pix: return "PIX"
case .creditCard: return "Cartão de Crédito"
case .debitCard: return "Cartão de Débito"
case .money: return "Dinheiro"
case .voucher: return "Vale Refeição/Alimentação"
}
}
var subtitle: String? {
switch self {
case .pix: return "Aprovação imediata"
case .creditCard: return "No app: rápido e seguro"
default: return nil
}
}
var iconName: String {
switch self {
case .pix: return "bolt.fill"
case .creditCard, .debitCard: return "creditcard.fill"
case .money: return "banknote.fill"
case .voucher: return "ticket.fill"
}
}
}

View File

@@ -0,0 +1,335 @@
import SwiftUI
extension CheckoutView {
enum CheckoutPayloadValidationError: LocalizedError {
case emptyCart
case missingCustomerName
case missingCustomerEmail
case missingCustomerPhone
case missingAddressStreet
case missingAddressNumber
case missingAddressNeighborhood
var errorDescription: String? {
switch self {
case .emptyCart: return "Carrinho vazio."
case .missingCustomerName: return "Nome do cliente não informado."
case .missingCustomerEmail: return "Email do cliente não informado."
case .missingCustomerPhone: return "Telefone do cliente não informado."
case .missingAddressStreet: return "Rua do endereço não informada."
case .missingAddressNumber: return "Número do endereço não informado."
case .missingAddressNeighborhood: return "Bairro do endereço não informado."
}
}
}
var checkoutAddressWatchKey: String {
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 "\(selectedId)|\(display)|\(lat)|\(lng)"
}
func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
@MainActor
func loadStoreInfoIfNeeded() async {
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
do {
let response = try await ApiService().storeInfo(storeId: storeId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar opções de checkout."
return
}
storeInfo = response.result
errorMessage = nil
} catch {
errorMessage = "Não foi possível carregar opções de checkout."
}
}
@MainActor
func refreshSelectedCustomerAddress() async {
do {
let response = try await ApiService().profile()
if let customer = response.result {
appState.profile.id = customer.id
appState.profile.name = customer.name
appState.profile.email = customer.email
if let phoneNumber = customer.phoneNumber, phoneNumber.isEmpty == false {
appState.profile.phone = phoneNumber
}
SessionStateStore.setActiveUserKey(
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
)
}
let addresses = response.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)
}
} catch {
selectedCustomerAddress = nil
}
}
@MainActor
func validateDeliveryAddressIfNeeded() async {
guard isDeliveryMode else {
addressValidationBlocked = false
addressValidationMessage = nil
baseDeliveryFee = nil
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else { return }
baseDeliveryFee = nil
let payload = ValidateDeliveryAddressPayload(
address: ValidateDeliveryAddressDataPayload(
street: selectedCustomerAddress?.address,
number: selectedCustomerAddress?.number,
neighborhood: selectedCustomerAddress?.neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
lat: appState.address.latitude,
lng: appState.address.longitude
)
)
isValidatingAddress = true
defer { isValidatingAddress = false }
do {
let response = try await ApiService().validateDeliveryAddress(storeId: storeId, payload: payload)
if response.error {
addressValidationBlocked = true
addressValidationMessage = response.message ?? "Não foi possível validar o endereço de entrega."
baseDeliveryFee = nil
return
}
let result = response.result
let allowed = result?.deliveryAllowed ?? false
addressValidationBlocked = allowed == false
addressValidationMessage = result?.reasonMessage
if allowed {
lastAcceptedAddressState = appState.address
} else {
showAddressNotServedAlert = true
baseDeliveryFee = nil
}
if allowed {
if let fee = result?.deliveryFee {
baseDeliveryFee = fee
} else {
addressValidationBlocked = true
addressValidationMessage = "Não foi possível calcular a taxa de entrega para este endereço."
baseDeliveryFee = nil
}
}
} catch {
addressValidationBlocked = true
addressValidationMessage = "Não foi possível validar o endereço de entrega."
baseDeliveryFee = nil
}
}
func normalizeSelectedOptions() {
if availableDeliveryTypes.contains(deliveryType) == false,
let first = availableDeliveryTypes.first {
deliveryType = first
}
if useInAppPayment {
if availableInAppPaymentMethods.contains(paymentMethod) == false,
let first = availableInAppPaymentMethods.first {
paymentMethod = first
}
} else {
if availableStoreMachineMethods.contains(paymentMethod) == false,
let first = availableStoreMachineMethods.first {
paymentMethod = first
}
}
if lastAcceptedAddressState == nil {
lastAcceptedAddressState = appState.address
}
}
func restoreLastAcceptedAddress() {
guard let snapshot = lastAcceptedAddressState else { return }
isRestoringAddress = true
appState.address = snapshot
SessionStateStore.saveAddress(snapshot)
Task { @MainActor in
await refreshSelectedCustomerAddress()
addressValidationBlocked = false
addressValidationMessage = nil
isRestoringAddress = false
}
}
@MainActor
func handleConfirmPaymentTap() async {
guard canConfirmPayment else { return }
if useInAppPayment == false {
SnackbarCenter.shared.show(title: "Pagamento presencial selecionado.", style: .info, icon: "creditcard.fill", duration: 2.0)
return
}
if paymentMethod == .creditCard {
openCardPayment = true
return
}
guard paymentMethod == .pix else {
SnackbarCenter.shared.show(title: "Método de pagamento não suportado no app.", style: .warning, icon: "exclamationmark.triangle.fill", duration: 2.0)
return
}
guard let storeId = appState.cart.storeId, storeId.isEmpty == false else {
SnackbarCenter.shared.show(title: "Loja do pedido não encontrada.", style: .error, icon: "xmark.octagon.fill", duration: 2.0)
return
}
await refreshSelectedCustomerAddress()
let payloadBuildResult = buildCreateOrderPayload(paymentMethod: .pix)
guard case .success(let payload) = payloadBuildResult else {
let message: String
if case .failure(let reason) = payloadBuildResult {
message = reason.localizedDescription
} else {
message = "Dados do pedido incompletos."
}
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 2.0)
return
}
isSubmittingOrder = true
defer { isSubmittingOrder = false }
do {
let response = try await ApiService().createOrder(storeId: storeId, payload: payload)
if response.error {
SnackbarCenter.shared.show(
title: response.message ?? "Não foi possível gerar o pagamento PIX.",
style: .error,
icon: "xmark.octagon.fill",
duration: 3.0
)
return
}
guard let result = response.result else {
SnackbarCenter.shared.show(title: "Resposta de pagamento inválida.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
let pixPayload = result.payment?.pix ?? result.paymentPayload
guard let copyPaste = pixPayload?.copyPaste, copyPaste.isEmpty == false else {
SnackbarCenter.shared.show(title: "Código PIX não retornado pela API.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
return
}
let orderId = result.id ?? UUID().uuidString
pixPaymentContext = PixPaymentContext(
id: orderId,
orderId: orderId,
shortId: result.shortId,
copyPaste: copyPaste,
qrCodeImageBase64: pixPayload?.qrCodeImage,
expirationDate: pixPayload?.expirationDate
)
} catch {
SnackbarCenter.shared.show(title: "Não foi possível gerar o pagamento PIX.", style: .error, icon: "xmark.octagon.fill", duration: 3.0)
}
}
func buildCreateOrderPayload(paymentMethod: CheckoutPaymentMethod) -> Result<CreateOrderPayload, CheckoutPayloadValidationError> {
guard appState.cart.items.isEmpty == false else { return .failure(.emptyCart) }
let profileName = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
let profileEmail = appState.profile.email.trimmingCharacters(in: .whitespacesAndNewlines)
let profilePhone = appState.profile.phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard profileName.isEmpty == false else { return .failure(.missingCustomerName) }
guard profileEmail.isEmpty == false else { return .failure(.missingCustomerEmail) }
guard profilePhone.isEmpty == false else { return .failure(.missingCustomerPhone) }
let addressPayload: CreateOrderAddressPayload?
if isDeliveryMode {
let street = selectedCustomerAddress?.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let number = selectedCustomerAddress?.number?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let neighborhood = selectedCustomerAddress?.neighborhood?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard street.isEmpty == false else { return .failure(.missingAddressStreet) }
guard number.isEmpty == false else { return .failure(.missingAddressNumber) }
guard neighborhood.isEmpty == false else { return .failure(.missingAddressNeighborhood) }
addressPayload = CreateOrderAddressPayload(
street: street,
number: number,
neighborhood: neighborhood,
city: selectedCustomerAddress?.city,
state: selectedCustomerAddress?.state,
zip: selectedCustomerAddress?.zipCode,
complement: selectedCustomerAddress?.complement
)
} else {
addressPayload = nil
}
return .success(
CreateOrderPayload(
customer: CreateOrderCustomerPayload(
name: profileName,
phone: profilePhone,
email: profileEmail,
asaasId: nil
),
items: appState.cart.toOrderItemsPayload(),
total: totalValue,
paymentMethod: paymentMethod.rawValue,
deliveryType: deliveryType.rawValue,
address: addressPayload
)
)
}
}

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: ",")
}
}

View File

@@ -40,6 +40,13 @@ struct HomeView: View {
.padding(.top, headerExpandedHeight + contentTopSpacing)
.padding(.bottom, contentBottomSpacing)
}
.refreshable {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory,
refreshCategories: true
)
}
.background(scrollOffsetObserver)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
@@ -73,6 +80,20 @@ struct HomeView: View {
}
collapseBaseOffset = scrollOffset
}
.onChange(of: addressCacheScope) { _, _ in
guard hasRequestedLocation else { return }
Task {
AppContentCache.shared.invalidate(prefix: "stores:")
AppContentCache.shared.invalidate(prefix: "store-info:")
AppContentCache.shared.invalidate(prefix: "store-catalog:")
AppImageCache.shared.invalidateAll()
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory,
refreshCategories: true
)
}
}
}
private var contentStack: some View {
@@ -309,18 +330,32 @@ struct HomeView: View {
}
}
private var sortedStores: [StoreSummary] {
private var storesByPositiveReviews: [StoreSummary] {
stores.sorted { lhs, rhs in
(lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0
let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0
if lhsPositive != rhsPositive {
return lhsPositive > rhsPositive
}
let lhsRating = lhs.rating ?? 0
let rhsRating = rhs.rating ?? 0
if lhsRating != rhsRating {
return lhsRating > rhsRating
}
return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
}
}
private var featuredStoresCards: [FeaturedStoreCardModel] {
Array(sortedStores.prefix(6)).map(mapStoreToCard)
Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard)
}
private var nearbyStoreCards: [FeaturedStoreCardModel] {
Array(sortedStores.prefix(20)).map(mapStoreToCard)
let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id))
let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false }
return Array(remaining.prefix(20)).map(mapStoreToCard)
}
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
@@ -330,7 +365,7 @@ struct HomeView: View {
id: store.id,
name: store.name,
rating: store.rating ?? 0,
reviews: "0",
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
distance: formatDistance(store.distance),
category: store.category ?? "Loja",
promoText: nil,
@@ -338,20 +373,24 @@ struct HomeView: View {
iconName: "storefront",
imageURL: coverURL ?? logoURL,
logoURL: logoURL,
coverURL: coverURL
coverURL: coverURL,
isOpen: store.isOpen ?? true,
statusLabel: store.statusLabel
)
}
private func resolveStoreMediaURL(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil }
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
return raw
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
@@ -385,6 +424,26 @@ struct HomeView: View {
}
do {
let storesCacheKey = homeStoresCacheKey(
lat: coordinate?.0,
lng: coordinate?.1,
category: category
)
if forceLocationRefresh == false,
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
isLoadingStores = false
stores = cachedStores
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: cachedStores)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
return
}
let response = try await ApiService().listStores(
lat: coordinate?.0,
lng: coordinate?.1,
@@ -398,6 +457,7 @@ struct HomeView: View {
}
let results = response.result ?? []
stores = results
AppContentCache.shared.set(results, for: storesCacheKey, ttl: 180)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results)
if categories.contains(where: { $0.id == selectedCategory }) == false {
@@ -412,7 +472,6 @@ struct HomeView: View {
}
}
@ViewBuilder
private var scrollOffsetObserver: some View {
#if os(iOS)
@@ -427,4 +486,19 @@ struct HomeView: View {
EmptyView()
#endif
}
private var addressCacheScope: String {
let selected = appState.address.selectedId ?? "nil"
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return "\(selected)|\(display)"
}
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil"
let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil"
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
}
}

View File

@@ -32,7 +32,7 @@ struct MainTabView: View {
private var customTabBar: some View {
HStack(spacing: 12) {
tabBarButton(tab: .home, title: "Home", icon: "house.fill")
tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill")
tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill", badgeCount: appState.cart.totalItems)
tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill")
}
.padding(.horizontal, 14)
@@ -46,14 +46,27 @@ struct MainTabView: View {
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
private func tabBarButton(tab: MainTab, title: String, icon: String) -> some View {
private func tabBarButton(tab: MainTab, title: String, icon: String, badgeCount: Int = 0) -> some View {
let isActive = selectedTab == tab
return Button {
selectedTab = tab
} label: {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
ZStack(alignment: .topTrailing) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
if badgeCount > 0 {
Text("\(min(badgeCount, 99))")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Color.white)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.red)
.clipShape(Capsule())
.offset(x: 9, y: -8)
}
}
if isActive {
Text(title)
.font(AppTypography.heading3)

View File

@@ -0,0 +1,106 @@
import Foundation
import SwiftUI
struct PizzaFlavorAddonsSheet: View {
let flavor: StoreCatalogProduct
@Binding var quantities: [String: Int]
@Environment(\.dismiss) var dismiss
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 12) {
Text(flavor.name)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
if flavor.addonGroups.isEmpty {
Text("Este sabor não possui adicionais.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
} else {
ForEach(flavor.addonGroups) { group in
VStack(alignment: .leading, spacing: 10) {
Text(group.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(group.items) { item in
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
Text("+ \(formatCurrency(item.price ?? 0))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 8) {
Button(action: { decrement(item.id) }) {
Image(systemName: "minus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.brandSoft)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled((quantities[item.id] ?? 0) <= 0)
Text("\(quantities[item.id] ?? 0)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { increment(item.id) }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
}
.padding(20)
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Concluir") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
}
private func increment(_ addonId: String) {
quantities[addonId, default: 0] += 1
}
private func decrement(_ addonId: String) {
let current = quantities[addonId] ?? 0
if current <= 1 {
quantities.removeValue(forKey: addonId)
} else {
quantities[addonId] = current - 1
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}

View File

@@ -0,0 +1,229 @@
import Foundation
import SwiftUI
extension PizzaProductDetailSheet {
var stepSizes: some View {
let sizeItems = sizes
return VStack(alignment: .leading, spacing: 10) {
Text("1. Tamanho")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
ForEach(Array(sizeItems.indices), id: \.self) { index in
sizeRow(sizeItems[index])
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepDoughs: some View {
VStack(alignment: .leading, spacing: 10) {
Text("2. Massa")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if doughs.count <= 1 {
Text(doughs.first?.name ?? "Massa tradicional")
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
} else {
ForEach(doughs) { dough in
radioRow(
title: dough.name ?? "Massa",
subtitle: nil,
isSelected: selectedDoughId == dough.id
) {
selectedDoughId = dough.id
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepCrusts: some View {
VStack(alignment: .leading, spacing: 10) {
Text("3. Borda")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if crusts.count <= 1 {
Text(crustDescription(crusts.first))
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
} else {
ForEach(crusts) { crust in
radioRow(
title: crust.name ?? "Borda",
subtitle: crust.priceModifier ?? 0 > 0 ? "+ \(formatCurrency(crust.priceModifier ?? 0))" : nil,
isSelected: selectedCrustId == crust.id
) {
selectedCrustId = crust.id
}
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
var stepFlavors: some View {
let flavorItems = flavors
return VStack(alignment: .leading, spacing: 10) {
Text("4. Sabores (\(selectedFlavorIds.count)/\(maxFlavorsAllowed))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
Text("Toque no sabor para escolher adicionais.")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
ForEach(Array(flavorItems.indices), id: \.self) { index in
let flavor = flavorItems[index]
let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
let maxReached = selectedFlavorIds.count >= maxFlavorsAllowed
let disableSwitch = isSelected == false && maxReached
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(flavor.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
if let price {
Text(formatCurrency(price))
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
Toggle("", isOn: Binding(
get: { isSelected },
set: { value in
if value {
addFlavor(flavor.id)
} else {
removeFlavor(flavor.id)
}
}
))
.labelsHidden()
.disabled(disableSwitch)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.onTapGesture {
guard isSelected else { return }
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
selectedFlavorForAddons = flavor
}
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
func sizeRow(_ size: StorePizzaSize) -> some View {
let title = size.name ?? "Tamanho"
let subtitle = "Até \(max(1, size.maxFlavors ?? 1)) sabor(es)"
let selected = selectedSizeId == size.id
return radioRow(
title: title,
subtitle: subtitle,
isSelected: selected
) {
selectedSizeId = size.id
}
}
func radioRow(title: String, subtitle: String?, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
ZStack {
Circle()
.stroke(isSelected ? AppColors.primary : AppColors.textMuted.opacity(0.4), lineWidth: 2)
.frame(width: 20, height: 20)
if isSelected {
Circle()
.fill(AppColors.primary)
.frame(width: 10, height: 10)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
if let subtitle, subtitle.isEmpty == false {
Text(subtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
func crustDescription(_ crust: StorePizzaCrust?) -> String {
guard let crust else { return "Sem borda especial" }
let name = crust.name ?? "Borda"
let modifier = crust.priceModifier ?? 0
if modifier > 0 {
return "\(name) (+ \(formatCurrency(modifier)))"
}
return name
}
func applyAutoSelections() {
if selectedSizeId != nil {
if doughs.count == 1 {
selectedDoughId = doughs.first?.id
} else if doughs.isEmpty {
selectedDoughId = "__none__"
}
}
if isDoughReady {
if crusts.count == 1 {
selectedCrustId = crusts.first?.id
} else if crusts.isEmpty {
selectedCrustId = "__none__"
}
}
}
func trimFlavorSelectionByLimit() {
let limit = maxFlavorsAllowed
guard selectedFlavorIds.count > limit else { return }
let sorted = selectedFlavorIds.sorted()
selectedFlavorIds = Set(sorted.prefix(limit))
}
func addFlavor(_ flavorId: String) {
if selectedFlavorIds.contains(flavorId) { return }
if selectedFlavorIds.count >= maxFlavorsAllowed { return }
selectedFlavorIds.insert(flavorId)
}
func removeFlavor(_ flavorId: String) {
selectedFlavorIds.remove(flavorId)
flavorAddonQuantities.removeValue(forKey: flavorId)
}
func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
}

View File

@@ -0,0 +1,319 @@
import Foundation
import SwiftUI
struct PizzaProductDetailSheet: View {
let category: StoreCatalogCategory
let storeId: String
let resolveImageURL: (String?) -> String?
let currentQuantityForItemId: (String) -> Int
let onAdd: (CartItemState) -> Void
@Environment(\.dismiss) var dismiss
@State var selectedSizeId: String? = nil
@State var selectedDoughId: String? = nil
@State var selectedCrustId: String? = nil
@State var selectedFlavorIds: Set<String> = []
@State var flavorAddonQuantities: [String: [String: Int]] = [:]
@State var selectedFlavorForAddons: StoreCatalogProduct? = nil
@State var quantity: Int = 1
var flavors: [StoreCatalogProduct] {
category.products
}
var pizzaConfig: StorePizzaConfig? {
category.pizzaConfig
}
var sizes: [StorePizzaSize] {
pizzaConfig?.sizes ?? []
}
var doughs: [StorePizzaDough] {
(pizzaConfig?.doughs ?? []).filter { $0.active ?? true }
}
var crusts: [StorePizzaCrust] {
(pizzaConfig?.crusts ?? []).filter { $0.active ?? true }
}
private var representativeImage: String? {
let firstImage = flavors
.compactMap(\.image)
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
return resolveImageURL(firstImage)
}
private var selectedSize: StorePizzaSize? {
guard let selectedSizeId else { return nil }
return sizes.first(where: { $0.id == selectedSizeId })
}
private var selectedDoughName: String? {
guard let selectedDoughId else { return nil }
return doughs.first(where: { $0.id == selectedDoughId })?.name
}
private var selectedCrust: StorePizzaCrust? {
guard let selectedCrustId else { return nil }
return crusts.first(where: { $0.id == selectedCrustId })
}
var maxFlavorsAllowed: Int {
max(1, selectedSize?.maxFlavors ?? 1)
}
var isDoughReady: Bool {
selectedSizeId != nil && (doughs.isEmpty || selectedDoughId != nil)
}
var isCrustReady: Bool {
isDoughReady && (crusts.isEmpty || selectedCrustId != nil)
}
var canShowFlavors: Bool {
isCrustReady
}
private var selectedFlavorProducts: [StoreCatalogProduct] {
flavors
.filter { selectedFlavorIds.contains($0.id) }
.sorted { $0.name < $1.name }
}
private var canConfirm: Bool {
selectedSizeId != nil && isDoughReady && isCrustReady && selectedFlavorIds.isEmpty == false && quantity > 0
}
private var crustPriceModifier: Double {
selectedCrust?.priceModifier ?? 0
}
private var addonsTotal: Double {
selectedFlavorProducts.reduce(0) { partial, flavor in
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
let pricesByAddon = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0.price ?? 0) })
let subtotal = byAddon.reduce(0.0) { line, pair in
line + (Double(max(0, pair.value)) * (pricesByAddon[pair.key] ?? 0))
}
return partial + subtotal
}
}
private var basePizzaPrice: Double {
selectedFlavorProducts
.map { flavor in
guard let selectedSizeId else { return flavor.price ?? 0 }
return flavor.pizzaPrices[selectedSizeId] ?? flavor.price ?? 0
}
.max() ?? 0
}
private var unitPrice: Double {
basePizzaPrice + crustPriceModifier + addonsTotal
}
private var totalPrice: Double {
unitPrice * Double(quantity)
}
private var cartItemId: String {
var tokens: [String] = []
if let selectedSizeId { tokens.append("size:\(selectedSizeId)") }
if let selectedDoughId { tokens.append("dough:\(selectedDoughId)") }
if let selectedCrustId { tokens.append("crust:\(selectedCrustId)") }
let flavorsToken = selectedFlavorIds.sorted().joined(separator: ",")
tokens.append("flavors:\(flavorsToken)")
let addonsToken = flavorAddonQuantities
.flatMap { flavorId, addons in
addons
.filter { $0.value > 0 }
.map { "\(flavorId):\($0.key):\($0.value)" }
}
.sorted()
.joined(separator: ",")
if addonsToken.isEmpty == false {
tokens.append("addons:\(addonsToken)")
}
return "\(storeId)::pizza::\(category.id)::" + tokens.joined(separator: "|")
}
private var selectedAddonsPayload: [CartItemAddonState] {
var payload: [CartItemAddonState] = []
for flavor in selectedFlavorProducts {
let byAddon = flavorAddonQuantities[flavor.id] ?? [:]
let addonMap = Dictionary(uniqueKeysWithValues: flavor.addonGroups.flatMap(\.items).map { ($0.id, $0) })
for (addonId, qty) in byAddon {
guard qty > 0, let addon = addonMap[addonId] else { continue }
payload.append(
CartItemAddonState(
id: "\(flavor.id)::\(addon.id)",
name: "\(flavor.name)\(addon.name)",
quantity: qty,
unitPrice: addon.price ?? 0
)
)
}
}
return payload
}
private var selectedDetailsText: String? {
var chunks: [String] = []
if let selectedSizeName = selectedSize?.name {
chunks.append("Tamanho: \(selectedSizeName)")
}
if let selectedDoughName, selectedDoughName.isEmpty == false {
chunks.append("Massa: \(selectedDoughName)")
}
if let crustName = selectedCrust?.name, crustName.isEmpty == false {
chunks.append("Borda: \(crustName)")
}
if selectedFlavorProducts.isEmpty == false {
chunks.append("Sabores: " + selectedFlavorProducts.map(\.name).joined(separator: ", "))
}
return chunks.isEmpty ? nil : chunks.joined(separator: "")
}
private var addButtonTitle: String {
if canConfirm == false {
return "Selecione as opções"
}
return "Adicionar • \(formatCurrency(totalPrice))"
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 16) {
AsyncStoreImage(imageURL: representativeImage)
.frame(height: 220)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
Text("Pizza de varios sabores")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text("Escolha o tamanho da sua fome")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Text(formatCurrency(unitPrice))
.font(AppTypography.heading2)
.foregroundStyle(AppColors.primary)
stepSizes
if selectedSizeId != nil {
stepDoughs
}
if isDoughReady {
stepCrusts
}
if canShowFlavors {
stepFlavors
}
}
.padding(20)
.padding(.bottom, 90)
}
.safeAreaInset(edge: .bottom) {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: { if quantity > 1 { quantity -= 1 } }) {
Image(systemName: "minus")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.surface)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(quantity <= 1)
Text("\(quantity)")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 20)
Button(action: { quantity += 1 }) {
Image(systemName: "plus")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 32, height: 32)
.background(AppColors.surface)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.frame(height: 48)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
PrimaryButton(title: addButtonTitle) {
guard canConfirm else { return }
let item = CartItemState(
id: cartItemId,
productId: selectedFlavorProducts.first?.id ?? category.id,
storeId: storeId,
name: "Pizza de varios sabores",
imageURL: representativeImage,
details: selectedDetailsText,
addons: selectedAddonsPayload,
quantity: quantity,
unitPrice: unitPrice
)
onAdd(item)
dismiss()
}
.disabled(canConfirm == false)
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 12)
.background(.ultraThinMaterial)
}
.sheet(item: $selectedFlavorForAddons) { flavor in
NavigationStack {
PizzaFlavorAddonsSheet(
flavor: flavor,
quantities: Binding(
get: { flavorAddonQuantities[flavor.id] ?? [:] },
set: { flavorAddonQuantities[flavor.id] = $0 }
)
)
}
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Fechar") { dismiss() }
.foregroundStyle(AppColors.primary)
}
}
.onAppear {
applyAutoSelections()
let existing = currentQuantityForItemId(cartItemId)
if existing > 0 {
quantity = existing
}
}
.onChange(of: selectedSizeId) { _, _ in
trimFlavorSelectionByLimit()
applyAutoSelections()
}
.onChange(of: selectedFlavorIds) { _, newValue in
let selected = newValue
flavorAddonQuantities = flavorAddonQuantities.filter { selected.contains($0.key) }
}
}
}

View File

@@ -8,17 +8,26 @@ struct ProductDetailSheet: View {
let currentQuantityForItemId: (String) -> Int
let onAdd: (CartItemState) -> Void
@Environment(\.dismiss) var dismiss
@State var selectedAddonItemIds: Set<String> = []
@State var selectedAddonQuantities: [String: Int] = [:]
@State var quantity: Int = 0
private var selectedAddonItems: [StoreAddonItem] {
product.addonGroups
.flatMap(\.items)
.filter { selectedAddonItemIds.contains($0.id) }
private var addonItemsById: [String: StoreAddonItem] {
Dictionary(uniqueKeysWithValues: product.addonGroups.flatMap(\.items).map { ($0.id, $0) })
}
private var selectedAddonItems: [(item: StoreAddonItem, quantity: Int)] {
selectedAddonQuantities
.compactMap { key, qty in
guard qty > 0, let item = addonItemsById[key] else { return nil }
return (item, qty)
}
.sorted { $0.item.name < $1.item.name }
}
private var addonsTotal: Double {
selectedAddonItems.reduce(0) { $0 + ($1.price ?? 0) }
selectedAddonItems.reduce(0) { partial, pair in
partial + (Double(pair.quantity) * (pair.item.price ?? 0))
}
}
private var unitPrice: Double {
@@ -30,16 +39,29 @@ struct ProductDetailSheet: View {
}
private var cartItemId: String {
let addonKey = selectedAddonItemIds.sorted().joined(separator: ",")
let addonKey = encodedAddonKey
return "\(storeId)::\(product.id)::\(addonKey)"
}
private var selectedAddonsSummary: String? {
let names = selectedAddonItems.map(\.name)
let names = selectedAddonItems.map { pair in
pair.quantity > 1 ? "\(pair.item.name) x\(pair.quantity)" : pair.item.name
}
if names.isEmpty { return nil }
return names.joined(separator: ", ")
}
private var selectedAddonsPayload: [CartItemAddonState] {
selectedAddonItems.map { pair in
CartItemAddonState(
id: pair.item.id,
name: pair.item.name,
quantity: pair.quantity,
unitPrice: pair.item.price ?? 0
)
}
}
private var addButtonTitle: String {
if quantity <= 0 {
return "Remover do carrinho"
@@ -72,6 +94,10 @@ struct ProductDetailSheet: View {
Text("Inclui adicionais: \(formatCurrency(addonsTotal))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
} else {
Text("Sem adicionais")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
if product.addonGroups.isEmpty == false {
@@ -86,28 +112,47 @@ struct ProductDetailSheet: View {
.foregroundStyle(AppColors.textPrimary)
ForEach(group.items) { item in
Button {
if selectedAddonItemIds.contains(item.id) {
selectedAddonItemIds.remove(item.id)
} else {
selectedAddonItemIds.insert(item.id)
}
} label: {
HStack {
Image(systemName: selectedAddonItemIds.contains(item.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(selectedAddonItemIds.contains(item.id) ? AppColors.primary : AppColors.textMuted)
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Spacer()
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
.font(AppTypography.body)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
Spacer()
HStack(spacing: 8) {
Button(action: { decrementAddon(item.id) }) {
Image(systemName: "minus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.brandSoft)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(quantity(forAddonId: item.id) <= 0 || quantity <= 0)
Text("\(quantity(forAddonId: item.id))")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.frame(minWidth: 18)
Button(action: { incrementAddon(item.id) }) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(AppColors.textPrimary)
.frame(width: 26, height: 26)
.background(AppColors.tertiary)
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(quantity <= 0)
}
}
.buttonStyle(.plain)
}
}
.padding(12)
@@ -160,7 +205,9 @@ struct ProductDetailSheet: View {
productId: product.id,
storeId: storeId,
name: product.name,
imageURL: imageURL,
details: selectedAddonsSummary,
addons: selectedAddonsPayload,
quantity: quantity,
unitPrice: unitPrice
)
@@ -183,14 +230,50 @@ struct ProductDetailSheet: View {
}
}
.onAppear {
quantity = currentQuantityForItemId(cartItemId)
let existing = currentQuantityForItemId(cartItemId)
quantity = existing > 0 ? existing : 1
}
.onChange(of: selectedAddonItemIds) { _, _ in
quantity = currentQuantityForItemId(cartItemId)
.onChange(of: selectedAddonQuantities) { _, _ in
// Keep the main quantity stable when changing addon quantities.
// Only hydrate from cart if this exact configuration already exists.
let existingQuantity = currentQuantityForItemId(cartItemId)
if existingQuantity > 0 {
quantity = existingQuantity
}
}
.onChange(of: quantity) { _, newValue in
if newValue <= 0 {
selectedAddonQuantities.removeAll()
}
}
}
private func formatCurrency(_ value: Double) -> String {
String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
private var encodedAddonKey: String {
let tokens = selectedAddonQuantities
.filter { $0.value > 0 }
.map { "\($0.key):\($0.value)" }
.sorted()
return tokens.isEmpty ? "base" : tokens.joined(separator: ",")
}
private func quantity(forAddonId addonId: String) -> Int {
selectedAddonQuantities[addonId] ?? 0
}
private func incrementAddon(_ addonId: String) {
selectedAddonQuantities[addonId, default: 0] += 1
}
private func decrementAddon(_ addonId: String) {
let current = selectedAddonQuantities[addonId] ?? 0
if current <= 1 {
selectedAddonQuantities.removeValue(forKey: addonId)
} else {
selectedAddonQuantities[addonId] = current - 1
}
}
}

View File

@@ -192,6 +192,8 @@ struct ProfileView: View {
private func logout() {
tokenStore.clear()
SessionStateStore.clearActiveUser()
AppContentCache.shared.invalidate()
AppImageCache.shared.invalidateAll()
appState = AppState()
root = .auth
}

View File

@@ -0,0 +1,32 @@
import Foundation
struct StoreCatalogListItem: Identifiable {
let id: String
let product: StoreCatalogProduct
let title: String
let description: String?
let imageURL: String?
let isPizzaSummary: Bool
let pizzaCategoryId: String?
let pizzaProductIds: [String]
init(
id: String,
product: StoreCatalogProduct,
title: String,
description: String?,
imageURL: String?,
isPizzaSummary: Bool = false,
pizzaCategoryId: String? = nil,
pizzaProductIds: [String] = []
) {
self.id = id
self.product = product
self.title = title
self.description = description
self.imageURL = imageURL
self.isPizzaSummary = isPizzaSummary
self.pizzaCategoryId = pizzaCategoryId
self.pizzaProductIds = pizzaProductIds
}
}

View File

@@ -134,22 +134,8 @@ struct AsyncStoreImage: View {
let imageURL: String?
var body: some View {
Group {
if let imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
fallback
}
}
} else {
fallback
}
CachedRemoteImage(imageURL: imageURL) {
fallback
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.brandSoft)

View File

@@ -56,11 +56,54 @@ extension StoreDetailView {
return "R$ --"
}
var isStoreOpen: Bool {
info?.isOpen ?? true
}
var summaryCardHeight: CGFloat {
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
}
var closedStoreBannerText: String {
let label = info?.statusLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if label.isEmpty {
return "Loja fechada • Consulte o horário de abertura"
}
let normalized = label.lowercased()
if normalized.hasPrefix("fechado") {
let cleaned = label.replacingOccurrences(of: "Fechado", with: "")
.replacingOccurrences(of: "fechado", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: " -:•"))
if cleaned.isEmpty == false {
return "Loja fechada • \(cleaned)"
}
}
return "Loja fechada • \(label)"
}
@MainActor
func loadStoreData() async {
func loadStoreData(forceRefresh: Bool = false) async {
isLoading = true
errorMessage = nil
let infoCacheKey = "store-info:\(storeId)"
let catalogCacheKey = "store-catalog:\(storeId)"
if forceRefresh == false,
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
info = cachedInfo
categories = cachedCatalog
selectedCategoryId = cachedCatalog.first?.id
isLoading = false
return
}
if forceRefresh {
AppContentCache.shared.invalidate(prefix: "store-info:\(storeId)")
AppContentCache.shared.invalidate(prefix: "store-catalog:\(storeId)")
}
do {
async let infoRequest = ApiService().storeInfo(storeId: storeId)
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
@@ -80,6 +123,10 @@ extension StoreDetailView {
info = infoResponse.result
categories = catalogResponse.result ?? []
selectedCategoryId = categories.first?.id
if let info = infoResponse.result {
AppContentCache.shared.set(info, for: infoCacheKey, ttl: 300)
}
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: 300)
isLoading = false
} catch {
isLoading = false
@@ -96,15 +143,16 @@ extension StoreDetailView {
}
func resolvedURL(_ raw: String?) -> String? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
raw.isEmpty == false else { return nil }
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
let lower = raw.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
return raw
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
@@ -113,6 +161,23 @@ extension StoreDetailView {
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
}
func listPriceLabel(for product: StoreCatalogProduct, in category: StoreCatalogCategory) -> String {
guard product.type?.lowercased() == "pizza", product.pizzaPrices.isEmpty == false else {
return formatCurrency(product.price)
}
if let firstSizeId = category.pizzaConfig?.sizes.first?.id,
let firstSizePrice = product.pizzaPrices[firstSizeId] {
return "A partir de \(formatCurrency(firstSizePrice))"
}
if let fallback = product.pizzaPrices.sorted(by: { $0.key < $1.key }).first?.value {
return "A partir de \(formatCurrency(fallback))"
}
return formatCurrency(product.price)
}
var topSectionHeight: CGFloat {
cardTopInset + summaryCardHeight
}
@@ -146,7 +211,53 @@ extension StoreDetailView {
.reduce(0) { $0 + $1.quantity }
}
func quantityInCart(for item: StoreCatalogListItem) -> Int {
if item.isPizzaSummary {
let ids = Set(item.pizzaProductIds)
return appState.cart.items
.filter { $0.storeId == storeId && ids.contains($0.productId) }
.reduce(0) { $0 + $1.quantity }
}
return quantityInCart(for: item.product.id)
}
func listItems(for category: StoreCatalogCategory) -> [StoreCatalogListItem] {
if category.isPizzaCategory {
guard let first = category.products.first else { return [] }
let representativeImage = category.products
.compactMap(\.image)
.first { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
return [
StoreCatalogListItem(
id: "\(category.id)::pizza-summary",
product: first,
title: "Pizza de varios sabores",
description: "Escolha o tamanho da sua fome",
imageURL: representativeImage ?? first.image,
isPizzaSummary: true,
pizzaCategoryId: category.id,
pizzaProductIds: category.products.map(\.id)
)
]
}
return category.products.map { product in
StoreCatalogListItem(
id: product.id,
product: product,
title: product.name,
description: product.description,
imageURL: product.image
)
}
}
func requestAddToCart(_ item: CartItemState) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .add
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = item
@@ -158,6 +269,10 @@ extension StoreDetailView {
}
func requestSetCartItem(_ item: CartItemState) {
guard isStoreOpen || item.quantity <= 0 else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .set
if shouldAskForStoreSwitch(for: storeId) && item.quantity > 0 {
pendingCartItem = item
@@ -169,6 +284,10 @@ extension StoreDetailView {
}
func requestOpenProductSheet(_ product: StoreCatalogProduct) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .openProductSheet
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = nil
@@ -179,6 +298,22 @@ extension StoreDetailView {
selectedProduct = product
}
func requestOpenPizzaSheet(categoryId: String) {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
pendingCartAction = .openPizzaSheet
if shouldAskForStoreSwitch(for: storeId) {
pendingCartItem = nil
pendingProductSheet = nil
pendingPizzaCategoryId = categoryId
showSwitchStoreAlert = true
return
}
selectedPizzaCategoryId = categoryId
}
func applyAddToCart(_ item: CartItemState) {
if appState.cart.storeId == nil {
appState.cart.storeId = storeId
@@ -224,4 +359,5 @@ enum CartAction {
case add
case set
case openProductSheet
case openPizzaSheet
}

View File

@@ -22,9 +22,11 @@ struct StoreDetailView: View {
@State var categories: [StoreCatalogCategory] = []
@State var selectedCategoryId: String? = nil
@State var selectedProduct: StoreCatalogProduct? = nil
@State var selectedPizzaCategoryId: String? = nil
@State var showSwitchStoreAlert = false
@State var pendingCartItem: CartItemState? = nil
@State var pendingProductSheet: StoreCatalogProduct? = nil
@State var pendingPizzaCategoryId: String? = nil
@State var pendingCartAction: CartAction = .add
@State var didLoad = false
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
@@ -32,7 +34,8 @@ struct StoreDetailView: View {
@State var scrollOffsetY: CGFloat = 0
let cardTopInset: CGFloat = 168
let summaryCardHeight: CGFloat = 170
let summaryCardBaseHeight: CGFloat = 170
let closedBannerHeight: CGFloat = 44
let coverVisibleUntilY: CGFloat = 253
let storeLogoSize: CGFloat = 84
@@ -53,6 +56,9 @@ struct StoreDetailView: View {
sectionedProducts
}
}
.refreshable {
await loadStoreData(forceRefresh: true)
}
.ignoresSafeArea(edges: .top)
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
}
@@ -64,6 +70,7 @@ struct StoreDetailView: View {
.zIndex(20)
}
}
.saturation(isStoreOpen ? 1 : 0)
}
}
.navigationBarBackButtonHidden(true)
@@ -72,7 +79,7 @@ struct StoreDetailView: View {
.task {
guard didLoad == false else { return }
didLoad = true
await loadStoreData()
await loadStoreData(forceRefresh: false)
}
.sheet(item: $selectedProduct) { product in
NavigationStack {
@@ -84,15 +91,52 @@ struct StoreDetailView: View {
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
}
.sheet(
isPresented: Binding(
get: { selectedPizzaCategoryId != nil },
set: { isPresented in
if isPresented == false {
selectedPizzaCategoryId = nil
}
}
)
) {
if let category = categories.first(where: { $0.id == selectedPizzaCategoryId }) {
NavigationStack {
PizzaProductDetailSheet(
category: category,
storeId: storeId,
resolveImageURL: { raw in resolvedURL(raw) },
currentQuantityForItemId: { itemId in
currentQuantity(forCartItemId: itemId)
},
onAdd: { item in
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
requestSetCartItem(item)
}
)
}
} else {
ProgressView()
}
}
.alert("Trocar de loja?", isPresented: $showSwitchStoreAlert) {
Button("Cancelar", role: .cancel) {
pendingCartItem = nil
pendingProductSheet = nil
pendingPizzaCategoryId = nil
}
Button("Limpar carrinho e adicionar", role: .destructive) {
appState.cart.clear()
@@ -106,9 +150,13 @@ struct StoreDetailView: View {
case .openProductSheet:
guard let pendingProductSheet else { return }
selectedProduct = pendingProductSheet
case .openPizzaSheet:
guard let pendingPizzaCategoryId else { return }
selectedPizzaCategoryId = pendingPizzaCategoryId
}
self.pendingCartItem = nil
self.pendingProductSheet = nil
self.pendingPizzaCategoryId = nil
}
} message: {
Text("Seu carrinho tem itens de outra loja. Deseja limpar o carrinho atual para adicionar este produto?")
@@ -193,34 +241,44 @@ struct StoreDetailView: View {
}
private var summaryCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(storeName)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
.padding(.top, 30)
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(storeName)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
.padding(.top, 30)
Text(storeSubtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
Text(storeSubtitle)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
}
Spacer()
ratingChip
}
Spacer()
ratingChip
HStack(spacing: 0) {
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
Divider().frame(height: 34)
statItem(title: "ENTREGA", value: deliveryValueLabel)
}
.padding(.vertical, 4)
}
.padding(16)
HStack(spacing: 0) {
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
Divider().frame(height: 34)
statItem(title: "ENTREGA", value: deliveryValueLabel)
if isStoreOpen == false {
Text(closedStoreBannerText)
.font(AppTypography.heading3)
.foregroundStyle(Color.white)
.frame(maxWidth: .infinity, minHeight: closedBannerHeight)
.background(AppColors.brandDark)
}
.padding(.vertical, 4)
}
.padding(16)
.frame(height: summaryCardHeight)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
@@ -256,8 +314,8 @@ struct StoreDetailView: View {
ForEach(categories, id: \.id) { category in
Section {
VStack(spacing: 12) {
ForEach(category.products) { product in
productCard(product)
ForEach(listItems(for: category)) { item in
productCard(item, in: category)
}
}
.padding(.horizontal, 16)
@@ -331,16 +389,19 @@ struct StoreDetailView: View {
max(8, safeTop - 44)
}
private func productCard(_ product: StoreCatalogProduct) -> some View {
HStack(spacing: 12) {
private func productCard(_ item: StoreCatalogListItem, in category: StoreCatalogCategory) -> some View {
let product = item.product
let hasSelectableAddons = product.addonGroups.contains { $0.items.isEmpty == false }
return HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(product.name)
Text(item.title)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
if let description = product.description, description.isEmpty == false {
if let description = item.description, description.isEmpty == false {
Text(description)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
@@ -348,7 +409,7 @@ struct StoreDetailView: View {
.multilineTextAlignment(.leading)
}
Text(formatCurrency(product.price))
Text(listPriceLabel(for: product, in: category))
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
@@ -356,18 +417,27 @@ struct StoreDetailView: View {
Spacer()
ZStack(alignment: .bottomTrailing) {
AsyncStoreImage(imageURL: resolvedURL(product.image))
AsyncStoreImage(imageURL: resolvedURL(item.imageURL))
.frame(width: 92, height: 92)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
Button {
if product.addonGroups.isEmpty {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
return
}
if hasSelectableAddons == false {
let basePrice = product.price ?? 0
let item = CartItemState(
id: "\(storeId)::\(product.id)::base",
productId: product.id,
storeId: storeId,
name: product.name,
imageURL: resolvedURL(product.image),
quantity: 1,
unitPrice: basePrice
)
@@ -384,7 +454,7 @@ struct StoreDetailView: View {
.background(AppColors.tertiary)
.clipShape(Circle())
let qty = quantityInCart(for: product.id)
let qty = quantityInCart(for: item)
if qty > 0 {
Text("\(qty)")
.font(.system(size: 10, weight: .bold))
@@ -399,14 +469,31 @@ struct StoreDetailView: View {
}
.offset(x: 3, y: -3)
.frame(width: 30, height: 30)
.contentShape(Circle())
}
.buttonStyle(.plain)
.frame(width: 30, height: 30)
.disabled(isStoreOpen == false)
.opacity(isStoreOpen ? 1 : 0.65)
.offset(x: 7, y: 7)
}
}
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.onTapGesture {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
return
}
if item.isPizzaSummary, let pizzaCategoryId = item.pizzaCategoryId {
requestOpenPizzaSheet(categoryId: pizzaCategoryId)
return
}
guard hasSelectableAddons else { return }
requestOpenProductSheet(product)
}
}
}