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 @Environment(\.dismiss) var dismiss init(initialOrder: ReviewDraft? = nil) { self.initialOrder = initialOrder } var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: 12) { screenHeader 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) .appHiddenNavigationBar() .navigationBarBackButtonHidden(true) .navigationDestination(item: $selectedDraft) { draft in OrderReviewView(draft: draft) { Task { await loadReviewsFromBackend(forceRefresh: true) } } } .onAppear { Task { await loadReviewsFromBackend(forceRefresh: true) } } .refreshable { // Decoupled from .refreshable's own cancellable wrapping Task — // see StoreDetailView's .refreshable for why. await Task { await loadReviewsFromBackend(forceRefresh: true) }.value } .task { guard didHandleInitialRoute == false else { return } didHandleInitialRoute = true if let initialOrder { selectedDraft = initialOrder } } } private var screenHeader: some View { ZStack { Text("Minhas Avaliações") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 24, weight: .semibold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 52, height: 52) .background(AppColors.surface) .clipShape(Circle()) .shadow(color: .black.opacity(0.06), radius: 8, y: 2) } .buttonStyle(.plain) Spacer() } } } 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) { screenHeader 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) .appHiddenNavigationBar() .navigationBarBackButtonHidden(true) .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 screenHeader: some View { ZStack { Text("Avaliar Pedido") .font(AppTypography.heading2) .foregroundStyle(AppColors.textPrimary) HStack { Button(action: { dismiss() }) { Image(systemName: "chevron.left") .font(.system(size: 24, weight: .semibold)) .foregroundStyle(AppColors.textPrimary) .frame(width: 52, height: 52) .background(AppColors.surface) .clipShape(Circle()) .shadow(color: .black.opacity(0.06), radius: 8, y: 2) } .buttonStyle(.plain) Spacer() } } } 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) .foregroundStyle(AppColors.textPrimary) .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)) } } }