Payments
This commit is contained in:
@@ -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 {
|
||||
|
||||
41
pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift
Normal file
41
pedi-foods/Sources/PediFoods/Views/Main/CheckoutTypes.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
335
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift
Normal file
335
pedi-foods/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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: ",")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,8 @@ struct ProfileView: View {
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
appState = AppState()
|
||||
root = .auth
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user