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

View File

@@ -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)
}
}
}