This commit is contained in:
Daniel Arantes Loverde
2026-03-05 10:10:27 -03:00
parent a8e3631177
commit 1407f0b9de
27 changed files with 1493 additions and 84 deletions

View File

@@ -84,12 +84,8 @@ final class ApiClient {
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
// NOTE:
// /api/customer/login is strict about body fields (email/phoneNumber/otp).
// On iOS, routing this endpoint through URLSession ensures JSON body arrives as-is.
// Also route order-related polling/listing through URLSession to avoid
// intermittent cancellation observed with the shared API bridge.
if request.path == "/api/customer/login" ||
request.path == "/api/app/orders" ||
request.path.hasPrefix("/api/public/orders/") {
// On iOS, route this endpoint through URLSession to preserve raw JSON body behavior.
if request.path == "/api/customer/login" {
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
print("[ApiClient] /api/customer/login body: \(bodyText)")
}

View File

@@ -14,6 +14,37 @@ struct CustomerProfileUpdatePayload: Encodable {
}
}
struct CustomerIdentityUpdatePayload: Encodable {
let name: String
let email: String
let phoneNumber: String
let profilePicture: String?
let addressBook: [CustomerAddressPayload]
enum CodingKeys: String, CodingKey {
case name
case email
case phoneNumber
case phone
case profilePicture
case profile_picture
case addressBook = "address_book"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(email, forKey: .email)
try container.encode(phoneNumber, forKey: .phoneNumber)
try container.encode(phoneNumber, forKey: .phone)
try container.encode(addressBook, forKey: .addressBook)
if let profilePicture, profilePicture.isEmpty == false {
try container.encode(profilePicture, forKey: .profilePicture)
try container.encode(profilePicture, forKey: .profile_picture)
}
}
}
struct CustomerAddressPayload: Encodable {
let label: String?
let address: String?

View File

@@ -114,6 +114,9 @@ struct StoreSummary: Decodable {
struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
let logo: String?
let cover: String?
let deliveryTime: String?
let minOrder: Double?
let address: StoreAddressInfo?
@@ -122,6 +125,9 @@ struct StoreInfoResult: Decodable {
enum CodingKeys: String, CodingKey {
case isOpen
case statusLabel
case fantasyName
case logo
case cover
case deliveryTime
case minOrder
case address
@@ -132,6 +138,9 @@ struct StoreInfoResult: Decodable {
let container = try decoder.container(keyedBy: CodingKeys.self)
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
address = try? container.decode(StoreAddressInfo.self, forKey: .address)

View File

@@ -0,0 +1,270 @@
import Foundation
enum ReviewPlatform: String, Encodable {
case ios
case android
case web
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#elseif os(Android)
return .android
#else
return .web
#endif
}
}
struct SubmitOrderReviewPayload: Encodable {
let rate: Int
let message: String
let orderRate: Int
let orderComment: String
let orderPositiveTags: [String]
let orderImprovementTags: [String]
let deliverySentiment: String
let deliveryPositiveTags: [String]
let deliveryNegativeTags: [String]
let appNps: Int
let platform: String
}
struct ReviewTagItem: Decodable, Hashable, Identifiable {
let id: String
let label: String
}
struct ReviewOrderTagRules: Decodable {
let positiveAllowedWhenRateGte: Int?
let improvementAllowedWhenRateLte: Int?
}
struct ReviewOrderTagsCatalog: Decodable {
let positive: [ReviewTagItem]
let improvement: [ReviewTagItem]
let rules: ReviewOrderTagRules?
}
struct ReviewDeliverySentimentRule: Decodable {
let id: String
let allowedTags: [String]
}
struct ReviewDeliveryTagsCatalog: Decodable {
let sentiments: [ReviewDeliverySentimentRule]
let positive: [ReviewTagItem]
let negative: [ReviewTagItem]
}
struct ReviewNpsCatalog: Decodable {
let min: Int?
let max: Int?
}
struct ReviewAppTagsCatalog: Decodable {
let nps: ReviewNpsCatalog?
let platforms: [String]?
}
struct ReviewTagsCatalog: Decodable {
let version: String?
let order: ReviewOrderTagsCatalog?
let delivery: ReviewDeliveryTagsCatalog?
let app: ReviewAppTagsCatalog?
}
struct SubmitOrderReviewResult: Decodable {
let id: String?
let storeId: String?
let userId: String?
let clientName: String?
let rate: Int?
let message: String?
let orderRate: Int?
let orderComment: String?
let deliverySentiment: String?
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let orderId: String?
let date: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
enum CodingKeys: String, CodingKey {
case id
case storeId
case userId
case clientName
case rate
case message
case orderRate
case orderComment
case deliverySentiment
case deliveryFeedback
case itemFeedback
case improvementFeedback
case orderPositiveTags
case orderImprovementTags
case deliveryPositiveTags
case deliveryNegativeTags
case appNps
case app_nps
case platform
case orderId
case date
case editableUntil
case storeReplyUntil
case reviewWindowExpiresAt
case storeReply
case store_response
case storeResponse
case reply
case storeReplyMessage
case storeReplyAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
clientName = ApiService.decodeFlexibleString(from: container, keys: [.clientName])
rate = ApiService.decodeFlexibleInt(from: container, keys: [.rate])
message = ApiService.decodeFlexibleString(from: container, keys: [.message])
orderRate = ApiService.decodeFlexibleInt(from: container, keys: [.orderRate, .rate])
orderComment = ApiService.decodeFlexibleString(from: container, keys: [.orderComment, .message])
deliverySentiment = ApiService.decodeFlexibleString(from: container, keys: [.deliverySentiment, .deliveryFeedback])
orderPositiveTags = Self.decodeStringList(from: container, keys: [.orderPositiveTags, .itemFeedback])
orderImprovementTags = Self.decodeStringList(from: container, keys: [.orderImprovementTags, .improvementFeedback])
deliveryPositiveTags = (try? container.decode([String].self, forKey: .deliveryPositiveTags)) ?? nil
deliveryNegativeTags = (try? container.decode([String].self, forKey: .deliveryNegativeTags)) ?? nil
appNps = Self.decodeNps(from: container, keys: [.appNps, .app_nps])
platform = ApiService.decodeFlexibleString(from: container, keys: [.platform])
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
date = ApiService.decodeFlexibleString(from: container, keys: [.date])
editableUntil = ApiService.decodeFlexibleString(from: container, keys: [.editableUntil])
storeReplyUntil = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyUntil])
reviewWindowExpiresAt = ApiService.decodeFlexibleString(from: container, keys: [.reviewWindowExpiresAt])
storeReplyMessage = Self.decodeReplyMessage(from: container)
storeReplyAt = Self.decodeReplyDate(from: container)
}
private static func decodeStringList(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> [String]? {
for key in keys {
if let list = try? container.decode([String].self, forKey: key) {
return list
}
if let single = try? container.decode(String.self, forKey: key) {
let normalized = single.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.isEmpty == false {
return [normalized]
}
}
}
return nil
}
private static func decodeNps(
from container: KeyedDecodingContainer<CodingKeys>,
keys: [CodingKeys]
) -> Int? {
for key in keys {
if let value = try? container.decode(Int.self, forKey: key) {
return value
}
if let value = try? container.decode(Double.self, forKey: key) {
return Int(value.rounded())
}
if let raw = try? container.decode(String.self, forKey: key) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
if let asInt = Int(trimmed) {
return asInt
}
let normalized = trimmed.replacingOccurrences(of: ",", with: ".")
if let asDouble = Double(normalized) {
return Int(asDouble.rounded())
}
}
}
return nil
}
private static func decodeReplyMessage(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyMessage]) {
return value
}
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReply, .store_response, .storeResponse, .reply]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["message", "text", "reply", "content", "body"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
private static func decodeReplyDate(from container: KeyedDecodingContainer<CodingKeys>) -> String? {
if let value = ApiService.decodeFlexibleString(from: container, keys: [.storeReplyAt]) {
return value
}
for key in [CodingKeys.storeReply, .store_response, .storeResponse, .reply] {
if let object = try? container.decode([String: String].self, forKey: key) {
let candidates = ["date", "createdAt", "updatedAt", "repliedAt", "replyAt"]
for candidate in candidates {
let value = (object[candidate] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if value.isEmpty == false { return value }
}
}
}
return nil
}
}
struct PublicStoreReviewsResult: Decodable {
let reviews: [SubmitOrderReviewResult]
enum CodingKeys: String, CodingKey {
case reviews
case data
case items
}
init(from decoder: Decoder) throws {
if let list = try? [SubmitOrderReviewResult](from: decoder) {
reviews = list
return
}
let container = try decoder.container(keyedBy: CodingKeys.self)
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .reviews) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .data) {
reviews = list
return
}
if let list = try? container.decode([SubmitOrderReviewResult].self, forKey: .items) {
reviews = list
return
}
reviews = []
}
}

View File

@@ -148,6 +148,31 @@ final class ApiService {
return envelope
}
func updateCustomerProfile(name: String, email: String, phoneNumber: String, profilePicture: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
let payload = CustomerIdentityUpdatePayload(
name: name,
email: email,
phoneNumber: phoneNumber,
profilePicture: profilePicture,
addressBook: (customer.addressBook ?? []).map(CustomerAddressPayload.init(from:))
)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
} else {
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
}
return envelope
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
return try await saveCustomerAddress(address, replacingAddressId: nil)
}
@@ -297,7 +322,13 @@ final class ApiService {
return cached
}
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
let req = ApiRequest(
path: "/api/app/orders",
method: "GET",
module: .app,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
@@ -306,7 +337,46 @@ final class ApiService {
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
let req = ApiRequest(path: "/api/public/orders/\(orderId)", method: "GET", module: .none, requiresAuth: true)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)",
method: "GET",
module: .none,
requiresAuth: true,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
func submitOrderReview(orderId: String, payload: SubmitOrderReviewPayload) async throws -> ApiEnvelope<SubmitOrderReviewResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(
path: "/api/public/orders/\(orderId)/review",
method: "POST",
module: .none,
requiresAuth: true,
body: body
)
return try await sendEnvelope(req)
}
func reviewTagsCatalog() async throws -> ApiEnvelope<ReviewTagsCatalog> {
let req = ApiRequest(
path: "/api/public/reviews/tags",
method: "GET",
module: .none,
requiresAuth: false
)
return try await sendEnvelope(req)
}
func publicStoreReviews(storeId: String) async throws -> ApiEnvelope<PublicStoreReviewsResult> {
let req = ApiRequest(
path: "/api/public/store/\(storeId)/reviews",
method: "GET",
module: .none,
requiresAuth: false,
queryItems: [URLQueryItem(name: "_ts", value: String(Int(Date().timeIntervalSince1970)))]
)
return try await sendEnvelope(req)
}
}

View File

@@ -12,6 +12,10 @@ enum AppCacheTTL {
static let homeStores: TimeInterval = 5 * 60
}
enum AppCacheKey {
static let homeStoresLatestSnapshot = "home-stores.latest.snapshot"
}
final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache()

View File

@@ -0,0 +1,44 @@
import Foundation
enum ImageSourceResolver {
static func resolve(_ raw: String?) -> String? {
guard var normalized = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
normalized.isEmpty == false else { return nil }
normalized = normalized.replacingOccurrences(of: "\\/", with: "/")
let lower = normalized.lowercased()
if lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("data:image") {
return normalized
}
// if let base64DataURL = normalizedBase64DataURL(normalized) {
// return base64DataURL
// }
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = normalized.hasPrefix("/") ? normalized : "/\(normalized)"
return "\(base)\(path)"
}
private static func normalizedBase64DataURL(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
let payload: String
if let marker = trimmed.range(of: "base64,", options: [.caseInsensitive]) {
payload = String(trimmed[marker.upperBound...])
} else {
payload = trimmed
}
let sanitized = payload
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard sanitized.count >= 64 else { return nil }
guard Data(base64Encoded: sanitized, options: [.ignoreUnknownCharacters]) != nil else { return nil }
return "data:image/png;base64,\(sanitized)"
}
}

View File

@@ -76,13 +76,18 @@ final class OrderRealtimeTracker {
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else { return nil }
SessionStateStore.saveTrackedOrder(order)
guard response.error == false, let order = response.result else {
logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
return nil
}
clearPendingCartIfNeeded(for: order)
logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
onOrderUpdated?(order)
return order
} catch {
logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
return nil
}
}

View File

@@ -3,4 +3,5 @@ import Foundation
extension Notification.Name {
static let sessionExpired = Notification.Name("SessionExpiredNotification")
static let cartDidReset = Notification.Name("CartDidResetNotification")
static let orderReviewDidSave = Notification.Name("OrderReviewDidSaveNotification")
}

View File

@@ -37,6 +37,48 @@ private struct PersistedTrackedOrdersState: Codable {
let orders: [PublicOrderResult]
}
struct OrderReviewRecord: Codable, 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 submittedAt: String
let rating: Int
let comment: String
let orderPositiveTags: [String]?
let orderImprovementTags: [String]?
let deliverySentiment: String?
let deliveryPositiveTags: [String]?
let deliveryNegativeTags: [String]?
let appNps: Int?
let platform: String?
let editableUntil: String?
let storeReplyUntil: String?
let reviewWindowExpiresAt: String?
let storeReplyMessage: String?
let storeReplyAt: String?
}
struct OrderReviewDraftState: Codable, Hashable {
var orderId: String
var orderRate: Int
var orderComment: String
var orderPositiveTags: [String]
var orderImprovementTags: [String]
var deliverySentiment: String
var deliveryPositiveTags: [String]
var deliveryNegativeTags: [String]
var appNps: Int
var platform: String
}
private struct PersistedOrderReviewsState: Codable {
let reviews: [OrderReviewRecord]
}
enum SessionStateStore {
private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2."
@@ -44,6 +86,8 @@ enum SessionStateStore {
private static let cartKeyPrefix = "session.cart.state.v1."
private static let trackedOrdersKeyPrefix = "session.orders.tracking.v1."
private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
@@ -261,6 +305,23 @@ enum SessionStateStore {
return pendingCartOrderKeyPrefix + safe
}
private static func orderReviewsStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return orderReviewsKeyPrefix + safe
}
private static func orderReviewDraftStorageKey(for orderId: String, userKey: String?) -> String {
let scope = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: " ", with: "_")
let id = orderId
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return "\(orderReviewDraftKeyPrefix)\(scope).\(id)"
}
static func loadTrackedOrders() -> [PublicOrderResult] {
let defaults = UserDefaults.standard
let key = trackedOrdersStorageKey(for: nil)
@@ -315,4 +376,66 @@ enum SessionStateStore {
static func clearPendingCartOrder() {
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadOrderReviews() -> [OrderReviewRecord] {
let key = orderReviewsStorageKey(for: nil)
guard let data = UserDefaults.standard.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
return []
}
return decoded.reviews.sorted { lhs, rhs in
lhs.submittedAt > rhs.submittedAt
}
}
static func loadOrderReview(orderId: String) -> OrderReviewRecord? {
let normalized = orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard normalized.isEmpty == false else { return nil }
return loadOrderReviews().first { review in
review.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
}
static func hasOrderReview(orderId: String) -> Bool {
loadOrderReview(orderId: orderId) != nil
}
static func saveOrderReview(_ review: OrderReviewRecord) {
let cleanId = review.orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard cleanId.isEmpty == false else { return }
var reviews = loadOrderReviews()
if let index = reviews.firstIndex(where: {
$0.orderId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == cleanId.lowercased()
}) {
reviews[index] = review
} else {
reviews.insert(review, at: 0)
}
let payload = PersistedOrderReviewsState(reviews: reviews)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
}
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
guard let data = UserDefaults.standard.data(forKey: key),
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
return nil
}
return draft
}
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
guard let data = try? JSONEncoder().encode(draft) else { return }
UserDefaults.standard.set(data, forKey: key)
}
static func clearOrderReviewDraft(orderId: String) {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
UserDefaults.standard.removeObject(forKey: key)
}
}