From 56d6ff722364f44f90f6bc3d73c79736f22e043b Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Thu, 5 Mar 2026 10:05:48 -0300 Subject: [PATCH] feat(orders): separar pedidos de reviews e listar pendentes --- .../PediFoods/Views/Main/OrdersView.swift | 190 +- .../PediFoods/Views/Main/ReviewsView.swift | 1566 +++++++++++++++++ 2 files changed, 1736 insertions(+), 20 deletions(-) create mode 100644 pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 7dad9d5..f78c60d 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -5,6 +5,8 @@ struct OrdersView: View { @State var errorMessage: String? = nil @State var orders: [AppOrderSummary] = [] @State var hasLoadedOnce = false + @State var storeRatingByStoreId: [String: Double] = [:] + @State var storeRatingByStoreName: [String: Double] = [:] var body: some View { ScrollView(showsIndicators: false) { @@ -44,6 +46,9 @@ struct OrdersView: View { orderRow(order) } .buttonStyle(.plain) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } } } @@ -55,9 +60,11 @@ struct OrdersView: View { .navigationBarTitleDisplayMode(.inline) .task { await loadOrdersIfNeeded() + await refreshStoreRatings() } .refreshable { await loadOrders(force: true) + await refreshStoreRatings() } } @@ -72,13 +79,28 @@ struct OrdersView: View { ) VStack(alignment: .leading, spacing: 4) { - Text("Pedido #\(order.shortId ?? order.id)") + Text(order.storeName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (order.storeName ?? "Pedido") : "Pedido #\(order.shortId ?? order.id)") .font(AppTypography.heading3) .foregroundStyle(AppColors.textPrimary) - Text(humanReadableStatus(for: order)) - .font(AppTypography.caption) - .foregroundStyle(AppColors.textMuted) + HStack(spacing: 6) { + Text(humanReadableStatus(for: order)) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + if let rating = storeRating(for: order) { + Text("•") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Color(hex: "#7CF02A")) + Text(String(format: "%.1f", rating).replacingOccurrences(of: ".", with: ",")) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + } + } } Spacer() @@ -94,9 +116,6 @@ struct OrdersView: View { } } } - .padding(14) - .background(AppColors.surface) - .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } private func humanReadableStatus(for order: AppOrderSummary) -> String { @@ -172,6 +191,17 @@ struct OrdersView: View { return order.id } + private func normalizedOrderId(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private func normalizedStoreName(_ value: String?) -> String { + (value ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + @MainActor private func loadOrdersIfNeeded() async { guard hasLoadedOnce == false else { return } @@ -332,6 +362,48 @@ struct OrdersView: View { return error.localizedDescription.lowercased().contains("cancel") } + + @MainActor + private func refreshStoreRatings() async { + var storeList: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + + if storeList.isEmpty { + let response = try? await ApiService().listStores() + storeList = response?.result ?? [] + } + + var byId: [String: Double] = [:] + var byName: [String: Double] = [:] + for store in storeList { + guard let rating = store.rating, rating > 0 else { continue } + let storeId = normalizedOrderId(store.id) + if storeId.isEmpty == false { + byId[storeId] = rating + } + let nameKey = normalizedStoreName(store.name) + if nameKey.isEmpty == false { + byName[nameKey] = rating + } + } + + storeRatingByStoreId = byId + storeRatingByStoreName = byName + } + + private func storeRating(for order: AppOrderSummary) -> Double? { + let storeId = normalizedOrderId(order.storeId) + if storeId.isEmpty == false, let fromId = storeRatingByStoreId[storeId] { + return fromId + } + let nameKey = normalizedStoreName(order.storeName) + if nameKey.isEmpty == false, let fromName = storeRatingByStoreName[nameKey] { + return fromName + } + return nil + } } extension AppOrderSummary { @@ -340,6 +412,7 @@ extension AppOrderSummary { id: tracked.id, orderId: tracked.realId ?? tracked.id, realId: tracked.realId, + storeId: nil, shortId: tracked.shortId, total: tracked.total, status: tracked.status, @@ -350,6 +423,7 @@ extension AppOrderSummary { paymentMethod: tracked.paymentMethod, deliveryType: tracked.deliveryType, storeName: tracked.storeName, + storeLogoURL: tracked.storeLogoURL, createdAt: tracked.createdAt, updatedAt: tracked.updatedAt ) @@ -359,6 +433,7 @@ extension AppOrderSummary { id: String, orderId: String?, realId: String?, + storeId: String?, shortId: String?, total: Double?, status: String?, @@ -369,12 +444,14 @@ extension AppOrderSummary { paymentMethod: String?, deliveryType: String?, storeName: String?, + storeLogoURL: String?, createdAt: String?, updatedAt: String? ) { self.id = id self.orderId = orderId self.realId = realId + self.storeId = storeId self.shortId = shortId self.total = total self.status = status @@ -385,6 +462,7 @@ extension AppOrderSummary { self.paymentMethod = paymentMethod self.deliveryType = deliveryType self.storeName = storeName + self.storeLogoURL = storeLogoURL self.createdAt = createdAt self.updatedAt = updatedAt } @@ -453,15 +531,9 @@ struct OrderEntryDestinationView: View { let order = await fetchOrderForRouting() guard let order else { return } - guard order.isPaymentConfirmed == false else { return } - guard isOnlinePaymentMethod(order) else { return } + guard shouldOpenPaymentScreen(for: order) else { return } let normalizedMethod = normalizePaymentMethod(order) - let normalizedStatus = normalize(order.paymentStatus) - if normalizedStatus.contains("PENDING") == false && normalizedStatus.isEmpty == false { - return - } - if normalizedMethod.contains("PIX") { let pixFromPayment = order.payment?.pix let pixFromPayload = order.paymentPayload @@ -489,33 +561,111 @@ struct OrderEntryDestinationView: View { return } - if normalizedMethod.contains("CREDIT") || normalizedMethod.contains("CARD") { + if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" { cardContext = CardPaymentContext( orderId: order.id, shortId: order.shortId ?? initialShortId, total: order.total ?? fallbackTotal ?? 0 ) + return } } @MainActor func fetchOrderForRouting() async -> PublicOrderResult? { + logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)") do { let response = try await ApiService().publicOrder(orderId: orderId) if response.error == false, let result = response.result { - SessionStateStore.saveTrackedOrder(result) + logger.info("OrderEntry fetch route success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)") return result } + logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)") } catch { - // Fallback to local cache when remote call fails. + logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } - - return SessionStateStore.loadTrackedOrder(orderId: orderId) + return nil } func isOnlinePaymentMethod(_ order: PublicOrderResult) -> Bool { let method = normalizePaymentMethod(order) - return method.contains("PIX") || method.contains("CREDIT") || method.contains("CARD") + return method == "PIX" || method == "CREDIT_CARD" || method == "DEBIT_CARD" + } + + func shouldOpenPaymentScreen(for order: PublicOrderResult) -> Bool { + guard order.isPaymentConfirmed == false else { return false } + guard isOnlinePaymentMethod(order) else { return false } + guard isPaymentPending(order) else { return false } + guard isInStorePayment(order) == false else { return false } + + let status = normalize(order.status) + if status.contains("PREPAR") || + status.contains("READY") || + status.contains("DELIVER") || + status.contains("ROTA") || + status.contains("COMPLETED") || + status.contains("CANCEL") || + status.contains("REFUND") { + return false + } + + let paymentStatus = normalize(order.paymentStatus) + if paymentStatus.contains("CONFIRM") || + paymentStatus.contains("PAID") || + paymentStatus.contains("RECEIV") || + paymentStatus.contains("APPROV") { + return false + } + + let method = normalizePaymentMethod(order) + if method == "PIX" { + return hasPixPayload(order) + } + return method == "CREDIT_CARD" || method == "DEBIT_CARD" + } + + func hasPixPayload(_ order: PublicOrderResult) -> Bool { + let fromPayment = (order.payment?.pix?.copyPaste ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + if fromPayment.isEmpty == false { return true } + + let fromPayload = (order.paymentPayload?.copyPaste ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return fromPayload.isEmpty == false + } + + func isPaymentPending(_ order: PublicOrderResult) -> Bool { + let status = normalize(order.status) + let paymentStatus = normalize(order.paymentStatus) + let nextAction = normalize(order.nextAction) + + if status.contains("PAYMENT_PENDING") { + return true + } + if paymentStatus.contains("PENDING") { + return true + } + if nextAction.contains("PAY") || nextAction.contains("PAYMENT") { + return true + } + return false + } + + func isInStorePayment(_ order: PublicOrderResult) -> Bool { + let nextAction = normalize(order.nextAction) + if nextAction.contains("TRACK") || nextAction.contains("DELIVER") { + return true + } + + let status = normalize(order.status) + if status.contains("PREPAR") || + status.contains("READY") || + status.contains("DELIVER") || + status.contains("ROTA") || + status.contains("OUT_FOR_DELIVERY") { + return true + } + return false } func normalizePaymentMethod(_ order: PublicOrderResult) -> String { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift new file mode 100644 index 0000000..e606d3e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift @@ -0,0 +1,1566 @@ +import SwiftUI +#if os(iOS) +import LCEssentials +#endif + +struct ReviewDraft: Identifiable, Hashable { + var id: String { orderId } + let orderId: String + let storeId: String? + let shortId: String? + let storeName: String? + let storeLogoURL: String? + let createdAt: String? + let total: Double? +} + +struct MyReviewsView: View { + let initialOrder: ReviewDraft? + + @State var reviews: [OrderReviewRecord] = [] + @State var pendingReviews: [ReviewDraft] = [] + @State var selectedDraft: ReviewDraft? = nil + @State var didHandleInitialRoute = false + @State var isLoading = false + @State var loadError: String? = nil + + init(initialOrder: ReviewDraft? = nil) { + self.initialOrder = initialOrder + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 12) { + if isLoading && reviews.isEmpty && pendingReviews.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if let loadError, loadError.isEmpty == false { + Text(loadError) + .font(AppTypography.body) + .foregroundStyle(Color.red) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 24) + } else if reviews.isEmpty && pendingReviews.isEmpty { + emptyState + } else { + if pendingReviews.isEmpty == false { + sectionTitle("Pendentes de avaliação") + ForEach(pendingReviews) { draft in + pendingReviewRow(draft) + } + } + + if reviews.isEmpty == false { + sectionTitle("Avaliações enviadas") + ForEach(reviews) { review in + reviewRow(review) + } + } + + } + } + .padding(.horizontal, 20) + .padding(.top, 14) + .padding(.bottom, UIDevice.bottomNotch + 30) + } + .background(AppColors.backgroundLight) + .navigationTitle("Minhas Avaliações") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(item: $selectedDraft) { draft in + OrderReviewView(draft: draft) { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + } + .onAppear { + Task { await loadReviewsFromBackend(forceRefresh: true) } + } + .refreshable { + await loadReviewsFromBackend(forceRefresh: true) + } + .task { + guard didHandleInitialRoute == false else { return } + didHandleInitialRoute = true + if let initialOrder { selectedDraft = initialOrder } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Text("Você não tem avaliações nem pendências no momento.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .multilineTextAlignment(.center) + + if let initialOrder { + Button("Avaliar pedido #\(initialOrder.shortId ?? initialOrder.orderId)") { + selectedDraft = initialOrder + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 30) + } + + private func sectionTitle(_ value: String) -> some View { + Text(value) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 6) + } + + private func pendingReviewRow(_ draft: ReviewDraft) -> some View { + Button { + selectedDraft = draft + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(draft.storeLogoURL)) + .frame(width: 44, height: 44) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text(draft.storeName ?? "Pedido #\(draft.shortId ?? draft.orderId)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido #\(draft.shortId ?? draft.orderId)") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + + Text("Toque para avaliar") + .font(AppTypography.caption) + .foregroundStyle(AppColors.primary) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func reviewRow(_ review: OrderReviewRecord) -> some View { + let draft = ReviewDraft( + orderId: review.orderId, + storeId: review.storeId, + shortId: review.shortId, + storeName: review.storeName, + storeLogoURL: review.storeLogoURL, + createdAt: review.createdAt, + total: nil + ) + + return Button { + selectedDraft = draft + } label: { + HStack(spacing: 12) { + AsyncStoreImage(imageURL: resolvedMediaURL(review.storeLogoURL)) + .frame(width: 44, height: 44) + .clipShape(Circle()) + .background(AppColors.brandSoft, in: Circle()) + + VStack(alignment: .leading, spacing: 6) { + Text(review.storeName ?? "Pedido #\(review.shortId ?? review.orderId)") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 4) { + ForEach(0..<5, id: \.self) { index in + Image(systemName: index < review.rating ? "star.fill" : "star") + .foregroundStyle(index < review.rating ? Color(hex: "#7CF02A") : Color(hex: "#BFC7D4")) + } + } + + if review.comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + Text(review.comment) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .lineLimit(2) + } + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + .buttonStyle(.plain) + } + + @MainActor + private func loadReviewsFromBackend(forceRefresh: Bool) async { + if isLoading { return } + isLoading = true + loadError = nil + defer { isLoading = false } + + do { + let service = ApiService() + let ordersResponse = try await service.listOrders(forceRefresh: forceRefresh) + guard ordersResponse.error == false else { + loadError = ordersResponse.message ?? "Não foi possível carregar avaliações." + return + } + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } +#if os(iOS) + for store in stores { + printLog( + title: "LOGO REVIEW LISTA", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + var storeLogoById: [String: String] = [:] + var storeLogoByName: [String: String] = [:] + for store in stores { + let idKey = normalized(store.id) + let nameKey = normalizedStoreKey(store.name) + if idKey.isEmpty == false, let logo = store.logo, normalized(logo).isEmpty == false { + storeLogoById[idKey] = logo + } + if nameKey.isEmpty == false, let logo = store.logo, normalized(logo).isEmpty == false { + storeLogoByName[nameKey] = logo + } + } + + let orders = ordersResponse.result ?? [] + var orderById: [String: AppOrderSummary] = [:] + var storeIds: Set = [] + for order in orders { + let orderId = resolveOrderId(order) + if orderId.isEmpty == false { + orderById[orderId] = order + } + let storeId = normalized(order.storeId) + if storeId.isEmpty == false { + storeIds.insert(storeId) + } + } + + var remoteReviewsByOrderId: [String: OrderReviewRecord] = [:] + remoteReviewsByOrderId.reserveCapacity(orderById.count) + + for storeId in storeIds { + do { + let response = try await service.publicStoreReviews(storeId: storeId) + guard response.error == false else { continue } + let storeReviews = response.result?.reviews ?? [] + + for review in storeReviews { + let orderId = normalized(review.orderId) + guard orderId.isEmpty == false, let order = orderById[orderId] else { + continue + } + let reviewStoreId = normalized(review.storeId) + let resolvedStoreId = reviewStoreId.isEmpty ? storeId : reviewStoreId + let resolvedName = normalized(order.storeName).isEmpty ? nil : order.storeName + let resolvedLogo = firstNonEmpty([ + storeLogoById[resolvedStoreId], + storeLogoByName[normalizedStoreKey(resolvedName)], + storeLogoById[normalized(order.storeId)], + storeLogoByName[normalizedStoreKey(order.storeName)] + ]) + + remoteReviewsByOrderId[orderId] = OrderReviewRecord( + orderId: orderId, + storeId: resolvedStoreId, + shortId: order.shortId, + storeName: resolvedName, + storeLogoURL: resolvedLogo, + createdAt: order.createdAt, + submittedAt: review.date ?? ISO8601DateFormatter().string(from: Date()), + rating: review.orderRate ?? review.rate ?? 0, + comment: review.orderComment ?? review.message ?? "", + orderPositiveTags: review.orderPositiveTags, + orderImprovementTags: review.orderImprovementTags, + deliverySentiment: review.deliverySentiment, + deliveryPositiveTags: review.deliveryPositiveTags, + deliveryNegativeTags: review.deliveryNegativeTags, + appNps: review.appNps, + platform: review.platform, + editableUntil: review.editableUntil, + storeReplyUntil: review.storeReplyUntil, + reviewWindowExpiresAt: review.reviewWindowExpiresAt, + storeReplyMessage: review.storeReplyMessage, + storeReplyAt: review.storeReplyAt + ) + } + } catch { + continue + } + } + + var localReviewByOrderId: [String: OrderReviewRecord] = [:] + for item in SessionStateStore.loadOrderReviews() { + let key = normalized(item.orderId).lowercased() + if key.isEmpty == false { + localReviewByOrderId[key] = item + } + } + let reviewedKeys = Set(remoteReviewsByOrderId.keys.map { normalized($0).lowercased() }) + .union(localReviewByOrderId.keys) + + pendingReviews = orders + .filter { canReview($0) } + .compactMap { order in + let resolvedId = resolveOrderId(order) + let normalizedId = normalized(resolvedId).lowercased() + guard normalizedId.isEmpty == false, reviewedKeys.contains(normalizedId) == false else { + return nil + } + + let resolvedLogo = firstNonEmpty([ + storeLogoById[normalized(order.storeId)], + storeLogoByName[normalizedStoreKey(order.storeName)] + ]) + return ReviewDraft( + orderId: resolvedId, + storeId: order.storeId, + shortId: order.shortId, + storeName: order.storeName, + storeLogoURL: resolvedLogo, + createdAt: order.createdAt, + total: order.total + ) + } + .sorted { lhs, rhs in + parseDateForSort(lhs.createdAt) > parseDateForSort(rhs.createdAt) + } + + reviews = Array(remoteReviewsByOrderId.values).sorted { lhs, rhs in + parseDateForSort(lhs.submittedAt) > parseDateForSort(rhs.submittedAt) + } + } catch { + loadError = error.localizedDescription + } + } + + private func canReview(_ order: AppOrderSummary) -> Bool { + let status = (order.status ?? "").uppercased() + let detailed = (order.statusDetailed ?? "").uppercased() + if status.contains("COMPLETED") || status.contains("DELIVERED") { + return true + } + return detailed.contains("COMPLETED") || detailed.contains("DELIVERED") + } + + private func parseDateForSort(_ value: String?) -> Date { + let raw = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard raw.isEmpty == false else { return .distantPast } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: raw) { return date } + iso.formatOptions = [.withInternetDateTime] + if let date = iso.date(from: raw) { return date } + return .distantPast + } + + private func resolveOrderId(_ order: AppOrderSummary) -> String { + let orderId = normalized(order.orderId) + if orderId.isEmpty == false { return orderId } + let realId = normalized(order.realId) + if realId.isEmpty == false { return realId } + return normalized(order.id) + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizedStoreKey(_ value: String?) -> String { + normalized(value) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private func firstNonEmpty(_ values: [String?]) -> String? { + for value in values { + let trimmed = normalized(value) + if trimmed.isEmpty == false { + return trimmed + } + } + return nil + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } +} + +struct OrderReviewView: View { + let draft: ReviewDraft + var onSaved: (() -> Void)? = nil + + @Environment(\.dismiss) var dismiss + @State var rating: Int = 0 + @State var comment: String = "" + @State var existingReview: OrderReviewRecord? = nil + @State var isSubmitting = false + @State var isLoadingTags = false + @State var tagsCatalog: ReviewTagsCatalog? = nil + @State var selectedOrderPositiveTags: Set = [] + @State var selectedOrderImprovementTags: Set = [] + @State var deliverySentiment: String = "" + @State var selectedDeliveryPositiveTags: Set = [] + @State var selectedDeliveryNegativeTags: Set = [] + @State var appNps: Int = -1 + @State var homeStoreLogoURL: String? = nil + @State var orderItems: [PublicOrderItem] = [] + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + topSection + orderItemsSection + if existingReview != nil { + existingReviewContent + } else { + newReviewContent + } + if existingReview == nil || canEditExistingReviewStars { + submitButton + } + } + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, UIDevice.bottomNotch + 140) + } + .background(AppColors.backgroundLight) + .navigationTitle("Avaliar Pedido") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) { + existingReview = cachedReview + rating = cachedReview.rating + comment = cachedReview.comment + selectedOrderPositiveTags = Set(cachedReview.orderPositiveTags ?? []) + selectedOrderImprovementTags = Set(cachedReview.orderImprovementTags ?? []) + deliverySentiment = normalizedSentiment(cachedReview.deliverySentiment) + selectedDeliveryPositiveTags = Set(cachedReview.deliveryPositiveTags ?? []) + selectedDeliveryNegativeTags = Set(cachedReview.deliveryNegativeTags ?? []) + if let savedNps = cachedReview.appNps { + appNps = savedNps + } + } + if let savedDraft = SessionStateStore.loadOrderReviewDraft(orderId: draft.orderId) { + rating = savedDraft.orderRate + comment = savedDraft.orderComment + selectedOrderPositiveTags = Set(savedDraft.orderPositiveTags) + selectedOrderImprovementTags = Set(savedDraft.orderImprovementTags) + deliverySentiment = normalizedSentiment(savedDraft.deliverySentiment) + selectedDeliveryPositiveTags = Set(savedDraft.deliveryPositiveTags) + selectedDeliveryNegativeTags = Set(savedDraft.deliveryNegativeTags) + appNps = savedDraft.appNps + } + } + .task { + await preloadStoreLogoFromHomeStores() + await preloadOrderItems() + await loadTagsCatalog() + await syncReviewFromBackend() + } + .onChange(of: rating) { _, newValue in + enforceOrderTagRules() + } + } + + private var newReviewContent: some View { + VStack(alignment: .leading, spacing: 24) { + starsSection + orderTagsSection + if rating > 0 { + commentSection + } + deliverySection + npsSection + } + } + + private var existingReviewContent: some View { + ExistingReviewReadOnlyView( + likedText: selectedOrderPositiveTags.isEmpty ? nil : joinedSelectedTags(selectedOrderPositiveTags, catalog: orderPositiveTags), + improvementText: selectedOrderImprovementTags.isEmpty ? nil : joinedSelectedTags(selectedOrderImprovementTags, catalog: orderImprovementTags), + comment: comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "-" : comment, + storeReply: normalized(existingReview?.storeReplyMessage), + storeReplyDate: formatReplyDate(existingReview?.storeReplyAt), + deliveryTitle: deliverySentimentTitle, + deliveryTagsTitle: deliveryTagsSummaryTitle, + deliveryTagsText: deliveryTagsSummaryText, + rating: rating, + canEditStars: canEditExistingReviewStars + ) { value in + rating = value + } + } + + private var topSection: some View { + let resolvedLogoURL = firstNonEmpty([homeStoreLogoURL, existingReview?.storeLogoURL]) + let resolvedStoreName = normalized(existingReview?.storeName).isEmpty ? draft.storeName : existingReview?.storeName + return VStack(spacing: 10) { + Circle() + .fill(Color(hex: "#C9EDB2")) + .frame(width: 100, height: 100) + .overlay( + AsyncStoreImage(imageURL: resolvedMediaURL(resolvedLogoURL)) + .frame(width: 94, height: 94) + .clipShape(Circle()) + ) + + Text(resolvedStoreName ?? "Pedido") + .font(AppTypography.heading1) + .foregroundStyle(AppColors.textPrimary) + + Text("Pedido #\(draft.shortId ?? draft.orderId) • \(orderDateLabel)") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + .frame(maxWidth: .infinity) + } + + private var starsSection: some View { + VStack(spacing: 12) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("O que você achou do pedido?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Escolha de 1 a 5 estrelas para classificar.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + if canEditRating { + ForEach(1...5, id: \.self) { value in + Button { + rating = value + } label: { + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + .buttonStyle(.plain) + } + } else { + ForEach(1...5, id: \.self) { value in + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 2) + } + + private var orderItemsSection: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(orderItems) { item in + HStack(alignment: .top, spacing: 8) { + let qty = max(1, item.qty ?? 1) + Text("\(qty)x \(item.name ?? "Item")") + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .lineLimit(2) + + Spacer(minLength: 8) + + if let price = item.price { + Text(formatCurrency(price)) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + } + } + .padding(.vertical, 3) + } + } + } + + private var orderTagsSection: some View { + VStack(alignment: .leading, spacing: 10) { + if isLoadingTags { + ProgressView() + .frame(maxWidth: .infinity, alignment: .leading) + } else { + if isOrderPositiveTagsEnabled { + Text("Do que você gostou?") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + tagCloud( + tags: orderPositiveTags, + selected: selectedOrderPositiveTags + ) { tag in + toggleTag(tag, in: &selectedOrderPositiveTags) + } + } + + if isOrderImprovementTagsEnabled { + Text("O que pode melhorar?") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + tagCloud( + tags: orderImprovementTags, + selected: selectedOrderImprovementTags + ) { tag in + toggleTag(tag, in: &selectedOrderImprovementTags) + } + } + } + } + } + + private var deliverySection: some View { + VStack(alignment: .leading, spacing: 10) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Você gostou da entrega?") + .font(AppTypography.heading2) + .foregroundStyle(AppColors.textPrimary) + + Text("Conte-nos se gostou ou não.") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + + HStack(spacing: 10) { + sentimentButton(id: "positive", title: "Sim, gostei") + sentimentButton(id: "negative", title: "Não, poderia melhorar") + } + + if deliverySentiment == "positive" { + tagCloud(tags: deliveryPositiveTags, selected: selectedDeliveryPositiveTags) { tag in + toggleTag(tag, in: &selectedDeliveryPositiveTags) + } + } else if deliverySentiment == "negative" { + tagCloud(tags: deliveryNegativeTags, selected: selectedDeliveryNegativeTags) { tag in + toggleTag(tag, in: &selectedDeliveryNegativeTags) + } + } + } + } + + private var npsSection: some View { + VStack(alignment: .leading, spacing: 10) { + requiredBadge + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Avalie o Pedi Foods também") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + Text("Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + + HStack(spacing: 4) { + ForEach(npsRange, id: \.self) { value in + Button { + appNps = value + } label: { + Text("\(value)") + .font(AppTypography.caption) + .foregroundStyle(appNps == value ? Color.white : AppColors.textPrimary) + .frame(width: 28, height: 28) + .background(appNps == value ? AppColors.primary : AppColors.surface) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } + } + } + + private var deliveryTagsSummaryTitle: String? { + if deliverySentiment == "positive", selectedDeliveryPositiveTags.isEmpty == false { + return "Pontos positivos da entrega" + } + if deliverySentiment == "negative", selectedDeliveryNegativeTags.isEmpty == false { + return "Pontos a melhorar na entrega" + } + return nil + } + + private var deliveryTagsSummaryText: String? { + if deliverySentiment == "positive", selectedDeliveryPositiveTags.isEmpty == false { + return joinedSelectedTags(selectedDeliveryPositiveTags, catalog: deliveryPositiveTags) + } + if deliverySentiment == "negative", selectedDeliveryNegativeTags.isEmpty == false { + return joinedSelectedTags(selectedDeliveryNegativeTags, catalog: deliveryNegativeTags) + } + return nil + } + + private var canEditRating: Bool { + if existingReview == nil { return true } + return canEditExistingReviewStars + } + + private var canEditExistingReviewStars: Bool { + guard let review = existingReview else { return false } + let candidateDates = [review.editableUntil, review.storeReplyUntil] + .compactMap(parseISODate) + guard let endDate = candidateDates.max() else { return false } + return Date() <= endDate + } + + private var deliverySentimentTitle: String { + if deliverySentiment == "positive" { return "Sim, gostei" } + if deliverySentiment == "negative" { return "Não, poderia melhorar" } + return "Não informado" + } + + private func joinedSelectedTags(_ selected: Set, catalog: [ReviewTagItem]) -> String { + if selected.isEmpty { return "-" } + let labels = selected.map { token in + canonicalTagLabel(for: token, in: catalog) ?? token + } + return labels.sorted().joined(separator: ", ") + } + + private func canonicalTagLabel(for raw: String, in tags: [ReviewTagItem]) -> String? { + let target = normalizeTagToken(raw) + return tags.first { item in + normalizeTagToken(item.id) == target || normalizeTagToken(item.label) == target + }?.label + } + + private func parseISODate(_ raw: String?) -> Date? { + let value = normalized(raw) + guard value.isEmpty == false else { return nil } + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = iso.date(from: value) { return date } + iso.formatOptions = [.withInternetDateTime] + return iso.date(from: value) + } + + private func formatReplyDate(_ raw: String?) -> String? { + guard let date = parseISODate(raw) else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM 'às' HH:mm" + return formatter.string(from: date) + } + + private var requiredBadge: some View { + Text("Obrigatório") + .font(AppTypography.overline) + .foregroundStyle(AppColors.textMuted) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(AppColors.surface) + .clipShape(Capsule()) + .overlay( + Capsule().stroke(AppColors.secondary.opacity(0.2), lineWidth: 1) + ) + } + + private var commentSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Comentário") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if existingReview != nil { + Text(comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "-" : comment) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } else { + TextEditor(text: $comment) + .frame(height: 110) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .scrollContentBackground(.hidden) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(AppColors.secondary.opacity(0.18), lineWidth: 1) + ) + .overlay(alignment: .topLeading) { + if comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(rating > 4 ? "O que você achou do pedido? Conte-nos sua experiência..." : "O que podemos melhorar? Conte-nos sua experiência...") + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted.opacity(0.9)) + .padding(.horizontal, 14) + .padding(.vertical, 16) + } + } + } + } + } + + private func formatCurrency(_ value: Double) -> String { + String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",") + } + + private var photosSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Adicionar fotos") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + HStack(spacing: 12) { + Button { + SnackbarCenter.shared.show( + title: "Upload de imagem para review ainda não está disponível na API Mobile.", + style: .info, + icon: "photo.on.rectangle", + duration: 3.0 + ) + } label: { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(style: StrokeStyle(lineWidth: 2, dash: [6, 6])) + .foregroundStyle(Color(hex: "#D4DAE3")) + .frame(width: 86, height: 86) + .overlay( + VStack(spacing: 2) { + Image(systemName: "camera.fill") + .foregroundStyle(AppColors.textMuted) + Text("UPLOAD") + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + ) + } + .buttonStyle(.plain) + } + } + } + + private var submitButton: some View { + Button(existingReview == nil ? "Enviar Avaliação" : "Reavaliar") { + Task { await submitReview() } + } + .font(AppTypography.button) + .foregroundStyle(AppColors.textInverse) + .frame(maxWidth: .infinity, minHeight: 56) + .background(AppColors.primary) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .buttonStyle(.plain) + .disabled(canSubmitReview == false || isSubmitting) + .opacity((canSubmitReview == false || isSubmitting) ? 0.55 : 1.0) + } + + private var canSubmitReview: Bool { + let hasRequiredNps = existingReview == nil ? (appNps >= npsMin && appNps <= npsMax) : true + let canEditExisting = existingReview == nil || canEditExistingReviewStars + return rating > 0 && deliverySentiment.isEmpty == false && hasRequiredNps && canEditExisting + } + + private var orderDateLabel: String { + guard let raw = draft.createdAt?.trimmingCharacters(in: .whitespacesAndNewlines), raw.isEmpty == false else { + return "sem data" + } + + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = iso.date(from: raw) + if date == nil { + iso.formatOptions = [.withInternetDateTime] + date = iso.date(from: raw) + } + guard let date else { return raw } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "pt_BR") + formatter.dateFormat = "dd/MM 'às' HH:mm" + return formatter.string(from: date) + } + + @MainActor + private func submitReview() async { + if canSubmitReview == false { + SnackbarCenter.shared.show(title: "Preencha nota, sentimento de entrega e NPS.", style: .warning, icon: "star", duration: 2.0) + return + } + + let cleanMessage = comment.trimmingCharacters(in: .whitespacesAndNewlines) + isSubmitting = true + defer { isSubmitting = false } + + do { + let payloadNps: Int + if appNps >= npsMin && appNps <= npsMax { + payloadNps = appNps + } else if let cachedNps = SessionStateStore.loadOrderReview(orderId: draft.orderId)?.appNps, + cachedNps >= npsMin && cachedNps <= npsMax { + payloadNps = cachedNps + } else { + payloadNps = npsMin + } + + let payload = SubmitOrderReviewPayload( + rate: rating, + message: cleanMessage, + orderRate: rating, + orderComment: cleanMessage, + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: payloadNps, + platform: selectedPlatform + ) + let response = try await ApiService().submitOrderReview(orderId: draft.orderId, payload: payload) + if response.error { + persistDraft() + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível enviar a avaliação.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + return + } + + let nowISO = ISO8601DateFormatter().string(from: Date()) + let submittedRecord = OrderReviewRecord( + orderId: normalized(draft.orderId).isEmpty ? draft.orderId : normalized(draft.orderId), + storeId: draft.storeId, + shortId: draft.shortId, + storeName: draft.storeName, + storeLogoURL: homeStoreLogoURL, + createdAt: draft.createdAt, + submittedAt: nowISO, + rating: rating, + comment: cleanMessage, + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: payloadNps, + platform: selectedPlatform, + editableUntil: nil, + storeReplyUntil: nil, + reviewWindowExpiresAt: nil, + storeReplyMessage: nil, + storeReplyAt: nil + ) + SessionStateStore.saveOrderReview(submittedRecord) + + await syncReviewFromBackend() + SessionStateStore.clearOrderReviewDraft(orderId: draft.orderId) + NotificationCenter.default.post(name: .orderReviewDidSave, object: nil, userInfo: ["orderId": draft.orderId]) + onSaved?() + SnackbarCenter.shared.show(title: "Avaliação enviada com sucesso.", style: .success, icon: "checkmark.circle.fill", duration: 2.0) + dismiss() + } catch { + let message: String + if let serviceError = error as? ApiServiceError { + message = serviceError.localizedDescription + } else if let networkError = error as? NetworkError { + message = networkError.localizedDescription + } else { + message = "Não foi possível enviar a avaliação." + } + persistDraft() + SnackbarCenter.shared.show( + title: message, + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func persistDraft() { + if canSubmitReview == false { + return + } + SessionStateStore.saveOrderReviewDraft( + OrderReviewDraftState( + orderId: draft.orderId, + orderRate: rating, + orderComment: comment.trimmingCharacters(in: .whitespacesAndNewlines), + orderPositiveTags: Array(selectedOrderPositiveTags).sorted(), + orderImprovementTags: Array(selectedOrderImprovementTags).sorted(), + deliverySentiment: deliverySentiment, + deliveryPositiveTags: Array(selectedDeliveryPositiveTags).sorted(), + deliveryNegativeTags: Array(selectedDeliveryNegativeTags).sorted(), + appNps: appNps, + platform: selectedPlatform + ) + ) + } + + @MainActor + private func loadTagsCatalog() async { + if isLoadingTags { return } + isLoadingTags = true + defer { isLoadingTags = false } + + do { + let response = try await ApiService().reviewTagsCatalog() + if response.error == false, let result = response.result { + tagsCatalog = result + remapSelectionsToCatalog() + enforceOrderTagRules() + enforceDeliveryTagRules() + } else { + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível carregar as tags de avaliação.", + style: .warning, + icon: "tag", + duration: 2.5 + ) + } + } catch { + SnackbarCenter.shared.show( + title: "Não foi possível carregar as tags de avaliação.", + style: .warning, + icon: "tag", + duration: 2.5 + ) + } + } + + private var orderPositiveTags: [ReviewTagItem] { + tagsCatalog?.order?.positive ?? [] + } + + private var orderImprovementTags: [ReviewTagItem] { + tagsCatalog?.order?.improvement ?? [] + } + + private var deliveryPositiveTags: [ReviewTagItem] { + tagsCatalog?.delivery?.positive ?? [] + } + + private var deliveryNegativeTags: [ReviewTagItem] { + tagsCatalog?.delivery?.negative ?? [] + } + + private var positiveAllowedThreshold: Int { + tagsCatalog?.order?.rules?.positiveAllowedWhenRateGte ?? 5 + } + + private var improvementAllowedThreshold: Int { + tagsCatalog?.order?.rules?.improvementAllowedWhenRateLte ?? 4 + } + + private var isOrderPositiveTagsEnabled: Bool { + rating > 0 && rating >= positiveAllowedThreshold + } + + private var isOrderImprovementTagsEnabled: Bool { + rating > 0 && rating <= improvementAllowedThreshold + } + + private var npsMin: Int { + tagsCatalog?.app?.nps?.min ?? 0 + } + + private var npsMax: Int { + tagsCatalog?.app?.nps?.max ?? 10 + } + + private var npsRange: [Int] { + if npsMin <= npsMax { + return Array(npsMin...npsMax) + } + return Array(0...10) + } + + private var selectedPlatform: String { + let current = ReviewPlatform.current.rawValue + let allowed = (tagsCatalog?.app?.platforms ?? []).map { $0.lowercased() } + if allowed.isEmpty || allowed.contains(current) { + return current + } + return allowed.first ?? current + } + + private func enforceOrderTagRules() { + if isOrderPositiveTagsEnabled == false { + selectedOrderPositiveTags.removeAll() + } + if isOrderImprovementTagsEnabled == false { + selectedOrderImprovementTags.removeAll() + } + } + + private func enforceDeliveryTagRules() { + if deliverySentiment != "positive" { + selectedDeliveryPositiveTags.removeAll() + } + if deliverySentiment != "negative" { + selectedDeliveryNegativeTags.removeAll() + } + } + + private func sentimentButton(id: String, title: String) -> some View { + Button { + deliverySentiment = id + enforceDeliveryTagRules() + } label: { + Text(title) + .font(AppTypography.button) + .foregroundStyle(deliverySentiment == id ? Color.white : AppColors.textPrimary) + .frame(maxWidth: .infinity, minHeight: 44) + .background(deliverySentiment == id ? AppColors.primary : AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous) + .stroke(AppColors.secondary.opacity(deliverySentiment == id ? 0 : 0.25), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func tagCloud(tags: [ReviewTagItem], selected: Set, onTap: @escaping (ReviewTagItem) -> Void) -> some View { + let columns = [GridItem(.adaptive(minimum: 120), spacing: 8)] + return AnyView( + LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + ForEach(tags) { tag in + let isSelected = containsSelectedTag(selected, tag: tag) + Button { + onTap(tag) + } label: { + Text(tag.label) + .font(AppTypography.caption) + .foregroundStyle(isSelected ? AppColors.textInverse : AppColors.textPrimary) + .frame(maxWidth: .infinity) + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(isSelected ? AppColors.primary : AppColors.surface) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(AppColors.secondary.opacity(isSelected ? 0 : 0.2), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + ) + } + + private func toggleTag(_ tag: ReviewTagItem, in set: inout Set) { + let normalizedId = normalizeTagToken(tag.id) + let normalizedLabel = normalizeTagToken(tag.label) + let hasEquivalent = set.contains { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue == normalizedId || normalizedValue == normalizedLabel + } + + set = Set(set.filter { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue != normalizedId && normalizedValue != normalizedLabel + }) + + if hasEquivalent == false { + set.insert(tag.id) + } + } + + @MainActor + private func syncReviewFromBackend() async { + do { + let service = ApiService() + var storeId = normalized(draft.storeId) + var orderName = normalized(draft.storeName) + if storeId.isEmpty { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + storeId = normalized(order.storeId) + if orderName.isEmpty { + orderName = normalized(order.storeName) + } + } + } + guard storeId.isEmpty == false else { return } + + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } + let storeLogoFromList = stores.first(where: { normalized($0.id) == storeId })?.logo + ?? stores.first(where: { normalizedStoreKey($0.name) == normalizedStoreKey(orderName) })?.logo + let storeNameFromList = stores.first(where: { normalized($0.id) == storeId })?.name + homeStoreLogoURL = firstNonEmpty([storeLogoFromList, homeStoreLogoURL]) + + let response = try await service.publicStoreReviews(storeId: storeId) + guard response.error == false else { return } + let remoteReview = response.result?.reviews.first { review in + normalized(review.orderId) == normalized(draft.orderId) + } + guard let remoteReview else { return } + + let cachedReviewNps = SessionStateStore.loadOrderReview(orderId: draft.orderId)?.appNps + let localNps = (appNps >= npsMin && appNps <= npsMax) ? appNps : nil + let resolvedNps = remoteReview.appNps ?? cachedReviewNps ?? existingReview?.appNps ?? localNps + + let record = OrderReviewRecord( + orderId: normalized(remoteReview.orderId).isEmpty ? draft.orderId : normalized(remoteReview.orderId), + storeId: storeId, + shortId: draft.shortId, + storeName: firstNonEmpty([orderName, storeNameFromList, draft.storeName]), + storeLogoURL: firstNonEmpty([storeLogoFromList, homeStoreLogoURL]), + createdAt: draft.createdAt, + submittedAt: remoteReview.date ?? ISO8601DateFormatter().string(from: Date()), + rating: remoteReview.orderRate ?? remoteReview.rate ?? 0, + comment: remoteReview.orderComment ?? remoteReview.message ?? "", + orderPositiveTags: remoteReview.orderPositiveTags, + orderImprovementTags: remoteReview.orderImprovementTags, + deliverySentiment: remoteReview.deliverySentiment, + deliveryPositiveTags: remoteReview.deliveryPositiveTags, + deliveryNegativeTags: remoteReview.deliveryNegativeTags, + appNps: resolvedNps, + platform: remoteReview.platform, + editableUntil: remoteReview.editableUntil, + storeReplyUntil: remoteReview.storeReplyUntil, + reviewWindowExpiresAt: remoteReview.reviewWindowExpiresAt, + storeReplyMessage: remoteReview.storeReplyMessage, + storeReplyAt: remoteReview.storeReplyAt + ) + + SessionStateStore.saveOrderReview(record) + existingReview = record + rating = record.rating + comment = record.comment + selectedOrderPositiveTags = Set(record.orderPositiveTags ?? []) + selectedOrderImprovementTags = Set(record.orderImprovementTags ?? []) + deliverySentiment = normalizedSentiment(record.deliverySentiment) + selectedDeliveryPositiveTags = Set(record.deliveryPositiveTags ?? []) + selectedDeliveryNegativeTags = Set(record.deliveryNegativeTags ?? []) + if let savedNps = record.appNps { + appNps = savedNps + } + } catch { + // Keep local state when sync fails. + } + } + + @MainActor + private func preloadOrderItems() async { + do { + let service = ApiService() + let orderIds = [ + normalized(draft.orderId), + normalized(draft.shortId) + ].filter { $0.isEmpty == false } + + var orderResult: PublicOrderResult? = nil + for candidate in orderIds { + let response = try? await service.publicOrder(orderId: candidate) + if response?.error == false, let result = response?.result { + orderResult = result + break + } + } + + if orderResult == nil { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + let fallbackCandidates = [ + normalized(order.orderId), + normalized(order.realId), + normalized(order.id) + ].filter { $0.isEmpty == false } + for candidate in fallbackCandidates { + let response = try? await service.publicOrder(orderId: candidate) + if response?.error == false, let result = response?.result { + orderResult = result + break + } + } + } + } + + orderItems = orderResult?.items ?? [] + } catch { + orderItems = [] + } + } + + @MainActor + private func preloadStoreLogoFromHomeStores() async { + do { + let service = ApiService() + var stores: [StoreSummary] = AppContentCache.shared.value( + for: AppCacheKey.homeStoresLatestSnapshot, + as: [StoreSummary].self + ) ?? [] + if stores.isEmpty { + let storesResponse = try? await service.listStores() + stores = storesResponse?.result ?? [] + } +#if os(iOS) + for store in stores { + printLog( + title: "LOGO REVIEW DETALHE", + msg: "storeId=\(store.id) | storeName=\(store.name) | logoRaw=\(store.logo ?? "nil")" + ) + } +#endif + guard stores.isEmpty == false else { return } + + var logoById: [String: String] = [:] + var logoByName: [String: String] = [:] + for store in stores { + let normalizedId = normalized(store.id) + let normalizedName = normalizedStoreKey(store.name) + let normalizedLogo = normalized(store.logo) + guard normalizedLogo.isEmpty == false else { continue } + if normalizedId.isEmpty == false { + logoById[normalizedId] = normalizedLogo + } + if normalizedName.isEmpty == false { + logoByName[normalizedName] = normalizedLogo + } + } + + var resolvedLogo = firstNonEmpty([ + logoById[normalized(draft.storeId)], + logoByName[normalizedStoreKey(draft.storeName)] + ]) + + if resolvedLogo == nil { + let ordersResponse = try? await service.listOrders(forceRefresh: true) + if let order = ordersResponse?.result?.first(where: { + normalized($0.orderId) == normalized(draft.orderId) + || normalized($0.id) == normalized(draft.orderId) + || normalized($0.realId) == normalized(draft.orderId) + || normalized($0.shortId) == normalized(draft.shortId) + }) { + resolvedLogo = firstNonEmpty([ + logoById[normalized(order.storeId)], + logoByName[normalizedStoreKey(order.storeName)] + ]) + } + } + + if let resolvedLogo { + homeStoreLogoURL = resolvedLogo + } + } catch { + // Keep placeholder when preload fails. + } + } + + private func normalizedSentiment(_ raw: String?) -> String { + let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if value == "positive" || value == "negative" { + return value + } + return "" + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizedStoreKey(_ value: String?) -> String { + normalized(value) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private func firstNonEmpty(_ values: [String?]) -> String? { + for value in values { + let trimmed = normalized(value) + if trimmed.isEmpty == false { + return trimmed + } + } + return nil + } + + private func resolvedMediaURL(_ raw: String?) -> String? { + ImageSourceResolver.resolve(raw) + } + + private func remapSelectionsToCatalog() { + selectedOrderPositiveTags = remapSelection(selectedOrderPositiveTags, tags: orderPositiveTags) + selectedOrderImprovementTags = remapSelection(selectedOrderImprovementTags, tags: orderImprovementTags) + selectedDeliveryPositiveTags = remapSelection(selectedDeliveryPositiveTags, tags: deliveryPositiveTags) + selectedDeliveryNegativeTags = remapSelection(selectedDeliveryNegativeTags, tags: deliveryNegativeTags) + } + + private func remapSelection(_ selection: Set, tags: [ReviewTagItem]) -> Set { + if selection.isEmpty || tags.isEmpty { return selection } + var mapped: Set = [] + for raw in selection { + if let canonical = canonicalTagId(for: raw, in: tags) { + mapped.insert(canonical) + } else { + mapped.insert(raw) + } + } + return mapped + } + + private func canonicalTagId(for raw: String, in tags: [ReviewTagItem]) -> String? { + let target = normalizeTagToken(raw) + return tags.first { item in + normalizeTagToken(item.id) == target || normalizeTagToken(item.label) == target + }?.id + } + + private func containsSelectedTag(_ selected: Set, tag: ReviewTagItem) -> Bool { + let normalizedId = normalizeTagToken(tag.id) + let normalizedLabel = normalizeTagToken(tag.label) + return selected.contains { value in + let normalizedValue = normalizeTagToken(value) + return normalizedValue == normalizedId || normalizedValue == normalizedLabel + } + } + + private func normalizeTagToken(_ value: String) -> String { + value + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct ExistingReviewReadOnlyView: View { + let likedText: String? + let improvementText: String? + let comment: String + let storeReply: String? + let storeReplyDate: String? + let deliveryTitle: String + let deliveryTagsTitle: String? + let deliveryTagsText: String? + let rating: Int + let canEditStars: Bool + let onSelectStar: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + if let likedText, likedText.isEmpty == false { + summaryBlock(title: "Do que você gostou no pedido", text: likedText) + } + if let improvementText, improvementText.isEmpty == false { + summaryBlock(title: "O que você sugeriu melhorar", text: improvementText) + } + + summaryBlock(title: "Como foi a entrega", text: deliveryTitle) + if let deliveryTagsTitle, let deliveryTagsText, deliveryTagsText.isEmpty == false { + summaryBlock(title: deliveryTagsTitle, text: deliveryTagsText) + } + + summaryCard(title: "Comentário", text: comment) + + if let storeReply, storeReply.isEmpty == false { + summaryCard( + title: "Resposta da loja", + subtitle: storeReplyDate, + text: storeReply + ) + } + + VStack(alignment: .leading, spacing: 10) { + Text("Nota do pedido") + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + HStack(spacing: 10) { + if canEditStars { + ForEach(1...5, id: \.self) { value in + Button { + onSelectStar(value) + } label: { + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + .buttonStyle(.plain) + } + } else { + ForEach(1...5, id: \.self) { value in + Image(systemName: value <= rating ? "star.fill" : "star") + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(value <= rating ? Color(hex: "#7CF02A") : Color(hex: "#C5CBD4")) + } + } + } + } + } + } + + private func summaryBlock(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textMuted) + Text(text) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + } + } + + private func summaryCard(title: String, subtitle: String? = nil, text: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textPrimary) + + if let subtitle, subtitle.isEmpty == false { + Text(subtitle) + .font(AppTypography.caption) + .foregroundStyle(AppColors.textMuted) + } + + Text(text) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } +}