payment and list

This commit is contained in:
Daniel Arantes Loverde
2026-02-24 10:19:47 -03:00
parent 3a3dc7217b
commit ca83a275ac
51 changed files with 1645 additions and 256 deletions

View File

@@ -13,6 +13,7 @@ enum NetworkError: Error, LocalizedError {
case unauthorized(String?)
case decodeError(String?)
case rateLimited(Int?)
case cancelled
case transportError(String)
var errorDescription: String? {
@@ -33,6 +34,8 @@ enum NetworkError: Error, LocalizedError {
return "Muitas requisicoes. Tente novamente em \(retryAfter)s."
}
return "Muitas requisicoes. Tente novamente."
case .cancelled:
return "Requisicao cancelada"
case .transportError(let message):
return "Erro de rede: \(message)"
}
@@ -82,7 +85,11 @@ final class ApiClient {
// 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.
if request.path == "/api/customer/login" {
// 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/") {
if let body = request.body, let bodyText = String(data: body, encoding: .utf8) {
print("[ApiClient] /api/customer/login body: \(bodyText)")
}
@@ -161,7 +168,6 @@ private extension ApiClient {
}
func mapError(_ error: Error) -> NetworkError {
printError(title: "httpReqError", msg: error.localizedDescription)
if let network = error as? NetworkError {
return network
}
@@ -176,6 +182,12 @@ private extension ApiClient {
return .unauthorized(payload?.message ?? apiMessage)
}
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return .cancelled
}
printError(title: "httpReqError", msg: error.localizedDescription)
switch nsError.code {
case 401, 403:
return .unauthorized(apiMessage)
@@ -209,6 +221,8 @@ private extension ApiClient {
while attempt <= maxAttempts {
do {
return try await perform(urlRequest, as: T.self)
} catch is CancellationError {
throw NetworkError.cancelled
} catch let error as NetworkError {
guard shouldRetry(error), attempt < maxAttempts else {
throw error
@@ -230,6 +244,9 @@ private extension ApiClient {
do {
(data, response) = try await session.data(for: request)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
throw NetworkError.cancelled
}
throw NetworkError.transportError(error.localizedDescription)
}
@@ -283,7 +300,7 @@ private extension ApiClient {
return true
case .httpError(let statusCode, _):
return statusCode >= 500
case .invalidURL, .invalidResponse, .decodeError, .unauthorized:
case .invalidURL, .invalidResponse, .decodeError, .unauthorized, .cancelled:
return false
}
}

View File

@@ -46,6 +46,7 @@ struct CreateOrderResult: Decodable {
let shortId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
@@ -55,6 +56,7 @@ struct CreateOrderResult: Decodable {
case shortId
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentPayload
case payment
@@ -62,11 +64,12 @@ struct CreateOrderResult: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try? container.decode(String.self, forKey: .id)
shortId = try? container.decode(String.self, forKey: .shortId)
status = try? container.decode(String.self, forKey: .status)
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
@@ -83,13 +86,13 @@ struct CreateOrderResult: Decodable {
}
}
struct CreateOrderPaymentInfo: Decodable {
struct CreateOrderPaymentInfo: Codable {
let method: String?
let status: String?
let pix: CreateOrderPaymentPayload?
}
struct CreateOrderPaymentPayload: Decodable {
struct CreateOrderPaymentPayload: Codable {
let copyPaste: String?
let qrCodeImage: String?
let expirationDate: String?
@@ -116,6 +119,13 @@ struct CreateOrderPaymentPayload: Decodable {
?? (try? container.decode(String.self, forKey: .encodedImage))
expirationDate = try? container.decode(String.self, forKey: .expirationDate)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(copyPaste, forKey: .copyPaste)
try container.encodeIfPresent(qrCodeImage, forKey: .qrCodeImage)
try container.encodeIfPresent(expirationDate, forKey: .expirationDate)
}
}
struct ValidateDeliveryAddressPayload: Encodable {

View File

@@ -2,9 +2,14 @@ import Foundation
struct AppOrderSummary: Decodable, Identifiable {
let id: String
let orderId: String?
let realId: String?
let shortId: String?
let total: Double?
let status: String?
let statusDetailed: String?
let statusLabel: String?
let nextAction: String?
let paymentStatus: String?
let paymentMethod: String?
let deliveryType: String?
@@ -14,39 +19,57 @@ struct AppOrderSummary: Decodable, Identifiable {
enum CodingKeys: String, CodingKey {
case id
case orderId
case realId
case shortId
case total
case status
case statusDetailed
case statusLabel
case nextAction
case paymentStatus
case paymentMethod
case deliveryType
case storeName
case date
case createdAt
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
shortId = try? container.decode(String.self, forKey: .shortId)
orderId = ApiService.decodeFlexibleString(from: container, keys: [.orderId])
id = ApiService.decodeFlexibleString(from: container, keys: [.orderId, .realId, .id]) ?? UUID().uuidString
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
status = try? container.decode(String.self, forKey: .status)
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
storeName = try? container.decode(String.self, forKey: .storeName)
createdAt = try? container.decode(String.self, forKey: .createdAt)
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
statusDetailed = ApiService.decodeFlexibleString(from: container, keys: [.statusDetailed])
statusLabel = ApiService.decodeFlexibleString(from: container, keys: [.statusLabel])
nextAction = ApiService.decodeFlexibleString(from: container, keys: [.nextAction])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt]) ?? fallbackDate
}
}
struct PublicOrderResult: Codable, Identifiable {
let id: String
let shortId: String?
let realId: String?
let status: String?
let paymentStatus: String?
let paymentConfirmed: Bool?
let paymentMethod: String?
let paymentMethodCode: String?
let paymentPayload: CreateOrderPaymentPayload?
let payment: CreateOrderPaymentInfo?
let deliveryType: String?
let deliveryTypeLabel: String?
let total: Double?
let storeName: String?
let createdAt: String?
@@ -54,16 +77,23 @@ struct PublicOrderResult: Codable, Identifiable {
let otp: String?
let customerOtp: String?
let confirmOtp: String?
let cancellationReason: String?
let items: [PublicOrderItem]
let timeline: [PublicOrderTimelineEvent]
enum CodingKeys: String, CodingKey {
case id
case shortId
case realId
case status
case paymentStatus
case paymentConfirmed
case paymentMethod
case paymentMethodCode
case paymentPayload
case payment
case deliveryType
case deliveryTypeLabel
case total
case storeName
case createdAt
@@ -71,6 +101,7 @@ struct PublicOrderResult: Codable, Identifiable {
case otp
case customerOtp
case confirmOtp
case cancellationReason
case items
case timeline
}
@@ -78,10 +109,16 @@ struct PublicOrderResult: Codable, Identifiable {
init(
id: String,
shortId: String? = nil,
realId: String? = nil,
status: String? = nil,
paymentStatus: String? = nil,
paymentConfirmed: Bool? = nil,
paymentMethod: String? = nil,
paymentMethodCode: String? = nil,
paymentPayload: CreateOrderPaymentPayload? = nil,
payment: CreateOrderPaymentInfo? = nil,
deliveryType: String? = nil,
deliveryTypeLabel: String? = nil,
total: Double? = nil,
storeName: String? = nil,
createdAt: String? = nil,
@@ -89,15 +126,22 @@ struct PublicOrderResult: Codable, Identifiable {
otp: String? = nil,
customerOtp: String? = nil,
confirmOtp: String? = nil,
cancellationReason: String? = nil,
items: [PublicOrderItem] = [],
timeline: [PublicOrderTimelineEvent] = []
) {
self.id = id
self.shortId = shortId
self.realId = realId
self.status = status
self.paymentStatus = paymentStatus
self.paymentConfirmed = paymentConfirmed
self.paymentMethod = paymentMethod
self.paymentMethodCode = paymentMethodCode
self.paymentPayload = paymentPayload
self.payment = payment
self.deliveryType = deliveryType
self.deliveryTypeLabel = deliveryTypeLabel
self.total = total
self.storeName = storeName
self.createdAt = createdAt
@@ -105,25 +149,43 @@ struct PublicOrderResult: Codable, Identifiable {
self.otp = otp
self.customerOtp = customerOtp
self.confirmOtp = confirmOtp
self.cancellationReason = cancellationReason
self.items = items
self.timeline = timeline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
shortId = try? container.decode(String.self, forKey: .shortId)
status = try? container.decode(String.self, forKey: .status)
paymentStatus = try? container.decode(String.self, forKey: .paymentStatus)
paymentMethod = try? container.decode(String.self, forKey: .paymentMethod)
deliveryType = try? container.decode(String.self, forKey: .deliveryType)
id = ApiService.decodeFlexibleString(from: container, keys: [.id]) ?? UUID().uuidString
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
realId = ApiService.decodeFlexibleString(from: container, keys: [.realId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
paymentConfirmed = try? container.decode(Bool.self, forKey: .paymentConfirmed)
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
paymentMethodCode = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethodCode])
payment = try? container.decode(CreateOrderPaymentInfo.self, forKey: .payment)
if let objectPayload = try? container.decode(CreateOrderPaymentPayload.self, forKey: .paymentPayload) {
paymentPayload = objectPayload
} else if let stringPayload = try? container.decode(String.self, forKey: .paymentPayload) {
paymentPayload = CreateOrderPaymentPayload(
copyPaste: stringPayload,
qrCodeImage: nil,
expirationDate: nil
)
} else {
paymentPayload = nil
}
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
deliveryTypeLabel = ApiService.decodeFlexibleString(from: container, keys: [.deliveryTypeLabel])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = try? container.decode(String.self, forKey: .storeName)
createdAt = try? container.decode(String.self, forKey: .createdAt)
updatedAt = try? container.decode(String.self, forKey: .updatedAt)
otp = try? container.decode(String.self, forKey: .otp)
customerOtp = try? container.decode(String.self, forKey: .customerOtp)
confirmOtp = try? container.decode(String.self, forKey: .confirmOtp)
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
otp = ApiService.decodeFlexibleString(from: container, keys: [.otp])
customerOtp = ApiService.decodeFlexibleString(from: container, keys: [.customerOtp])
confirmOtp = ApiService.decodeFlexibleString(from: container, keys: [.confirmOtp])
cancellationReason = ApiService.decodeFlexibleString(from: container, keys: [.cancellationReason])
items = (try? container.decode([PublicOrderItem].self, forKey: .items)) ?? []
timeline = (try? container.decode([PublicOrderTimelineEvent].self, forKey: .timeline)) ?? []
}
@@ -152,6 +214,10 @@ struct PublicOrderResult: Codable, Identifiable {
}
var isPaymentConfirmed: Bool {
if let paymentConfirmed {
return paymentConfirmed
}
let payment = (paymentStatus ?? "").uppercased()
let currentStatus = (status ?? "").uppercased()
if Self.looksConfirmed(payment) || Self.looksConfirmed(currentStatus) {
@@ -226,21 +292,38 @@ struct PublicOrderItem: Codable, Identifiable {
struct PublicOrderTimelineEvent: Codable, Identifiable {
let id: String
let status: String?
let label: String?
let active: Bool?
let completed: Bool?
let message: String?
let time: String?
enum CodingKeys: String, CodingKey {
case id
case status
case label
case active
case completed
case message
case time
case createdAt
case updatedAt
}
init(id: String = UUID().uuidString, status: String?, message: String?, time: String?) {
init(
id: String = UUID().uuidString,
status: String?,
label: String? = nil,
active: Bool? = nil,
completed: Bool? = nil,
message: String?,
time: String?
) {
self.id = id
self.status = status
self.label = label
self.active = active
self.completed = completed
self.message = message
self.time = time
}
@@ -249,6 +332,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
status = try? container.decode(String.self, forKey: .status)
label = try? container.decode(String.self, forKey: .label)
active = try? container.decode(Bool.self, forKey: .active)
completed = try? container.decode(Bool.self, forKey: .completed)
message = try? container.decode(String.self, forKey: .message)
time = (try? container.decode(String.self, forKey: .time))
?? (try? container.decode(String.self, forKey: .createdAt))
@@ -259,6 +345,9 @@ struct PublicOrderTimelineEvent: Codable, Identifiable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(status, forKey: .status)
try container.encodeIfPresent(label, forKey: .label)
try container.encodeIfPresent(active, forKey: .active)
try container.encodeIfPresent(completed, forKey: .completed)
try container.encodeIfPresent(message, forKey: .message)
try container.encodeIfPresent(time, forKey: .time)
}
@@ -272,6 +361,27 @@ struct OrderRealtimeUpdate: Decodable {
let status: String?
let paymentStatus: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case shortId
case storeId
case userId
case status
case paymentStatus
case updatedAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = ApiService.decodeFlexibleString(from: container, keys: [.id])
shortId = ApiService.decodeFlexibleString(from: container, keys: [.shortId])
storeId = ApiService.decodeFlexibleString(from: container, keys: [.storeId])
userId = ApiService.decodeFlexibleString(from: container, keys: [.userId])
status = ApiService.decodeFlexibleString(from: container, keys: [.status])
paymentStatus = ApiService.decodeFlexibleString(from: container, keys: [.paymentStatus])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
}
}
extension CreateOrderResult {
@@ -281,7 +391,10 @@ extension CreateOrderResult {
shortId: shortId,
status: status,
paymentStatus: paymentStatus,
paymentMethod: paymentMethod
paymentConfirmed: paymentConfirmed,
paymentMethod: paymentMethod,
paymentPayload: paymentPayload,
payment: payment
)
}
}

View File

@@ -21,6 +21,9 @@ struct ApiEnvelope<T: Decodable>: Decodable {
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
private let profileCachePrefix = "api:profile:"
private let ordersCachePrefix = "api:orders:"
private let publicCategoriesCacheKey = "api:public-categories"
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
@@ -63,9 +66,18 @@ final class ApiService {
private func expireSession(_ message: String?) {
tokenStore.clear()
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
AppContentCache.shared.invalidate(prefix: publicCategoriesCacheKey)
NotificationCenter.default.post(name: .sessionExpired, object: message)
}
private func scopedCacheSuffix() -> String {
let jwt = tokenStore.jwt ?? "anonymous"
if jwt.count <= 16 { return jwt }
return String(jwt.prefix(16))
}
// MARK: - Auth
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
@@ -121,9 +133,19 @@ final class ApiService {
return try JSONSerialization.data(withJSONObject: payload, options: [])
}
func profile() async throws -> ApiEnvelope<CustomerProfile> {
func profile(forceRefresh: Bool = false) async throws -> ApiEnvelope<CustomerProfile> {
let cacheKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<CustomerProfile> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<CustomerProfile>.self) {
return cached
}
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true)
return try await sendEnvelope(req)
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
if envelope.error == false, envelope.result != nil {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
@@ -131,7 +153,7 @@ final class ApiService {
}
func saveCustomerAddress(_ address: CustomerAddress, replacingAddressId: String?) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile()
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
@@ -151,7 +173,7 @@ final class ApiService {
}
func deleteCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
let currentProfile = try await profile()
let currentProfile = try await profile(forceRefresh: true)
guard currentProfile.error == false, let customer = currentProfile.result else {
throw NetworkError.invalidResponse
}
@@ -189,7 +211,14 @@ final class ApiService {
let payload = CustomerProfileUpdatePayload(addressBook: addressBook)
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
return try await sendEnvelope(req)
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
}
private func matchesAddress(_ lhs: CustomerAddress, _ rhs: CustomerAddress) -> Bool {
@@ -205,9 +234,18 @@ final class ApiService {
// MARK: - Stores
func listPublicCategories() async throws -> ApiEnvelope<[PublicCategory]> {
func listPublicCategories(forceRefresh: Bool = false) async throws -> ApiEnvelope<[PublicCategory]> {
if forceRefresh == false,
let cached: ApiEnvelope<[PublicCategory]> = AppContentCache.shared.value(for: publicCategoriesCacheKey, as: ApiEnvelope<[PublicCategory]>.self) {
return cached
}
let req = ApiRequest(path: "/api/public/categories", method: "GET", module: .none, requiresAuth: false)
return try await sendEnvelope(req)
let envelope: ApiEnvelope<[PublicCategory]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: publicCategoriesCacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func listStores(lat: Double? = nil, lng: Double? = nil, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
@@ -239,7 +277,11 @@ final class ApiService {
func createOrder(storeId: String, payload: CreateOrderPayload) async throws -> ApiEnvelope<CreateOrderResult> {
let body = try JSONEncoder().encode(payload)
let req = ApiRequest(path: "/api/store/\(storeId)/orders", method: "POST", module: .store, requiresAuth: true, body: body)
return try await sendEnvelope(req)
let envelope: ApiEnvelope<CreateOrderResult> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.invalidate(prefix: ordersCachePrefix)
}
return envelope
}
func validateDeliveryAddress(storeId: String, payload: ValidateDeliveryAddressPayload) async throws -> ApiEnvelope<ValidateDeliveryAddressResult> {
@@ -248,9 +290,19 @@ final class ApiService {
return try await sendEnvelope(req)
}
func listOrders() async throws -> ApiEnvelope<[AppOrderSummary]> {
func listOrders(forceRefresh: Bool = false) async throws -> ApiEnvelope<[AppOrderSummary]> {
let cacheKey = "\(ordersCachePrefix)\(scopedCacheSuffix())"
if forceRefresh == false,
let cached: ApiEnvelope<[AppOrderSummary]> = AppContentCache.shared.value(for: cacheKey, as: ApiEnvelope<[AppOrderSummary]>.self) {
return cached
}
let req = ApiRequest(path: "/api/app/orders", method: "GET", module: .app, requiresAuth: true)
return try await sendEnvelope(req)
let envelope: ApiEnvelope<[AppOrderSummary]> = try await sendEnvelope(req)
if envelope.error == false {
AppContentCache.shared.set(envelope, for: cacheKey, ttl: AppCacheTTL.twoHours)
}
return envelope
}
func publicOrder(orderId: String) async throws -> ApiEnvelope<PublicOrderResult> {
@@ -259,6 +311,27 @@ final class ApiService {
}
}
extension ApiService {
static func decodeFlexibleString<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> String? {
for key in keys {
if let value = try? container.decode(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty == false {
return trimmed
}
}
if let asInt = try? container.decode(Int.self, forKey: key) {
return String(asInt)
}
if let asDouble = try? container.decode(Double.self, forKey: key) {
if asDouble.rounded() == asDouble {
return String(Int(asDouble))
}
return String(asDouble)
}
}
return nil
}
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
for key in keys {
if let value = try? container.decode(Double.self, forKey: key) {

View File

@@ -7,6 +7,11 @@ import AppKit
typealias PlatformImage = NSImage
#endif
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
static let homeStores: TimeInterval = 5 * 60
}
final class AppContentCache: @unchecked Sendable {
static let shared = AppContentCache()

View File

@@ -5,6 +5,9 @@ import FoundationNetworking
@MainActor
final class OrderRealtimeTracker {
// Keep realtime tracking on polling to avoid socket.io handshake failures
// on environments where websocket upgrade is not available.
private let useSocketRealtime = false
private var pollingTask: Task<Void, Never>? = nil
private var socketClient: OrderSocketClient? = nil
private var activeOrderId: String? = nil
@@ -20,7 +23,7 @@ final class OrderRealtimeTracker {
await self.runPollingLoop(orderId: orderId)
}
guard let jwt, jwt.isEmpty == false else { return }
guard useSocketRealtime, let jwt, jwt.isEmpty == false else { return }
let socket = OrderSocketClient()
socket.onOrderUpdate = { [weak self] update in
guard let self else { return }
@@ -76,12 +79,42 @@ final class OrderRealtimeTracker {
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else { return nil }
SessionStateStore.saveTrackedOrder(order)
clearPendingCartIfNeeded(for: order)
onOrderUpdated?(order)
return order
} catch {
return nil
}
}
private func clearPendingCartIfNeeded(for order: PublicOrderResult) {
guard let pendingId = SessionStateStore.loadPendingCartOrderId() else { return }
let normalizedPending = pendingId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalizedPending.isEmpty { return }
let ids = [order.id, order.realId]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard ids.contains(normalizedPending) else { return }
if shouldClearCart(for: order) == false { return }
SessionStateStore.clearCart()
SessionStateStore.clearPendingCartOrder()
NotificationCenter.default.post(name: .cartDidReset, object: nil)
}
private func shouldClearCart(for order: PublicOrderResult) -> Bool {
if order.isPaymentConfirmed {
return true
}
let status = (order.status ?? "").uppercased()
if status.contains("COMPLETED") || status.contains("DELIVERED") || status.contains("RECEIVED") {
return true
}
return false
}
}
final class OrderSocketClient: @unchecked Sendable {

View File

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

View File

@@ -43,6 +43,7 @@ enum SessionStateStore {
private static let activeUserKey = "session.active.user.v1"
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."
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
@@ -253,6 +254,13 @@ enum SessionStateStore {
return trackedOrdersKeyPrefix + safe
}
private static func pendingCartOrderStorageKey(for userKey: String?) -> String {
let key = (userKey ?? loadActiveUserKey() ?? "anonymous")
.trimmingCharacters(in: .whitespacesAndNewlines)
let safe = key.replacingOccurrences(of: " ", with: "_")
return pendingCartOrderKeyPrefix + safe
}
static func loadTrackedOrders() -> [PublicOrderResult] {
let defaults = UserDefaults.standard
let key = trackedOrdersStorageKey(for: nil)
@@ -285,4 +293,26 @@ enum SessionStateStore {
static func clearTrackedOrders() {
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
}
static func savePendingCartOrderId(_ orderId: String) {
let clean = orderId.trimmingCharacters(in: .whitespacesAndNewlines)
guard clean.isEmpty == false else {
clearPendingCartOrder()
return
}
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadPendingCartOrderId() -> String? {
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
}
return nil
}
static func clearPendingCartOrder() {
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
}